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:

MetricBeforeAfter
Scroll frame rate42 fps60 fps
Image memory (200 landmarks)380 MB48 MB
List initial render1.2s0.15s
View body evaluations per scroll200+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.