On this page 10 sections
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:
- 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.
- 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:
- Launch: the first screen is usable quickly enough that nobody wonders whether the tap registered. Apple’s guidance in Reducing your app’s launch time explains how launch is measured and what the system does during it.
- Interactions: a tap gets a visible response straight away, even if that’s a spinner. Apple’s hang documentation explains that most of Apple’s developer tools start reporting a hang when the main run loop is unresponsive for more than 250 ms, and Xcode reports hangs from the field too.
- Scrolling and animation: every frame is ready before the display needs it. On a 120 Hz ProMotion display that budget is much tighter than at 60 Hz.
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:
- Work in
initorapplication(_:didFinishLaunchingWithOptions:). Setting up analytics, crash reporting, databases and remote config all before the first frame. Most of it can wait until after the first screen appears. - Synchronous disk or network access while building the first screen, such as reading a large JSON file or waiting on a token refresh.
- Large dynamic frameworks. Each one has to be loaded and linked. Fewer, static libraries help.
- An expensive first screen. A complex view hierarchy, or images decoded at full size.
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:
- JSON decoding of a large response on the main actor;
- sorting or filtering a big array inside a SwiftUI
bodyor a computed property it reads; DateFormatterorNumberFormattercreated inside a loop (create them once, or use theformatted()APIs);- synchronous file reads, or waiting on a semaphore;
- image decoding and resizing at full resolution.
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:
- Views that update too often. A model property that changes many times a second (a timer, a scroll offset, a download’s progress) read by a large view refreshes all of it. Read fast-changing values in a small subview so only that subview updates.
- Expensive bodies. Formatting, filtering or building large arrays inside
body. Precompute in the model. - Unstable identity.
ForEachover values without stable IDs, or using\.selfon items that change, makes SwiftUI rebuild rows instead of updating them. Use real identifiers. - Eager stacks. A
VStackinside aScrollViewbuilds every row up front. UseLazyVStackorListfor long content. - Full-size images. Decode thumbnails at display size rather than scaling a 12-megapixel photo in the view.
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 Allocations instrument shows what’s allocated and how memory changes over time. Use Mark Generation before and after repeating an action, such as opening and closing a screen five times. Anything that grows each generation is being kept alive.
- The Leaks instrument finds memory nothing refers to any more.
- Xcode’s Memory Graph Debugger (the button in the debug bar) shows who holds a reference to an object, which is usually the quickest way to find a retain cycle.
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
- Reproduce the problem on a device with a release build.
- Record with the narrowest template that shows it: App Launch, Time Profiler, Animation Hitches, SwiftUI, Allocations.
- Find the heaviest frames from your own code.
- Change one thing.
- Record again and compare against the numbers you wrote down.
- 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
- Profile on a real, preferably older, device with a release build.
- Defer everything that isn’t needed for the first screen; guard launch with
XCTApplicationLaunchMetric. - Hangs are main-thread work: find them in Time Profiler and Organizer, then move the work off the main actor.
- In SwiftUI, keep bodies cheap, identity stable and fast-changing state in small subviews.
- Use signposts for your own milestones and MetricKit or Organizer for real-world data.
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.