SwiftUI performance
What actually makes body run, the diffing model that makes SwiftUI fast by default, and the handful of ways apps defeat it.
By the end you will be able to
- Explain the invalidate-diff-render pipeline and what marks a view dirty
- Diagnose excessive body evaluation with _printChanges and the SwiftUI instrument
- Apply the four real fixes — dependency narrowing, stable identity, lazy containers, work out of body
A list of 500 rows shows a timestamp in the toolbar, updated every second by a timer. With well-structured SwiftUI, what re-renders each second?
The pipeline: invalidate → body → diff → render
When state changes, SwiftUI does four things, and knowing which step costs what is the diagnostic skill:
- Invalidate the views whose dependencies changed — nothing else.
- Evaluate
bodyon those views — producing new value-type descriptions. - Diff new descriptions against old — attribute by attribute, using identity.
- Render only actual differences to the screen.
The consequences run in both directions. Body evaluation is designed to be cheap and frequent — a body returning slightly different structs costs almost nothing, because the diff discards no-ops. But that design carries the contract from the very first SwiftUI lesson: body must be cheap. The pipeline amortises many small bodies; it cannot amortise a sort of 20,000 elements inside one.
So SwiftUI performance work is two hunts, in order:
- Too many invalidations — views re-evaluating that had no business being dirty.
- Too much per evaluation — heavy work inside
body.
Hunt 1: who is invalidating, and why
The first tool is one line:
var body: some View {
let _ = Self._printChanges() // logs WHY this body ran
…
}
@self means the view's own value changed (a parent passed new data); @identity means it was recreated; a property name means that dependency changed. Sprinkle it on suspicious views, exercise the UI, read the console. For the visual version, the SwiftUI instrument in Instruments shows body-evaluation counts and durations per view type — the "who ran 400 times during one scroll" table.
The classic causes, in the order you should suspect them:
Legacy whole-object invalidation. The Combine-era ObservableObject invalidates every observer on any @Published change — the pre-Observation world. A codebase mid-migration mixes both models; screens still on ObservableObject re-render wholesale. The fix is the migration you already know: @Observable, whose per-property tracking turns most of this class of problem off.
Dependency breadth. Even with Observation, a row that takes the whole model reads more than it needs:
// Broad: this row depends on every property it touches on the model —
// and re-evaluates when any of them change.
struct Row: View {
let model: FeedModel
let index: Int
var body: some View { Text(model.items[index].title) }
}
// Narrow: the row depends on exactly one value.
struct Row: View {
let title: String
var body: some View { Text(title) }
}
Passing values, not containers is the single highest-leverage habit: a row taking String cannot be invalidated by anything but that string changing — and as a plain-data view, SwiftUI can skip re-evaluating it entirely when the parent re-renders with equal inputs.
Unstable identity. The lists lesson's rules return as a performance issue: id: \.self on mutating data, or ids minted per render (UUID() in body — a real bug found in real codebases), make the differ see new views every pass — state torn down, animations restarted, rows rebuilt. Identity churn is invisible in code review and glaring in the SwiftUI instrument.
201 bodies per keystroke versus 1 — same screen, same framework, different dependency shapes. On a device this is the difference between a search field that keeps up with typing and one that hiccups; in the SwiftUI instrument it is the difference between a quiet table and Row at the top with four hundred evaluations.
Hunt 2: work that doesn't belong in body
body runs whenever SwiftUI needs it — so anything expensive inside it runs that often too. The offenders and their homes:
In body today | Where it belongs |
|---|---|
| sorting/filtering collections | the model — computed once per data change, or cached |
DateFormatter() / NumberFormatter() creation | a static let — creation loads locale and calendar data every time; a 200-row list pays 200 loads per render pass versus one, ever |
| image decoding/resizing | the model/cache layer, off-main (last lesson's thumbnails) |
| heavy string building | precomputed at write time |
The subtlety interviews probe: let sorted = items.sorted { … } in body is invisible at 50 items and a dropped frame at 5,000 — the code was never wrong, the scale changed. The complexity-vs-profiler pairing from the Instruments lesson tells you which lines have that property.
And the container half: lazy means lazy. List and LazyVStack build rows on approach; a plain VStack in a ScrollView builds all of them immediately. A 1,000-row eager stack pays a thousand body evaluations before the first frame. The reflex: scrolling content of meaningful size → List/Lazy*; small fixed groups → plain stacks. Laziness is not free either: wrap three fixed rows in a LazyVStack and you buy per-cell bookkeeping plus an unknown content height — the scroll indicator jumping as real sizes replace estimates — in exchange for deferring zero meaningful work.
The SwiftUI instrument shows ProductRow.body evaluated 4,000 times during a 10-second scroll of 100 visible rows, and _printChanges logs @identity for them. The diagnosis?
Narrow the dependencies
StatsRow takes the whole dashboard model and re-renders on every tick. Restructure it to take exactly the values it shows, and prove the invalidation count drops.
Get the work out of body
This render loop re-sorts on every evaluation. Restructure so sorting happens once per data change, and the counters prove it.
A teammate proposes wrapping every view in the app in an equality check "so nothing re-renders unnecessarily". Using this lesson's model, write the reply: what the diff already guarantees, where the real waste comes from (invalidation breadth, identity churn, heavy bodies), and why blanket memoisation adds cost without attacking any of the three.
You can now:
- Explain dependency-based invalidation and the invalidate/body/diff/render costs
- Diagnose with
_printChangesand the SwiftUI instrument, reading@identitycorrectly - Narrow dependencies, stabilise identity, move work out of body, choose lazy deliberately
- Argue why SwiftUI is fast by default and how apps defeat it
Next up: caching — the "don't do the work" level of the fix hierarchy, as a discipline of its own.