Performance and Instruments
Measure, don't guess — the Time Profiler mental model, the memory instruments, and the diagnosis discipline senior interviews probe.
By the end you will be able to
- Run the diagnosis loop — reproduce, measure on device in release, fix the heaviest thing, re-measure
- Read Time Profiler output — self time vs total time — and find the real culprit
- Match each symptom (jank, hang, growth, leak) to the instrument that catches it
The senior interview classic: "users say the app is slow." A candidate answers: "I'd check the code for inefficient loops and optimise them." Why does this answer fail the interview?
The rules of measurement
Three rules before any instrument, because breaking them produces convincing lies:
- Profile release builds. Debug builds skip optimisation — the classic lie is a debug profile whose top rows are retain/release traffic and bounds checks that
-Odeletes outright, sending you off to spend an afternoon "optimising" a function that is already free in the shipped binary. Profile with ⌘I (Product ▸ Profile), which builds Release by default. - Profile on a device — the oldest one you support. The simulator borrows your Mac's CPU and its performance characteristics are fiction. The iPhone your users actually hold is the truth.
- Fix the biggest thing first. A profile is a ranked list; resist the interesting-looking third item. Fix #1, re-measure — the list often reshuffles entirely.
Time Profiler: the mental model
Time Profiler does not trace your code — it samples it: ~1000 times per second it records every thread's call stack. It judges how a colleague spends their day by glancing at their desk at random moments: enough glances and the proportions are the truth — though a task finished entirely between glances never appears, which is exactly what sampling can miss. Functions that appear in many samples were running a lot. Two numbers fall out of the aggregation, and telling them apart is the skill:
- Total time (inclusive) — samples where the function was anywhere on the stack — it or anything it called.
- Self time (exclusive) — samples where it was the function actually executing at the top.
The culprit is high self time. High total with low self is just an ancestor — viewDidLoad "taking 2 seconds" is usually innocent; the leaf under it doing image decoding is guilty. That aggregation is mechanical, and running it yourself demystifies every profile you will ever read:
Read it like the interviewer would: loadFeed has total 9 — it looks like the problem — but self 1. The samples name resizeImage (self 5) as the heaviest actual work, with decodeJSON (self 3) second. The fix conversation starts at image resizing, and "my loadFeed is slow" was true but useless. Every Time Profiler screenshot you will ever read is this arithmetic at scale.
The symptom → instrument table
| Symptom | Instrument | What you look for |
|---|---|---|
| Scrolling stutters, animations skip | Time Profiler | main-thread self time inside the scroll window |
App freezes seconds, maybe killed (0x8badf00d) | Hangs | main-thread blocks > 250ms, and what held it |
| Memory grows and never returns | Allocations | growth across repeated cycles (see below) |
| Memory grows, objects never freed | Leaks / Memory Graph | retain cycles — the ARC lesson's purple badges |
| Slow launch | App Launch | work before first frame; what belongs later |
| "Something is slow" in your domain | os_signpost | your own labelled intervals on the timeline |
Two of these deserve their technique named:
The generation loop (Allocations). Suspected leak in a screen: open it, click Mark Generation in the Allocations detail pane, close it, mark again — repeat five times. Healthy screens produce generations that shrink to ~zero after closing; a screen that retains shows persistent growth per generation, and the instrument lists exactly which objects survived. This is the retain-cycle hunt from the ARC lesson, industrialised.
Signposts (your own instrument). The profiler names functions; it cannot name your concepts — "feed refresh", "checkout flow". Signposts put them on the timeline:
let signposter = OSSignposter(subsystem: "com.app.feed", category: "refresh")
let state = signposter.beginInterval("refresh")
defer { signposter.endInterval("refresh", state) }
Now Instruments shows refresh as a labelled bar you can correlate with CPU, memory and hangs — and the same intervals feed automated performance tests (XCTOSSignpostMetric), turning "it feels slower lately" into a CI regression gate.
The main-thread budget
The number behind "jank": at 60Hz the main thread has ~16ms per frame (~8ms at 120Hz ProMotion). Anything on the main thread during scrolling — JSON decoding, image resizing, date formatting, SwiftData fetches — spends that budget. One 40ms decode = two or three dropped frames = the stutter users report.
The fix hierarchy, in order of preference: don't do the work (cache it — next lessons), do it once (memoise, precompute at write time), do it elsewhere (@concurrent / background task, from the actors lesson), and only then do it faster. Interviews reward candidates who reach for "don't" before "faster".
Complexity is the other half of performance. Same task, two data structures — the operation counts tell the story before any profiler runs. What prints?
// Task: which of 1,000 incoming ids already exist among 10,000 stored ids?
let storedCount = 10000
let incomingCount = 1000
// Strategy A: Array.contains — scans half the array on average per lookup.
let arrayComparisons = incomingCount * (storedCount / 2)
// Strategy B: build a Set once, then constant-time lookups.
let setBuildCost = storedCount
let setLookupCost = incomingCount
let setTotal = setBuildCost + setLookupCost
print("array strategy: \(arrayComparisons) comparisons")
print("set strategy: \(setTotal) operations")
print("ratio: \(arrayComparisons / setTotal)x")Launch takes 4 seconds. Time Profiler during launch shows: main total 3.9s; under it setupAnalytics self 1.8s, loadThemeFromDisk self 1.2s, buildInitialView self 0.4s. The lead-level answer?
Build the profiler's table
Implement heaviest(samples:) — aggregate stack samples into self time and return the functions ranked by it, ties broken alphabetically.
Spend the frame budget
Implement the frame-budget audit: given main-thread tasks per frame, report the total against 16ms and name the single change with the biggest saving.
Close the page and write the diagnosis you would give in an interview for: "our feed scrolls badly on iPhone 12 but fine on 16 Pro, and only after a few minutes of use." Name the instruments in order, what each would show if the cause is (a) main-thread image decoding vs (b) unbounded memory growth, and the first fix for each. Then check yourself against the lesson.
You can now:
- Run the measure-first loop and say why release-on-device is non-negotiable
- Read self vs total time and find real culprits under innocent ancestors
- Route each symptom to its instrument, including the generation technique
- Argue the fix hierarchy from "don't" down to "faster"
Next up: SwiftUI-specific performance — what makes body run, and how to stop it running for nothing.