Skip to content
Commitwise
Menu
iOS Development

Performance and Instruments

Find and fix what makes an iOS app slow, measuring release builds on a device with Instruments (Time Profiler, Hangs, the SwiftUI instrument, Allocations), keeping SwiftUI bodies cheap with stable identity, moving work off the main actor for real, speeding up launch, and catching regressions with signposts, XCTest metrics and MetricKit.

advanced

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

The photo journal app feels sluggish: tapping an entry takes half a second to respond, scrolling the timeline hitches every few rows, and launching takes long enough that users see the splash screen twice as long as they did last year. The team “optimized” by adding DispatchQueue.global().async in a few places and replacing a map with a for loop. It didn’t help, and nobody could say why.

Performance work on iOS starts with Instruments, the profiling tool that comes with Xcode: it shows exactly where time goes, on the main thread and elsewhere. Then the fixes are usually few and specific: work on the main thread that shouldn’t be there, SwiftUI views doing too much or being rebuilt for no reason, and launch work that could wait.

New to some of these? Open what you need, or skip ahead.

Basics:What is the main thread (main actor)?

The main thread draws the UI and handles touches. In Swift concurrency it’s the main actor: code marked @MainActor, including all SwiftUI views and UIKit, runs there. If it’s busy, the app can’t respond. A pause of a quarter of a second or more is called a hang.

Basics:Debug and release builds

Xcode’s Run button builds the Debug configuration: no compiler optimizations, extra checks. Swift code can be many times slower there than in the Release build users get. Instruments profiles with Release by default (Product → Profile), which is what you want.

Part Not started

Measure with Instruments, then fix the main thread

To profile: connect a real iPhone (ideally not the newest one), choose Product → Profile in Xcode, and pick a template:

  • Time Profiler: shows which functions use the CPU, on which thread. Look at the main thread first.
  • Hangs: marks every moment the main thread was blocked long enough for the user to notice.
  • SwiftUI: shows which views’ bodies ran, how often and how long.
  • Allocations / Leaks: memory use and objects that are never freed.

The usual SwiftUI problems are:

  • Expensive work in body: creating a DateFormatter, sorting a big array or decoding an image every time the view updates.
  • Unstable identity: giving list rows ids that change every update (like a new UUID()), so SwiftUI thinks every row is new, throws the old ones away and builds new ones.
  • One huge view reading lots of state, so any change rebuilds everything.

Profile Release builds on a physical device; simulators run on your Mac’s CPU and are meaningless for performance. Time Profiler with “Hide System Libraries” and inverted call trees finds hot code; Hangs (and the Hang Risks in Xcode’s thread performance checker) attributes main-thread stalls of 250 ms and more; the SwiftUI instrument (rebuilt in Xcode 26 with update causes) shows body evaluation counts, durations and what triggered them; Self._printChanges() in debug answers “why did this body run?”. SwiftUI performance comes down to: cheap, side-effect-free body; stable identity (ForEach over Identifiable models with persistent ids; never id: \.self on non-unique values or ids generated in body); narrow dependencies (Observation tracks per property, so split large views into subviews that read only what they need, and pass values rather than whole models where it helps); and lazy containers for long content. Concurrency pitfall: an async function on a @MainActor type runs on the main actor between suspension points, so CPU-heavy work inside it still blocks the UI; move it to a nonisolated async function (which, since Swift 6.2 with approachable concurrency enabled, needs @concurrent to actually leave the caller’s actor) or a dedicated actor.

struct JournalEntry: Identifiable, Hashable {
    let id: UUID                                   // persistent: stored with the entry
    let date: Date
    let text: String
    let thumbnailURL: URL
}

@Observable @MainActor
final class TimelineModel {
    private(set) var rows: [TimelineRow] = []      // sorted and formatted once, when data changes

    private static let dateFormat = Date.FormatStyle(date: .abbreviated, time: .shortened)

    func update(with entries: [JournalEntry]) async {
        let rows = await Self.makeRows(entries)    // off the main actor
        self.rows = rows
    }

    @concurrent
    nonisolated private static func makeRows(_ entries: [JournalEntry]) async -> [TimelineRow] {
        entries
            .sorted { $0.date > $1.date }
            .map { TimelineRow(id: $0.id, dateText: $0.date.formatted(dateFormat),
                               preview: String($0.text.prefix(140)), thumbnailURL: $0.thumbnailURL) }
    }
}

struct TimelineView: View {
    let model: TimelineModel

    var body: some View {
        List(model.rows) { row in                  // stable ids: rows are reused, not rebuilt
            TimelineRowView(row: row)
        }
    }
}

struct TimelineRowView: View {
    let row: TimelineRow                           // a value: unchanged rows skip body
    var body: some View {
        HStack {
            Thumbnail(url: row.thumbnailURL, size: 60)   // downsampled, cached loader
            VStack(alignment: .leading) {
                Text(row.dateText).font(.caption).foregroundStyle(.secondary)
                Text(row.preview).lineLimit(2)
            }
        }
    }
}
  • Sorting and formatting happen once, when the data changes, in the model, not every time a row’s body runs.
  • makeRows runs off the main actor, so sorting thousands of entries doesn’t freeze scrolling.
  • Rows have persistent ids from the stored entries, so SwiftUI keeps existing rows when the list changes.
  • Each row receives a small value. If it didn’t change, SwiftUI can skip running that row’s body at all.
Spot the bugEvery update rebuilds the whole list

Tap the line with the bug.

struct TimelineView: View {
    let entries: [JournalEntry]

    struct Row: Identifiable {
        let id = UUID()
        let entry: JournalEntry
    }

    var body: some View {
        let formatter = DateFormatter()
        formatter.dateStyle = .medium
        let rows = entries.sorted { $0.date > $1.date }.map { Row(entry: $0) }

        List(rows) { row in
            Text(formatter.string(from: row.entry.date))
            Text(row.entry.text).lineLimit(2)
        }
    }
}

Predict the output

A @MainActor view model has func export() async { let pdf = renderPDF(entries) /* 2 s of CPU work */; await upload(pdf) }. It's called from a button with Task { await model.export() }. Does the UI stay responsive during rendering?

  • Release builds on physical devices; Time Profiler (inverted, hide system libraries), Hangs (250 ms or more), SwiftUI instrument (update counts, durations, causes), Allocations/Leaks.
  • SwiftUI: side-effect-free cheap body; stable persistent identity (no UUID() in body, no id: \.self on non-unique values); split views to narrow Observation dependencies; pass values to rows.
  • Self._printChanges() to diagnose body invalidations in debug.
  • async ≠ background: @MainActor async functions run synchronous work on the main actor; use @concurrent nonisolated functions (Swift 6.2) or actors for CPU work.

Check this part

  1. Where should you profile an app's performance?

  2. Why is let id = UUID() in a row struct built inside body a problem?

  3. A @MainActor async method does 2 seconds of CPU work before its first await. Result?

Profile release builds on a real device with Instruments: Time Profiler for CPU, the Hangs instrument for main-thread stalls, the SwiftUI instrument for view body updates. In SwiftUI, keep body cheap (no formatters, sorting or decoding), give ForEach stable identity, and split views so state changes update only what reads them. async doesn't mean background: heavy work needs a nonisolated or detached context.

Part Not started

Launch time and catching regressions

Launch has two phases:

  1. Before your code runs: iOS loads the app and its libraries. Lots of third-party frameworks make this slower.
  2. Before the first frame: your App struct is created, its init runs, and the first screen’s body is evaluated. Anything slow here (reading a big file, setting up five SDKs, waiting for the network) is time the user stares at the launch screen.

The fix is the same as on any platform: do only what the first screen needs, and start everything else afterwards, in the background or when it’s first used.

To keep an app fast, measure the same things repeatedly:

  • Signposts mark the start and end of important work (“load timeline”), and show up in Instruments.
  • Performance tests in XCTest measure launch time or a signposted interval and fail if it gets much worse.
  • MetricKit and Xcode Organizer report launch times, hangs and memory from real users’ devices.

Pre-main time is driven by dynamic library loading (merge or statically link small frameworks; Xcode’s mergeable libraries), Objective-C class realization and static initializers (+load, C++ static constructors, Swift globals are lazy). Post-main: the App initializer, scene setup and first body evaluation; anything synchronous here (Core Data or SwiftData store migration, keychain reads, UserDefaults of large blobs, SDK initialization doing network or disk I/O) delays first frame. iOS also prewarms apps (running pre-main before the user taps), so measuring with XCTApplicationLaunchMetric(waitUntilResponsive: true) on a device is the trustworthy number. Instrument critical paths with OSSignposter intervals, assert them in measure(metrics: [XCTOSSignpostMetric(...)]) with baselines in CI on fixed devices, and close the loop with MetricKit (MXMetricPayload daily aggregates: launch, hang rate, memory, disk writes; MXDiagnosticPayload with hang and crash call stacks) and the Organizer’s launch, hang and terminations reports.

import os

enum Perf {
    static let signposter = OSSignposter(subsystem: "com.example.journal", category: "Timeline")
}

extension TimelineModel {
    func reload() async {
        let state = Perf.signposter.beginInterval("LoadTimeline")       // visible in Instruments
        defer { Perf.signposter.endInterval("LoadTimeline", state) }
        if let entries = try? await store.entries() { await update(with: entries) }
    }
}

// UI test target: fails when launch or timeline loading regresses beyond the baseline
final class PerformanceTests: XCTestCase {
    func testLaunch() {
        measure(metrics: [XCTApplicationLaunchMetric(waitUntilResponsive: true)]) {
            XCUIApplication().launch()
        }
    }

    func testTimelineLoad() {
        let app = XCUIApplication()
        let metric = XCTOSSignpostMetric(subsystem: "com.example.journal", category: "Timeline", name: "LoadTimeline")
        measure(metrics: [metric]) {
            app.launch()
            _ = app.collectionViews.firstMatch.waitForExistence(timeout: 5)
            app.terminate()
        }
    }
}

// Real-world data from users' devices, delivered about once a day
final class MetricsReceiver: NSObject, MXMetricManagerSubscriber {
    func start() { MXMetricManager.shared.add(self) }

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

    func didReceive(_ payloads: [MXDiagnosticPayload]) {
        for payload in payloads { upload(payload.jsonRepresentation()) }   // hang and crash call stacks
    }
}

Production puzzles

Predict the output

The App initializer creates a ModelContainer for SwiftData, and the new version adds a migration that rewrites every entry. Users with 10,000 entries update the app. What do they see on first launch?

Predict the output

Allocations shows memory climbing by about 50 MB each time the user opens and closes an entry's detail screen. Leaks shows nothing. What's the likely cause?

Swift note

Swift 6.2’s approachable concurrency settings make nonisolated async functions run on the caller’s actor by default (nonisolated(nonsending)); @concurrent explicitly moves them to the global concurrent executor. With the older default, nonisolated async functions always left the actor. Check the project’s build settings before assuming where an async function runs, and verify in Time Profiler by looking at which thread the work lands on.

A performance checklist

  • No DateFormatter, NumberFormatter, JSONDecoder or image decoding inside body.
  • Images downsampled to display size and cached (see image loading and caching).
  • List and LazyVStack for long content; plain VStack only for short, fixed content.
  • Nothing slow in App.init or the first screen’s initializer.
  • Signposts around the app’s key operations, with XCTest metrics guarding them in CI.
Exercisemedium

Find the hang

Users report that tapping “Share” on an entry freezes the app for about a second before the share sheet appears. Describe how you’d find the cause with Instruments, and two likely fixes.

›

Which instrument shows a frozen main thread, and how do you see what the main thread was doing at that moment?

SolutionTry it yourself first
  1. Profile a Release build on a device with the Time Profiler template (it includes Hangs). Tap Share a few times.
  2. Find the hang markers on the timeline, select one, and look at the main thread’s heaviest stack in that range, with system libraries hidden.
  3. Likely culprits: rendering the entry to an image or PDF synchronously, loading a full-resolution photo from disk, or creating the share items on the main actor.

Fixes:

  • Prepare the shareable content before the tap (when the detail screen opens), or render it off the main actor and show the share sheet when it’s ready, with a small progress indicator.
  • Share a URL or a lightweight item provider that produces the file lazily (ShareLink with a Transferable type), instead of creating the file up front.
  • Pre-main: dylib loading (mergeable libraries), static initializers; post-main: App.init, scene setup, first body. Defer SDKs, migrations, I/O.
  • Prewarming skews naive measurements: XCTApplicationLaunchMetric(waitUntilResponsive: true) on devices.
  • OSSignposter intervals + XCTOSSignpostMetric baselines in CI; MetricKit metric and diagnostic payloads; Organizer launch, hang and termination reports.
  • Abandoned memory isn’t a leak: use Allocations generation marking.
  • Swift 6.2: nonisolated async may run on the caller’s actor; @concurrent to offload.

Check this part

  1. What should App.init do at launch?

  2. What does MetricKit give you that Instruments doesn't?

  3. Memory grows on every open and close of a screen, but Leaks reports nothing. Next step?

Launch time is spent before main (dynamic libraries, static initializers) and before the first frame (App init, first view). Defer non-essential setup, avoid synchronous I/O and network at launch. Mark important intervals with OSSignposter, guard them with XCTest performance tests (XCTApplicationLaunchMetric, XCTOSSignpostMetric), and watch real-world data from MetricKit and the Xcode Organizer.

Key takeaways

  • Profile Release builds on a real device with Instruments before changing code.
  • Keep SwiftUI body cheap and give list rows stable ids.
  • async code on the main actor still blocks it; move heavy work elsewhere.
  • Launch fast by doing only what the first screen needs.
  • Time Profiler, Hangs and the SwiftUI instrument find causes; _printChanges explains invalidations.
  • Stable identity, narrow dependencies and value-typed row models keep updates small.
  • Isolation, not async, decides where code runs.
  • Signposts, XCTest metrics and MetricKit catch regressions in CI and in the field.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.