Home

Performance

Finding out what redraws, instead of guessing

SwiftUI performance work is almost entirely about one question: which views are re-evaluating, and why? Rendering is rarely the problem. body running a thousand times a second is.

The good news is that this is measurable rather than mystical.

Find out what is re-evaluating

The cheapest instrument in the whole framework:

var body: some View {
    let _ = Self._printChanges()
    …
}

On every re-evaluation this prints the view’s name and what caused it — a property name, @self if the view value itself changed, or @identity if it is a new view entirely. Put it in a view you suspect and watch the console while you use the app.

The output tells you which of three problems you have:

  • A named property — some state is changing more than you thought, or is being read too high.
  • @self — the view struct’s own properties changed, usually because a parent is passing new values every time. A closure or an array literal created inline is a common cause.
  • @identity — the view is being replaced rather than updated. Go back to the identity chapter.

Warning

_printChanges is underscored API. It is for the debugger, not for shipping code — wrap it in #if DEBUG if it survives past the session you added it in.

For a whole-app view, Instruments has a SwiftUI template. The View Body track shows how long each body took and how often it ran; the Update Groups track shows what triggered each cycle. Long bodies are worth a look, but a short body running four hundred times is the more common finding.

The changes that actually help

Split large views. Invalidation happens per view, so one enormous body re-runs entirely for any change it depends on. Extract a struct — not a computed property or a @ViewBuilder function, which are inlined into the parent and get no separate identity.

Read state as low as possible. Covered twice already, and it is the highest-leverage change there is. A property read in a parent invalidates the parent and its entire subtree; the same read in the leaf invalidates one leaf.

Give ForEach stable ids. An index-based or freshly-generated id makes every row a new view on every update, which defeats every optimisation the framework has.

Use lazy containers when the list is long. VStack inside a ScrollView creates every child immediately; LazyVStack creates them as they approach the viewport. For twenty rows the difference is noise, for two thousand it is the whole problem. List is lazy already.

Do not put GeometryReader in a row. It is greedy in both dimensions, participates in the sizing pass, and reruns whenever anything about the layout changes. In a list row it is a reliable way to make scrolling stutter. Alignment guides, containerRelativeFrame or onGeometryChange cover most of the cases people reach for it.

Keep expensive work out of body. body may run many times per frame. Sorting, filtering, formatting and date arithmetic in there run every time.

struct OrderList: View {
    let orders: [Order]

    private var sorted: [Order] {
        orders.sorted { $0.date > $1.date }     // runs on every evaluation
    }
}

Sort it once where the data changes — in the model — and hand the view a list that is already in order.

Things that look like problems and are not

Views being created constantly. A view struct is a few bytes on the stack, and creating millions is genuinely cheap. The cost is in body being evaluated, not in the struct existing. Do not contort a design to avoid making view values.

AnyView everywhere. It does erase the type and does cost something, but it is nowhere near the first thing to fix. Type erasure in one branch of a conditional is fine; a whole view hierarchy built out of AnyView is a design worth revisiting for readability before performance.

Deep view hierarchies. Depth from modifiers is normal and largely free. The nesting is resolved at compile time.

Measuring the launch, not the frame

Two numbers worth watching that have nothing to do with body:

Cold launch. Anything on the path to first frame — a @State initialiser doing real work, a model loading synchronously in init, a large @main scene graph — delays it. Move work into .task so the first frame is drawn from placeholder state.

Memory from images. Image decoding is not free and a full-resolution photo in a thumbnail grid holds the decoded bitmap, not the file. Downsample to the size actually being displayed; this is routinely the difference between a smooth grid and a jettisoned app.

The rule that survives all of it: measure, change one thing, measure again. SwiftUI’s update behaviour is unintuitive often enough that reasoning from first principles about what “should” be faster is unreliable, and _printChanges costs ten seconds to try.