iosNudge

How do you find and fix performance problems in SwiftUI?

HardAsked oftenSwiftUIPerformance 2 min read

TL;DR

SwiftUI is slow when body runs too often or does too much work. Fixes: keep body cheap, split views so each one reads only what it needs, keep identity stable, and use lazy stacks for long content. Always measure first. Use Instruments (the SwiftUI instrument) and Self._printChanges().

1. Keep body cheap

body can run many times per second. Do not create formatters, sort arrays or decode images there.

// Bad
var body: some View {
    let formatter = DateFormatter()          // new object every update
    formatter.dateStyle = .medium
    return List(items.sorted { $0.date > $1.date }) { item in  // sorts every update
        Text(formatter.string(from: item.date))
    }
}

// Good
var body: some View {
    List(model.sortedItems) { item in
        Text(item.date, format: .dateTime.day().month().year())
    }
}

Move heavy work to the model, and compute it when the data changes, not when the view draws.

2. Depend on less data

A view updates when data it reads changes. Read less, update less.

// Bad: the whole row list updates when anything in `store` changes
struct Header: View {
    let store: Store
    var body: some View { Text(store.user.name) }
}

// Better: pass only what is needed
struct Header: View {
    let name: String
    var body: some View { Text(name) }
}

@Observable already tracks per property. With old ObservableObject, any @Published change updates every observer, so this matters even more.

3. Split big views

Small child views are cheap. SwiftUI can skip them if their inputs did not change.

struct ProductScreen: View {
    let product: Product
    @State private var quantity = 1

    var body: some View {
        VStack {
            ProductHeader(product: product)   // not updated when quantity changes
            Stepper("Qty \(quantity)", value: $quantity)
        }
    }
}

4. Stable identity

  • Use stable IDs in ForEach. Not indices, not UUID() created on each load.
  • Avoid AnyView in lists.
  • Avoid if/else that swaps between two copies of the same view. Change a modifier value instead.

5. Lazy containers

ScrollView {
    LazyVStack {
        ForEach(posts) { PostRow(post: $0) }
    }
}

VStack builds all children at once. LazyVStack and List build only visible rows.

6. Images

  • Downsample big images before showing them. A 4000 px photo in a 100 pt cell wastes memory and CPU.
  • Decode off the main thread, for example with UIImage.byPreparingThumbnail(ofSize:).
  • AsyncImage is simple but gives you little control over caching.

How to measure

var body: some View {
    let _ = Self._printChanges() // debug only: prints why body ran
    Text(title)
}
  • Instruments > SwiftUI template. Xcode 26 has a new SwiftUI instrument that shows long view body updates and what caused each update.
  • Animation Hitches instrument for dropped frames while scrolling.
  • Time Profiler for slow code inside body or models.
  • Always profile a Release build on a real device.

Other tips

  • .equatable() or an Equatable view can skip updates when inputs are equal. Measure first; it is rarely needed with @Observable.
  • .drawingGroup() renders complex vector drawings with Metal. Use it for heavy shapes, not for normal UI.
  • Avoid GeometryReader in every row. Use onGeometryChange (iOS 18, back-deployed) or containerRelativeFrame when possible.

Common follow-up questions

  • Why does my whole list update when one item changes? The list reads a big object, or row IDs are unstable.
  • Is creating view structs expensive? No. Structs are cheap. The cost is in body work and in layout.
  • What is the main thread budget? About 16.7 ms per frame at 60 Hz and 8.3 ms at 120 Hz.
  • Does @State in many rows hurt? Not by itself. Heavy work on each update does.