Xcode Instruments for SwiftUI: Profiling Hangs, Frame Drops, and Memory Leaks in iOS 26
Profile SwiftUI apps with Xcode Instruments in Xcode 26. A real-device workflow for finding hangs, frame drops, and memory leaks, plus custom signposts with OSSignposter.
Xcode Instruments is Apple's official performance-profiling suite for SwiftUI and UIKit apps, and in Xcode 26 the toolkit that matters most for SwiftUI work is a handful of specialized instruments: Time Profiler for CPU cost, Hangs for main-thread stalls, SwiftUI for view-body counts and update phases, Animation Hitches for scroll and transition smoothness, and Allocations / Leaks for memory. To profile a SwiftUI app, build for a real device with the Release configuration, choose Product → Profile, then pick the template that matches the symptom you can see. This guide walks through each instrument the way I actually use them day to day, including the small tricks that took me a decade of shipping apps to internalize.
Always profile a Release build on a real device. Debug builds and the Simulator inflate SwiftUI update costs by 3–10× and hide most main-thread hangs.
The Hangs instrument now classifies stalls as Micro, Expected, or Severe; anything ≥ 250 ms is user-visible and Apple's public review team flags it.
The SwiftUI instrument reports View Body, View Properties, and Core Animation Commit for every update. A single misplaced @State can multiply body evaluations by 100×.
Use OSSignposter to mark long-running operations with named intervals so they show up as coloured spans in every Instruments template.
Memory leaks in SwiftUI almost always come from unowned references escaping @Observable classes or Task {} closures capturing self strongly.
Animation Hitches surfaces frame drops in real user units (ms) rather than fps. Its Hitch Ratio lets you compare screens objectively across releases.
Why profile SwiftUI apps with Instruments at all
SwiftUI hides a lot. That is its great gift and, for anyone chasing 120 Hz on a ProMotion display, its most annoying trait. The framework decides when to call your body, when to diff, when to commit to Core Animation, and when to run a transition. Most of those decisions are invisible from your call site. I came to SwiftUI from a decade of Objective-C and UIKit, and the muscle memory of "call setNeedsLayout, then check the flame graph" simply doesn't transfer. You have to ask the framework what it did, and Instruments is the only sanctioned way to get that answer.
The other reason? Xcode's in-editor previews and the debug scheme both add substantial overhead. In a Debug build with the concurrency runtime checks on, a small chart view I profiled recently spent 8.4 ms per body evaluation; the same view in a Release build on the same phone dropped to 0.9 ms. If you tune against the Debug number you'll over-optimize the wrong path, and you'll still ship a janky screen. Instruments is where you find the truth, and the truth is a real device with the Release scheme and Metal validation off.
Instruments isn't new. It has shipped with Xcode since 2007, back when the templates were called Sampler and ObjectAlloc. The SwiftUI-specific instruments landed in Xcode 14, though, and have grown steadily. Xcode 26 finally makes the SwiftUI template useful for tracking down update storms without diffing frame dumps by hand.
Setting up an Instruments session in Xcode 26
Getting a clean trace matters more than any single instrument. Start by editing your scheme (Product → Scheme → Edit Scheme), selecting Profile in the sidebar, and setting Build Configuration to Release. Uncheck Debug executable so lldb isn't attached; the debugger's breakpoint traps show up as phantom hangs. Then connect a real device (not the Simulator) because the Simulator uses your Mac's CPU and GPU and lies about both. If you're targeting the low-end floor of your app's support matrix, profile there specifically. An iPhone 11 will find hitches that an iPhone 17 Pro will happily paper over.
Launch Instruments with Product → Profile (⌘I). Xcode builds the app and opens the template picker. Pick the template that matches your symptom, not a generic one; the templates pre-configure sample intervals, trackers, and helper views that make a huge difference. For SwiftUI work the templates I open most are SwiftUI, Time Profiler, Animation Hitches, Hangs, and Allocations. Record a short session (10–30 s), exercise the exact interaction you want to measure, then stop. Long traces are hard to read and easy to overinterpret.
How do I profile a SwiftUI app in Xcode?
The short answer: choose Product → Profile, pick the SwiftUI template, tap Record, drive the app through the interaction you care about, and read the four tracks that appear. The longer answer is that the value comes from knowing which track answers which question. The View Body track shows every call to a view's body, grouped by view type and colour-coded by cost. The View Properties track shows how the framework diffed each view's inputs. The Core Animation Commit track shows what the render server actually did. Any spike that lasts longer than one frame at your target refresh rate (8.3 ms at 120 Hz, 16.7 ms at 60 Hz) is a candidate for optimization.
Honestly, here's the minimum reproducible view I use when demonstrating body-count issues to teammates. It looks innocent, but every keystroke re-evaluates the whole outer VStack:
import SwiftUI
struct SearchScreen: View {
@State private var query = ""
var body: some View {
VStack {
TextField("Search", text: $query)
ResultsList(query: query)
FooterBar()
}
}
}
Under the SwiftUI instrument, FooterBar re-renders on every keystroke even though its inputs never change. The fix is to move query into a smaller subview so its state changes stay contained. It's the same principle that made view models useful in UIKit, just enforced by the diffing algorithm rather than by convention. Related state-migration patterns are covered in the SwiftUI @Observable Macro guide, which explains exactly which reads register with the tracking runtime.
What causes hangs in SwiftUI and how do I fix them?
A hang is any period where the main thread cannot service a run-loop iteration in time. In Xcode 26 the Hangs instrument classifies them into three buckets: Micro (100–250 ms), Expected (250–500 ms), and Severe (> 500 ms). Anything above 250 ms is what App Review's automation flags, and anything above 500 ms is what users will file bug reports about. The instrument gives you the exact stack trace that was on the main thread at the moment the hang began, which is usually enough to identify the culprit.
In my experience the four repeat offenders in SwiftUI codebases are: synchronous file reads (Data(contentsOf:) from the app bundle at launch), Codable decoding of large JSON on the main actor, image decoding done implicitly by Image(uiImage:), and blocking Core Data / SwiftData fetches inside a body. The last one is particularly nasty because SwiftUI reruns the body during scroll, which means one bad fetch can compound into a scroll-long hang. I hit this exact bug shipping a recipe app last year; the fix took ten minutes once Instruments pointed at it, and about three days of guessing before I finally opened the Hangs template.
The fix is almost always to move the work off the main actor with structured concurrency, then hand a value type back to the view:
@Observable
final class SearchModel {
var results: [Recipe] = []
func load(query: String) async {
// Runs on a cooperative background thread.
let fetched = await Task.detached(priority: .userInitiated) {
try? await RecipeStore.shared.search(query)
}.value
results = fetched ?? [] // hop back to MainActor via @Observable
}
}
If you're moving legacy code across, our Swift Actors guide has the isolation rules in detail. Apple's own Improving app responsiveness page is worth bookmarking. It's where the hang thresholds are defined and where MetricKit's hang telemetry is documented.
Fixing frame drops with the Animation Hitches instrument
A "hitch" is Apple's precise term for a frame that missed its deadline. It's a better metric than fps because a screen can drop one frame and still average 59.9 fps, and that missed frame is exactly what your users noticed. The Animation Hitches instrument reports every hitch's duration in milliseconds along with the render loop phase that caused it: Commit, Render Prepare, or GPU. Alongside each hitch you get a Hitch Time Ratio, expressed in ms per second of scroll; anything above 5 ms/s is noticeable, and anything above 10 ms/s is the sort of thing reviewers screenshot.
The instrument works best when you drive the interaction deterministically. Set up an XCTest UI test that swipes at a known velocity and record against that, so week-over-week comparisons actually mean something. On the read side, look for hitches that cluster around a specific view type. That usually points to an image decode, a shadow rendered without drawingGroup(), or a Text layout with a variable-width font. If the hitch is in the GPU phase, the fix is Metal-side; our SwiftUI Metal Shaders guide has the modifiers to reach for, plus the ones to avoid on older GPUs.
The SwiftUI instrument: view body counts and update phases
The SwiftUI instrument is the one I open first whenever a screen feels sluggish. Its View Body track shows the exact count and duration of every body evaluation, and its View Properties track shows which inputs changed to cause each one. Together they answer the question "why is this view re-rendering?", which is a question the framework will otherwise never answer for you.
So, the tricks worth internalizing: sort the View Body track by Count and look for views whose count is orders of magnitude higher than the sibling views around them. Then group by Cause to see whether the trigger was a state change, an environment change, or a parent recomposition. A common finding is that a single @State variable at the top of a screen is invalidating a subtree that doesn't depend on it; hoisting the state into a smaller container view or using @Bindable on an observable model class fixes it in one commit. If you're working across the boundary between UIKit and SwiftUI, remember that any UIViewRepresentable also emits into this instrument (its updateUIView shows up under the Representable Update subtype).
The most valuable metric on the SwiftUI instrument, though, is Longest View Body. If any single body call exceeds one frame, no amount of skipping evaluations elsewhere will fix your scroll performance. The body itself has to be split. That's usually a signal to break a monolithic screen into computed subviews with their own @Observable models.
How to detect memory leaks in Swift and SwiftUI
Memory leaks in SwiftUI apps almost always come from three sources: Task {} closures that capture self strongly on a view model that outlives the view, @Observable classes holding references to each other in cycles, and closures stored in Combine or delegate patterns without [weak self]. The framework itself is careful about retain cycles, but as soon as you drop into ObjC-heritage APIs (delegates, notification observers, KVO) the old rules apply.
The Instruments workflow for finding a leak is unchanged since the ObjC days, and that consistency is a gift. Choose the Leaks template, record the interaction that you suspect leaks, use Mark Generation at the point where you expect memory to be stable, drive the app back and forth through the leaky path, then mark another generation. The Allocations track shows objects that were allocated after your first mark and were not deallocated by the second. Those are your suspects. Right-click a suspect and choose Cycles to see the exact retain graph.
final class TimerViewModel {
private var task: Task<Void, Never>?
func start() {
// BUG: captures self strongly, keeps the view model alive forever.
task = Task {
for await tick in AsyncTimer.every(.seconds(1)) {
await self.handle(tick)
}
}
}
func stop() {
task?.cancel()
}
}
The fix is to make self weak inside the closure, or to cancel the task from deinit and hold the task in an actor. For persistent stores like SwiftData or Core Data, watch for observer tokens that were registered but never removed. The Persistent Bytes column in Allocations will grow linearly if you've got one.
Custom instrumentation with OSSignposter
OSSignposter is the modern Swift replacement for the C-era os_signpost macros, and it's the single feature that most changed how I profile apps. A signpost creates a named coloured interval in every Instruments trace, which means you can annotate the boundaries of your own logical operations (a data import, a network round-trip, an offline sync) and see them lined up against the framework's own tracks. If you've ever tried to correlate a hang to a specific network call by scrolling the stack trace, signposts pay for themselves the first time you use them.
import os
let signposter = OSSignposter(subsystem: "app.recipes", category: "sync")
func performSync() async throws {
let state = signposter.beginInterval("full-sync")
defer { signposter.endInterval("full-sync", state) }
try await fetchDeltas()
signposter.emitEvent("deltas-fetched")
try await applyChanges()
}
The intervals appear under a Points of Interest track in the Time Profiler, Hangs, and SwiftUI templates alike. Event emission is essentially free (a few hundred nanoseconds), so you can leave it in Release builds. The runtime only records data when Instruments is attached. Apple's own OSSignposter reference covers the metadata format, and the WWDC23 "Analyze hangs with Instruments" session is the best walkthrough of pairing signposts with the Hangs instrument.
Reading the Time Profiler for main-thread work
The Time Profiler samples the call stack of every thread at a fixed interval (1 ms by default) and aggregates the samples into a call tree. It's a statistical view of where time was spent, not an exact trace, but for main-thread bottlenecks that's exactly what you want. The two options I always enable are Invert Call Tree (leaves at the top, so the actual hot functions are visible immediately) and Hide System Libraries (which folds Foundation, SwiftUI, and CoreFoundation into single rows so your own symbols stand out). With those two on, most main-thread problems are visible in the top five rows of the tree.
Beyond the basics, learn to filter by thread. Pick Main Thread from the thread dropdown and you get a call tree of just the work that could plausibly cause hangs. Anything not on the main thread cannot block a UI update, no matter how expensive it is. When something looks expensive in the tree, right-click and choose Reveal in Xcode. The editor jumps to the exact line, which saves the ten seconds of grepping I used to do every profiling session in the pre-Swift era.
The Time Profiler is also where you first spot serialization work that should be off the main actor: any JSON decode, any large plist read, any Core Graphics context creation. If it shows up on the main thread and takes more than a few milliseconds, wrap it in a Task.detached or hand it to an actor.
A continuous profiling workflow for real projects
Profiling is only valuable if it's repeatable. The workflow I recommend for teams is to check a set of profiling schemes into the repository (one per critical screen), automate their launch with xcodebuild -launchtesting against a specific device, and archive the resulting .trace files as build artifacts. Instruments can open a trace file from CI locally, which means you can diff yesterday's trace against today's and see whether a PR added hitches before the review even completes. Apple exposes some of this data through MetricKit as well, and the MXHangDiagnostic and MXAppLaunchMetric payloads are what you want to log from Release builds. The MetricKit documentation has the full payload shapes.
My personal rule of thumb: profile once a sprint against a fixed baseline device, keep the traces, and open Instruments the moment you notice a hitch in normal use. Don't batch profiling to the end of a release cycle. The regressions accumulate faster than you can untangle them, and the code that caused each one is no longer fresh in anyone's head. If you want a specific animation to render in less than one frame at 120 Hz, you need to be measuring it every time you touch the view, and Instruments is the only tool that will tell you honestly whether you got there.
Frequently Asked Questions
Should I profile in Debug or Release configuration?
Always Release, and always on a real device. Debug builds disable compiler optimizations and enable runtime checks that inflate SwiftUI update costs by 3–10×, so any numbers you gather there will misrepresent production performance and lead you to optimize the wrong code paths.
What is the SwiftUI instrument in Xcode Instruments?
The SwiftUI instrument is a template introduced in Xcode 14 and expanded in every release since. It shows every view body evaluation, every diffed property change, and every Core Animation commit that resulted, so you can see exactly why a view re-rendered and how long each render took.
How do I detect memory leaks in Swift without Instruments?
Xcode's Debug Memory Graph button in the debug bar catches most cycles without launching Instruments: it snapshots the object graph and highlights any strong reference cycles it finds. For interaction-based leaks or slow growth over time, though, Instruments' Allocations and Leaks templates are still the definitive tools.
What is the difference between a hitch and a hang?
A hitch is one frame missing its render deadline, typically 8–33 ms of delay. A hang is the main thread being blocked long enough that the app stops responding to input, which Apple defines as anything past a 250 ms threshold. Hitches are animation smoothness problems; hangs are unresponsiveness problems, and each has its own dedicated Instruments template.
Can I profile SwiftUI Previews with Instruments?
Not usefully. Previews run under a special harness that adds substantial overhead of its own, and the numbers don't correspond to Release-on-device performance. Build a small standalone Preview host app, run it on a real device with the Release scheme, and profile that.
A practical guide to Apple's unified logging in Swift for iOS 26: how Logger replaces print, when to use each log level, how privacy annotations protect PII, and how OSLogStore and OSSignposter power in-app diagnostics and performance tracing.
SwiftUI's PhaseAnimator walks a view through ordered animation phases from a single trigger, replacing @State flags and asyncAfter chains. Learn per-phase curves, trigger patterns, and how to combine it with CustomAnimation, plus real-world code for like buttons and toasts.
Ship real HealthKit features in SwiftUI on iOS 26: async authorization, hourly step buckets, live heart rate as an AsyncSequence, workout builders, and background delivery, with all the Info.plist gotchas laid out.