Start a project

Article · iOS · Performance · Instruments · 8 min read

Finding and fixing iOS performance problems with Instruments

People rarely describe an app as slow in technical terms. They say it “feels heavy”, that it “freezes for a second” when they tap something, or they just stop opening it. Underneath, it’s nearly always one of four problems: a slow launch, hangs on the main thread, dropped frames while scrolling or animating, or memory that grows until the system steps in. This guide shows how to find each one with Instruments and the other tools in Xcode, and the fixes that usually follow.

On this page 10 sections
  1. Measure on a real device, in a release build
  2. Decide what “fast enough” means
  3. Slow launch
  4. Hangs: the main thread is busy
  5. Marking your own code with signposts
  6. Janky scrolling and slow SwiftUI updates
  7. Memory growth and leaks
  8. Watching real-world performance with MetricKit
  9. A workflow that sticks
  10. Key takeaways

Battery drain is a close relative with its own toolset; it has its own article, Optimising battery efficiency.

Measure on a real device, in a release build

Before opening any tool, two rules:

  1. Profile on a device, not the simulator. The simulator runs on your Mac’s processor and memory and tells you very little about an iPhone that’s a few years old.
  2. Profile a release build. Debug builds skip optimisations, so loops and generic code can be many times slower than what users run. Xcode’s Product › Profile command builds with the Release configuration by default for this reason.

If you can, keep an older supported iPhone for testing. Performance problems tend to appear first on the oldest devices your app supports.

Decide what “fast enough” means

Without a target you can’t tell whether a change helped. Useful targets:

Write the targets down next to the build you measured, so you can compare later builds against the same numbers.

Slow launch

Use the App Launch template in Instruments. It shows the phases of launch and what your code does in each, from the process starting to the first frame.

The usual causes, in rough order of how often they turn up:

The fix is nearly always to show something useful first and do the rest afterwards:

@main
struct ShopApp: App {
    @State private var catalogue = CatalogueModel()

    var body: some Scene {
        WindowGroup {
            HomeView()
                .environment(catalogue)
                .task {
                    // After the first frame: warm caches, refresh content, start services.
                    await catalogue.refreshIfStale()
                    await Services.startDeferred()
                }
        }
    }
}

Guard against regressions with a launch test that runs on every pull request:

import XCTest

final class LaunchPerformanceTests: XCTestCase {
    func testLaunchPerformance() throws {
        measure(metrics: [XCTApplicationLaunchMetric()]) {
            XCUIApplication().launch()
        }
    }
}

Set a baseline in Xcode after the first run, and the test fails when launch gets noticeably slower.

Hangs: the main thread is busy

A hang happens when the main thread is busy with something other than drawing and handling touches. In Instruments, the Hangs instrument flags them on the timeline, and you can add it alongside the Time Profiler, which samples what each thread is doing. Select one, and the call tree below shows what the main thread was doing.

Read the call tree with Hide System Libraries and Invert Call Tree switched on. The heaviest frames from your own code float to the top. Typical culprits:

The fix is to move the work off the main actor and only come back to publish the result. With Swift concurrency, that’s an @concurrent function or a separate actor; the Swift concurrency guide shows both.

After release, Xcode’s Organizer shows hang reports from devices of people who share analytics with developers, grouped by the code that was running. It’s one of the most useful screens in Xcode and one of the least visited.

Marking your own code with signposts

Instruments shows system activity well, but it doesn’t know that “the moment the feed finished loading” matters to you. Signposts add your own intervals to the timeline:

import os

let feedSignposter = OSSignposter(subsystem: "uk.leej.example", category: "Feed")

func loadFeed() async throws -> [Post] {
    let state = feedSignposter.beginInterval("Load feed")
    defer { feedSignposter.endInterval("Load feed", state) }

    let posts = try await api.feed()
    return posts
}

Add the os_signpost instrument (or the Points of Interest track) and each call shows up as a labelled bar. Now you can see whether a slow screen is waiting on the network, on decoding or on drawing. Signposts cost very little when nobody is recording, so they can stay in release builds.

Janky scrolling and slow SwiftUI updates

Dropped frames show up as stutter when scrolling or during animations. Start with the Animation Hitches template, which marks frames that arrived late. Then work out why.

For SwiftUI, Xcode 26 added a dedicated SwiftUI instrument that shows long view body updates and helps trace which state change caused which views to update. Apple introduced it in the WWDC25 session Optimize SwiftUI performance with Instruments. The things it most often points to:

Here is the “small subview” fix in practice:

struct DownloadRow: View {
    let download: Download   // @Observable, progress changes many times a second

    var body: some View {
        HStack {
            Text(download.fileName)
            Spacer()
            DownloadProgress(download: download) // only this view reads progress
        }
    }
}

struct DownloadProgress: View {
    let download: Download

    var body: some View {
        ProgressView(value: download.progress)
            .frame(width: 80)
    }
}

Memory growth and leaks

Memory problems often don’t crash in testing; the system ends the app in the background, and users experience it as the app “restarting” every time they come back.

The classic cycle is a closure stored on an object that captures that object strongly. With Swift concurrency, a long-running Task that captures self keeps self alive until the task ends. Cancel tasks when their owner goes away, or let SwiftUI’s .task do it.

Watching real-world performance with MetricKit

Testing tells you about your devices. MetricKit tells you about your users’ devices. The system collects launch times, hang rates, memory, disk writes and crash diagnostics, and delivers them to your app about once a day:

import MetricKit

final class MetricsReceiver: NSObject, MXMetricManagerSubscriber {
    func didReceive(_ payloads: [MXMetricPayload]) {
        for payload in payloads {
            upload(payload.jsonRepresentation())
        }
    }

    func didReceive(_ payloads: [MXDiagnosticPayload]) {
        for payload in payloads {
            upload(payload.jsonRepresentation())
        }
    }

    private func upload(_ json: Data) {
        // Send to your own endpoint, or log it for review.
    }
}

// At launch, keep a strong reference to the receiver:
let metricsReceiver = MetricsReceiver()
MXMetricManager.shared.add(metricsReceiver)

Much of the same data, aggregated, also appears in Xcode’s Organizer under Metrics, without any code. MetricKit adds detail per payload and lets you send it to your own dashboard. See the MetricKit documentation.

A workflow that sticks

  1. Reproduce the problem on a device with a release build.
  2. Record with the narrowest template that shows it: App Launch, Time Profiler, Animation Hitches, SwiftUI, Allocations.
  3. Find the heaviest frames from your own code.
  4. Change one thing.
  5. Record again and compare against the numbers you wrote down.
  6. Add a test or a signpost so you’ll notice if it comes back.

Most performance work fails at step 4, by changing several things at once and not knowing which one helped.

Key takeaways

If your app feels slower than it should and you’d like a second pair of eyes on the traces, send me a note with what you’re seeing.

Written by Lee Johnson. Published 30 October 2026.

All articles · Swift A–Z · Apps

Questions, or an app in mind?

Let's bring your ideas to life. Whether you're envisioning a game-changing Apple Watch app or a transformative iOS experience, I'm here to make it happen.

Start a project