Debugging Performance Issues with Instruments: Leaks, Time Profiler, and Allocations
Your app passed code review, sailed through QA, and shipped. Two weeks later, support tickets start arriving: “the app gets slower the longer I use it,” “it crashed after about an hour,” “scrolling through the movie list feels janky.” Xcode’s debugger shows green memory bars and no obvious errors. The problem is real but invisible — until you reach for Instruments.
Instruments is the profiling and tracing toolkit built
into Xcode. It doesn’t just show you that something is wrong — it shows you exactly where time is being spent,
exactly which object is leaking, and exactly which view is causing your scroll to drop frames. This guide covers the
four instruments you’ll reach for most in production debugging: Time Profiler, Leaks, Allocations, and SwiftUI View
Body. We’ll also cover os_signpost for custom instrumentation. This guide assumes you know
ARC and retain cycles — we’ll be seeing them in the wild.
Contents
- The Problem
- Launching Instruments
- Time Profiler: Finding CPU Bottlenecks
- Leaks Instrument: Finding Retain Cycles
- Allocations Instrument: Finding Memory Growth
- SwiftUI View Body: Diagnosing Unnecessary Redraws
- Network Instrument: Waterfall Analysis
- Advanced Usage
- When to Use (and When Not To)
- Summary
The Problem
Consider a Pixar film browser app. It loads a catalogue of films, supports search, plays trailers, and caches poster art. It works fine during development against a dataset of 20 films. In production, with a full catalogue of 10,000 entries and real users navigating between scenes for 30 minutes, three distinct symptoms emerge:
Symptom 1 — Gradual slowdown: The app grows sluggish over time. Memory climbs steadily in Xcode’s Debug Navigator. This is the fingerprint of a memory leak — objects being allocated but never released.
Symptom 2 — Scroll jank: Scrolling the film list drops below 60 fps. The main thread is being blocked by work that should happen off it. This is the fingerprint of a CPU bottleneck on the main thread.
Symptom 3 — Excessive battery drain: Users on iOS 17+ see your app at the top of the battery usage list. Background CPU cycles and unnecessary view redraws compound the problem.
Each symptom maps to a different Instruments template. Here’s how to diagnose all three.
Launching Instruments
Never profile against a Debug build — the compiler’s optimizations are disabled, and you’ll measure artificial overhead. Always profile a Release build via Product → Profile (⌘I). Xcode builds the app in Release configuration and hands it to Instruments.
When Instruments opens, you’ll see the template chooser. The most commonly used templates are:
- Time Profiler — CPU samples over time; identifies bottlenecks
- Leaks — detects abandoned memory and retain cycles
- Allocations — tracks every heap allocation; finds memory growth
- SwiftUI — counts view body invocations
- Network — traces URLSession activity
- Hangs — detects main thread unresponsiveness (iOS 16+)
For most sessions, choose Leaks first — it bundles Allocations alongside it, giving you two instruments in one trace.
Record vs Immediate Mode
The toolbar shows two record modes. Immediate starts recording as soon as the app launches, capturing startup behavior. Deferred lets you navigate to the specific scenario before starting the trace, reducing noise. For investigating a leak that only appears after 10 minutes of use, deferred mode keeps the trace focused.
Tip: Use Instruments → File → Save (⌘S) to save
.tracefiles. A saved trace lets you compare allocations between app versions without re-running the scenario.
Time Profiler: Finding CPU Bottlenecks
Time Profiler works by sampling the call stack of every thread at a high frequency (typically ~1ms intervals). Over thousands of samples, functions that appear frequently are the ones consuming CPU time — even if no single call is slow, repeated calls aggregate into a clear signal.
Reading the Call Tree
After recording 30 seconds of scroll jank, stop the trace and look at the call tree in the bottom panel. Two views matter:
- Heavy (Bottom Up) — Shows the most CPU-expensive leaf functions at the top. Start here when you have no hypothesis about where the problem is.
- Top Down — Shows the call chain from roots to leaves. Use this once you’ve identified a hot function and want to understand what called it.
Enable Hide System Libraries in the call tree options. This collapses Apple framework frames and surfaces your own code at the top of the list.
The Culprit: Filtering on the Main Thread
Here’s the film search implementation that causes scroll jank:
// FilmSearchViewModel.swift
final class FilmSearchViewModel: ObservableObject {
@Published var results: [PixarFilm] = []
private let allFilms: [PixarFilm]
init(catalogue: [PixarFilm]) {
self.allFilms = catalogue
}
// ❌ Called on every keystroke, runs synchronously on the main thread
func search(query: String) {
results = allFilms.filter { film in
// Three expensive localizedCaseInsensitiveContains calls × 10,000 films
film.title.localizedCaseInsensitiveContains(query) ||
film.director.localizedCaseInsensitiveContains(query) ||
film.cast.joined().localizedCaseInsensitiveContains(query)
}
}
}
In the Time Profiler trace, localizedCaseInsensitiveContains will sit near the top of the heavy stack, called from the
main thread’s run loop. The fix moves the work off the main thread and caches the join operation:
// FilmSearchViewModel.swift — fixed
import Combine
final class FilmSearchViewModel: ObservableObject {
@Published var results: [PixarFilm] = []
// Pre-computed search strings avoid repeated `joined()` calls
private struct SearchEntry {
let film: PixarFilm
let searchableText: String
}
private let searchIndex: [SearchEntry]
private var searchTask: Task<Void, Never>?
init(catalogue: [PixarFilm]) {
self.searchIndex = catalogue.map { film in
SearchEntry(
film: film,
// Build the joined string once, at init time
searchableText: "\(film.title) \(film.director) \(film.cast.joined(separator: " "))"
.lowercased()
)
}
}
func search(query: String) {
searchTask?.cancel()
let lowercasedQuery = query.lowercased()
searchTask = Task { [weak self] in
guard let self else { return }
// Heavy work runs on a background executor
let filtered = self.searchIndex
.filter { $0.searchableText.contains(lowercasedQuery) }
.map(\.film)
guard !Task.isCancelled else { return }
await MainActor.run {
self.results = filtered
}
}
}
}
After this change, re-run the Time Profiler trace. The main thread samples during scroll should flatten out, and your search work will appear on a background thread instead.
Apple Docs:
Task— Swift Concurrency
Leaks Instrument: Finding Retain Cycles
Leaks runs alongside Allocations during a trace. It periodically scans the heap looking for objects that are no longer reachable from any root but haven’t been deallocated. When it finds one, it marks the timeline with a red X and records the full backtrace of where that object was allocated.
Classic Closure Capture Cycle
The most common leak in iOS apps is a closure capturing self strongly inside an object that also holds the closure.
Here’s an AnimationController that manages a looping render cycle for a scene:
// ❌ Retain cycle: AnimationController → Timer → closure → AnimationController
final class AnimationController {
private var renderTimer: Timer?
var currentScene: PixarScene?
func startRendering() {
// The closure captures `self` strongly.
// Timer retains the closure. AnimationController retains the Timer.
// Nothing can be deallocated.
renderTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { _ in
self.renderFrame() // ← strong capture of self
}
}
private func renderFrame() {
currentScene?.render()
}
deinit {
renderTimer?.invalidate()
// This is never called — the cycle prevents deallocation
print("AnimationController deallocated")
}
}
In the Leaks trace, you’ll see AnimationController instances accumulating — one per scene navigation — and the
backtrace will point to the Timer.scheduledTimer call site. The fix uses a capture list:
// ✅ Weak capture breaks the retain cycle
final class AnimationController {
private var renderTimer: Timer?
var currentScene: PixarScene?
func startRendering() {
renderTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in
// If AnimationController has been deallocated, self is nil — no crash, no leak
self?.renderFrame()
}
}
private func renderFrame() {
currentScene?.render()
}
deinit {
renderTimer?.invalidate()
print("AnimationController deallocated") // ← now fires correctly
}
}
Warning: Using
[unowned self]instead of[weak self]in a Timer closure is dangerous. If the timer fires after the controller is deallocated — which can happen ifinvalidate()is not called before the object is released — you’ll get a crash, not a graceful nil. Prefer[weak self]with an optional check.
Reading the Backtrace
When Leaks flags an object, click the red X in the timeline. The bottom panel shows two things:
- Leaked object type and address — confirms exactly which class is leaking.
- Allocation backtrace — the call stack at the moment the leaked object was created. Double-clicking any frame jumps directly to the source line in Xcode.
The allocation site is where you need to look for the cycle — not where the crash eventually manifests.
Allocations Instrument: Finding Memory Growth
An app can grow in memory without technically leaking. You might be caching poster images with no eviction policy, building up view model instances on a navigation stack that never pops, or accumulating decoded video frames. Allocations tracks every heap allocation and lets you compare memory state across time using generation marks.
Mark Generation Workflow
The most powerful Allocations technique is the generation comparison:
- Navigate to the feature you want to measure (e.g., the film detail screen).
- In Instruments, click Mark Generation (or press ⌘G). This snapshots the current heap state. Label it “Before.”
- Navigate into a film detail and back out. Repeat 5–10 times to amplify any growth.
- Click Mark Generation again. Label it “After.”
- Select the “After” generation in the Allocations timeline.
The bottom panel now shows only objects allocated after your baseline mark that are still alive. These are persistent allocations — objects that should have been released when you navigated back but weren’t.
Persistent vs Transient Allocations
| Allocation Type | Description | What to Do |
|---|---|---|
| Persistent | Still alive at trace end | Investigate — these are your growth candidates |
| Transient | Allocated and freed during the trace | Normal; only investigate if counts are unexpectedly high |
A PixarFilmDetailViewModel that persists after navigation means something in your navigation stack is holding a
reference to it. Common culprits: @StateObject being recreated, a singleton cache with no eviction, or a coordinator
pattern that never removes its children.
Apple Docs:
OSAllocatedUnfairLock— OS Framework — useful context when investigating thread-safe allocation patterns
SwiftUI View Body: Diagnosing Unnecessary Redraws
Scroll jank in a SwiftUI list is often not a computation problem — it’s a rendering problem caused by views being rebuilt unnecessarily. The SwiftUI instrument (available in the SwiftUI template) tracks every view body invocation with timestamps, view identity, and the reason for the invalidation.
Finding Overdrawn Views
Record a scroll session and look for views with unexpectedly high body invocation counts. A FilmRowView that
re-renders 60 times during a 1-second scroll when only 10 rows are visible is your signal.
Common causes and fixes:
Cause: An @Observable model exposes a property the view doesn’t use, but changes to that property still invalidate
the view.
// ❌ FilmRowView depends on the entire FilmCatalogue model,
// even though it only reads `film.title`
struct FilmRowView: View {
@Environment(FilmCatalogue.self) var catalogue
let filmID: String
var body: some View {
// catalogue.allFilms is accessed here, but changes to
// catalogue.selectedFilmID also trigger a redraw
Text(catalogue.film(id: filmID)?.title ?? "")
}
}
// ✅ Pass only the data the view needs — no environment observation overhead
struct FilmRowView: View {
let title: String
var body: some View {
Text(title)
}
}
Pairing with _printChanges()
During development, add _printChanges() as the first line of a view’s body to print the reason for each invalidation
to the console:
var body: some View {
let _ = Self._printChanges() // Logs: "FilmRowView: @self changed"
Text(title)
}
This is a private API — remove it before shipping — but it’s the fastest way to identify exactly which property is triggering a specific redraw without launching a full Instruments session.
Tip:
_printChanges()pairs well with the SwiftUI Instruments template. Use the console output to identify which view is misbehaving, then use Instruments to quantify how often it redraws across a full user session.
Network Instrument: Waterfall Analysis
For apps reporting slow load times, the Network instrument visualizes every URLSession request as a Gantt-style waterfall. You can immediately see:
- Sequential requests that could be parallel — three film asset requests that run one after another add latency
equal to the sum of all three. Grouping them with
async letorTaskGroupmakes them concurrent. - DNS resolution overhead — a repeated cold DNS lookup on every request indicates missing connection reuse.
- Unexpectedly large payloads — a JSON response that should be 5 KB arriving at 500 KB suggests the server isn’t filtering fields.
// ❌ Sequential requests — total time = t1 + t2 + t3
let poster = try await filmService.fetchPoster(for: film)
let trailer = try await filmService.fetchTrailerURL(for: film)
let credits = try await filmService.fetchCredits(for: film)
// ✅ Concurrent requests — total time = max(t1, t2, t3)
async let poster = filmService.fetchPoster(for: film)
async let trailer = filmService.fetchTrailerURL(for: film)
async let credits = filmService.fetchCredits(for: film)
let (posterImage, trailerURL, filmCredits) = try await (poster, trailer, credits)
Apple Docs:
URLSession— Foundation
Advanced Usage
Custom Markers with os_signpost
When you’re profiling a complex rendering pipeline — say, decoding and compositing frames for a Pixar scene — the
built-in instruments tell you where CPU time goes but not what your app considers a meaningful unit of work.
OSSignposter lets you annotate your own code with named
intervals that appear as colored bands directly in the Instruments timeline.
import os.signpost
final class SceneRenderer {
// Subsystem and category identify your markers in Instruments
private let signposter = OSSignposter(
subsystem: "com.pixar.filmapp",
category: "Rendering"
)
func renderScene(_ scene: PixarScene) {
// Begin a named interval — captures a timestamp
let state = signposter.beginInterval("renderFilmScene", id: signposter.makeSignpostID())
defer {
// End interval — duration appears as a colored band in the Instruments timeline
signposter.endInterval("renderFilmScene", state)
}
scene.prepareAssets()
scene.compositeFrames()
scene.applyPostProcessing()
}
func loadFilmAssets(for film: PixarFilm) async throws -> FilmAssets {
let state = signposter.beginInterval(
"loadFilmAssets",
id: signposter.makeSignpostID(),
"%{public}s", film.title // Metadata visible in Instruments tooltip
)
defer { signposter.endInterval("loadFilmAssets", state) }
return try await assetService.load(film)
}
}
In the Instruments timeline, your renderFilmScene intervals will appear as labeled bands, making it immediately
obvious whether the render budget is being exceeded between frames.
Apple Docs:
OSSignposter— OS Framework
Command-Line Automation with xctrace
For CI pipelines, you can run Instruments traces headlessly using
xctrace:
# Record a 30-second Leaks trace against a simulator
xctrace record \
--template "Leaks" \
--device "iPhone 16 Pro" \
--launch -- /path/to/PixarFilmApp.app \
--output leaks-trace.trace \
--time-limit 30s
This produces a .trace file you can open in Instruments on any machine, making it practical to run performance
regression checks in CI and flag memory growth between builds.
Memory Graph Debugger as a First Step
Before launching a full Instruments session, the Memory Graph Debugger built into Xcode’s Debug toolbar is often faster for investigating a suspected retain cycle. Click the Memory Graph button (the overlapping circles icon) while your app is paused. Xcode generates a visual graph of every live object and its reference edges. Retain cycles appear as loops in the graph — you can trace the path and identify the exact properties involved in seconds, without a trace recording.
Use the Memory Graph when you already suspect a leak and want to confirm which objects are involved. Use Leaks in Instruments when you need to find leaks you don’t yet know about.
Hang Detection with the Hangs Template
iOS 16 introduced Hang detection at the system level. The Hangs template in Instruments records every main thread block exceeding a configurable threshold (default: 250ms). Unlike Time Profiler — which samples continuously — the Hangs template captures only the call stack at the moment of the hang, making it low-overhead and focused.
Use the Hangs template when:
- Users report “the app froze for a second” intermittently
- You’re investigating background-to-foreground transition delays
- You want to audit your app for hangs without a full Time Profiler trace
When to Use (and When Not To)
| Symptom | Instrument to Use | When to Skip |
|---|---|---|
| Scroll jank / UI stutter | Time Profiler | Skip if jank only appears in Debug builds (expected) |
| Growing memory over time | Leaks + Allocations | Skip if growth is bounded and explainable (e.g., image cache) |
| Main thread ANR / freeze | Hangs | Skip if ANR only appears under heavy network load — profile Network first |
| Battery drain | Energy Log | Skip for simulator — only meaningful on a physical device |
| Slow SwiftUI rendering | SwiftUI View Body | Skip for simple static views — overhead is negligible |
| Slow network loading | Network | Skip if latency is server-side — profile server independently |
| Complex custom pipelines | os_signpost + any template | Adds instrumentation overhead — only use in profiling builds |
Warning: Always profile on a physical device, not a simulator, when measuring battery usage, GPU performance, or thermal throttling behavior. The simulator runs on your Mac’s hardware and will produce wildly inaccurate results for these metrics.
Summary
- The Leaks instrument catches retain cycles that the debugger misses entirely — run it on any screen where you suspect a cycle, using the red-X markers to jump directly to the allocation site.
- Time Profiler’s Heavy (Bottom Up) view with “Hide System Libraries” surfaces your code’s CPU hotspots in seconds; move expensive work off the main thread to eliminate scroll jank.
- Allocations’ generation marks let you precisely measure what’s left alive after a user action — persistent objects that should have been freed are your memory growth candidates.
os_signpostbridges the gap between Instruments’ generic call tree and your app’s domain-specific events, giving you named, labeled intervals directly in the timeline.- Profile Release builds on physical devices — Debug builds and the simulator produce misleading performance data.
Memory profiling and performance optimization intersect closely with how SwiftUI manages state and redraws. For the next
level, explore how @Observable’s fine-grained dependency tracking reduces view invalidations in
SwiftUI Performance Optimization, and how structured concurrency patterns prevent
the data races that create subtle timing-dependent leaks in
Concurrency Patterns in SwiftUI.