Performance Profiling with Instruments
The Landmarks app works. It authenticates users, sends push notifications, syncs offline data, and the server has observability. But "works" and "feels good" are different things. A list that stutters during scrolling, an image that blocks the main thread, or a view body that recomputes too often - these are the things that make an app feel cheap even when the features are solid.
This post is about finding those problems before your users do. We'll use Instruments to profile real performance issues in the Landmarks app and fix each one.
The companion repo has the complete working code: View Code
The Profiling Mindset
Don't guess where the bottleneck is. Measure. Developers are consistently terrible at predicting what's slow. The line you think is expensive probably isn't. The one you'd never suspect probably is.
Profile on a real device, not the simulator. The simulator runs on your Mac's hardware with a completely different CPU architecture and memory profile. What's fast on an M-series Mac might be slow on a two-year-old iPhone.
Time Profiler: Finding CPU Bottlenecks
Open your project in Xcode, choose Product > Profile (or Cmd+I), and select the Time Profiler template. Run the app and scroll through the landmark list.
The Time Profiler shows you where CPU time is being spent. Look at the call tree and sort by "Self Weight" to find the functions that are actually doing the work (not just calling other functions).
What We Found
In the Landmarks app, the Time Profiler revealed three issues:
1. Image decoding on the main thread
Every time a LandmarkRow appeared, it loaded and decoded a full-resolution image synchronously. This blocks the main thread during scrolling:
// Before: blocking the main thread
struct LandmarkRow: View {
let landmark: Landmark
var body: some View {
HStack {
Image(landmark.imageName)
.resizable()
.frame(width: 50, height: 50)
.clipShape(Circle())
Text(landmark.name)
}
}
}
The fix is to use AsyncImage for remote images, or for local images, downsample on a background thread:
// After: decoded off the main thread and downsampled
struct LandmarkRow: View {
let landmark: Landmark
@State private var thumbnail: UIImage?
var body: some View {
HStack {
Group {
if let thumbnail {
Image(uiImage: thumbnail)
.clipShape(Circle())
} else {
Circle()
.fill(Color.gray.opacity(0.2))
}
}
.frame(width: 50, height: 50)
Text(landmark.name)
}
.task(id: landmark.id) {
thumbnail = await downsample(
imageName: landmark.imageName,
to: CGSize(width: 100, height: 100) // 2x for retina
)
}
}
}
The downsampling function uses CGImageSourceCreateThumbnailAtIndex, which is significantly cheaper than loading the full image and then scaling it:
func downsample(imageName: String, to size: CGSize) async -> UIImage? {
await Task.detached(priority: .background) {
guard let url = Bundle.main.url(forResource: imageName, withExtension: "jpg"),
let source = CGImageSourceCreateWithURL(url as CFURL, nil) else {
return nil
}
let maxDimension = max(size.width, size.height) * UIScreen.main.scale
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxDimension
]
guard let thumbnail = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
return nil
}
return UIImage(cgImage: thumbnail)
}.value
}
2. Unnecessary view body evaluations
The Time Profiler showed LandmarkListView.body being called far more often than expected. The culprit was a parent view that held an @Observable store with a frequently changing property (sync status), causing the entire view tree to re-evaluate.
The fix is to isolate observation boundaries. Only the views that need the changing data should observe it:
// Before: the whole list re-renders when syncStatus changes
struct LandmarksNavigationView: View {
@State private var store: LandmarkStore
var body: some View {
NavigationStack {
VStack {
SyncStatusView(lastSynced: store.lastSynced) // Changes often
LandmarkListView(landmarks: store.landmarks) // Re-evaluates unnecessarily
}
}
}
}
// After: sync status in its own observed scope
struct LandmarksNavigationView: View {
@State private var store: LandmarkStore
var body: some View {
NavigationStack {
VStack {
SyncStatusBadge(store: store) // Isolated observation
LandmarkListView(landmarks: store.landmarks)
}
}
}
}
// Only this small view re-renders when sync status changes
struct SyncStatusBadge: View {
let store: LandmarkStore
var body: some View {
SyncStatusView(lastSynced: store.lastSynced)
}
}
Allocations: Finding Memory Issues
Switch to the Allocations instrument. This shows you how much memory the app is using and where allocations are happening.
What We Found
3. Images not being released
Scrolling through the full landmark list accumulated hundreds of megabytes of decoded image data. The images were being cached by the system but never released under memory pressure.
The fix is to use a cache with a size limit:
actor ThumbnailCache {
static let shared = ThumbnailCache()
private let cache = NSCache<NSString, UIImage>()
init() {
cache.countLimit = 100
cache.totalCostLimit = 50 * 1024 * 1024 // 50 MB
}
func thumbnail(for imageName: String, size: CGSize) async -> UIImage? {
let key = "\(imageName)_\(Int(size.width))x\(Int(size.height))" as NSString
if let cached = cache.object(forKey: key) {
return cached
}
guard let image = await downsample(imageName: imageName, to: size) else {
return nil
}
cache.setObject(image, forKey: key)
return image
}
}
NSCache automatically evicts entries under memory pressure, which is exactly what you want for image caches. Don't use a Dictionary for this - it won't release memory when the system needs it.
SwiftUI View Body Counts
Xcode has a built-in way to count view body evaluations. Add this launch argument in your scheme:
-com.apple.SwiftUI.EnableBodyCallCountLogging YES
Or, for a more targeted approach, add a counter to specific views during profiling:
struct LandmarkRow: View {
let landmark: Landmark
@State private var thumbnail: UIImage?
var body: some View {
let _ = Self._printChanges() // Debug only - remove before shipping
HStack {
// ...
}
}
}
Self._printChanges() prints to the console whenever the view's body is called and what property triggered the re-evaluation. It's invaluable during development but should never ship.
LazyVStack vs VStack
If you're rendering a list of landmarks without List or LazyVStack, every row gets created immediately - even the ones off screen. For 200+ landmarks, that's a lot of wasted work:
// Before: all rows created at once
ScrollView {
VStack {
ForEach(landmarks) { landmark in
LandmarkRow(landmark: landmark)
}
}
}
// After: only visible rows are created
ScrollView {
LazyVStack {
ForEach(landmarks) { landmark in
LandmarkRow(landmark: landmark)
}
}
}
The List view is lazy by default, but if you're using ScrollView with a custom layout, you need LazyVStack explicitly.
Network Profiling
Use the Network instrument to inspect API calls. This showed that the app was making duplicate requests on launch - one from the view's .task modifier and another from the sync engine running simultaneously.
The fix is to make the sync engine the single source of truth for network fetches:
@Observable
final class LandmarkStore {
private(set) var landmarks: [Landmark] = []
private(set) var lastSynced: Date?
private var isFetching = false
func fetchIfNeeded(service: LandmarkService) async {
guard !isFetching else { return } // Prevent duplicate fetches
isFetching = true
defer { isFetching = false }
do {
landmarks = try await service.fetchAll()
lastSynced = .now
} catch {
// Fall back to whatever we already have
}
}
}
The isFetching guard prevents concurrent duplicate requests. Simple, effective, no Combine needed.
Measuring the Impact
After applying all fixes, profile again to verify:
| Metric | Before | After |
|---|---|---|
| Scroll frame rate | 42 fps | 60 fps |
| Image memory (200 landmarks) | 380 MB | 48 MB |
| List initial render | 1.2s | 0.15s |
| View body evaluations per scroll | 200+ | 12 |
The numbers tell the story. Profile, fix, measure. No guessing.
What's Next
The app is fast, observable, and offline-capable. In the next post, we'll build a release pipeline using GitHub Actions and stable branches so you can ship updates to users with confidence.