The hidden cost of view re-renders in SwiftUI
All Articles
· iOS SwiftUI Performance Swift

The hidden cost of view re-renders in SwiftUI

SwiftUI's diffing is fast, but it's not free. Here's how @Observable, EquatableView, and the shape of your state graph determine whether your app runs at 60fps or 30fps under load.


SwiftUI’s declarative model promises that you describe the UI and the framework handles updates. What it doesn’t tell you is that how you structure your state determines how much invalidation and body re-evaluation happens on every change. Get it wrong and you’ll spend hours staring at Instruments wondering why a simple list update tanks your frame rate.

I’ve profiled enough SwiftUI apps at this point to have a predictable playbook. Here it is.

How SwiftUI Decides What to Re-evaluate

At a high level, SwiftUI re-evaluates a view when:

  1. One of its @State or @Binding values changes.
  2. One of its observed objects notifies a change.
  3. Its parent re-evaluates and passes new values in.

Point 3 is the sneaky one. When a parent view re-evaluates, SwiftUI has to decide how much downstream work can be skipped. Stable identity and stable inputs help a lot. Inline construction, derived collections in body, and unstable IDs make that job harder and create more work than you intended.

@Observable vs ObservableObject

The shift from ObservableObject + @Published to Swift’s @Observable macro (Swift 5.9, iOS 17+) is more significant than it first appears.

With ObservableObject, any @Published property change triggers objectWillChange, which invalidates every view observing that object — even views that only use a property that didn’t change:

class UserViewModel: ObservableObject {
    @Published var name: String = ""
    @Published var avatarURL: URL? = nil
    @Published var followerCount: Int = 0
}

// This view re-renders whenever name, avatarURL, OR followerCount changes
// even though it only displays the name
struct NameLabel: View {
    @ObservedObject var vm: UserViewModel
    var body: some View {
        Text(vm.name)
    }
}

With @Observable, SwiftUI tracks which properties a view actually reads during its last render, and only invalidates the view if those specific properties change:

@Observable
class UserViewModel {
    var name: String = ""
    var avatarURL: URL? = nil
    var followerCount: Int = 0
}

// Only re-renders if name changes — avatarURL and followerCount changes are ignored
struct NameLabel: View {
    var vm: UserViewModel
    var body: some View {
        Text(vm.name)
    }
}

If you’re supporting iOS 17+ and you have view models with multiple properties, migrating to @Observable is one of the highest-ROI performance changes you can make. I cut re-render counts by 60–70% on a complex list screen with this change alone.

The List Performance Trap

List is more efficient than ForEach + ScrollView precisely because it virtualizes its content — it only realizes visible cells. But there’s a specific pattern I see constantly that adds unnecessary work:

// ❌ Recomputes the filtered array on every body evaluation
List(viewModel.items.filter { $0.isVisible }) { item in
    ItemRow(item: item)
}

The filter runs on every body evaluation. For 10 items it’s fine. For 1,000 it’s a hitch you’ll feel. The fix: precompute once when inputs change, then pass the already-filtered list.

Even worse is applying .id() modifiers incorrectly:

// ❌ Random ID means List treats every render as a completely new set
ItemRow(item: item).id(UUID())

With a random id(), List cannot diff its content — it destroys and recreates every cell on every update. I’ve seen this in production code where someone added .id(UUID()) to “fix” an animation glitch. It fixes the animation by throwing away all identity tracking, which is like fixing a slow query by deleting the database.

Equatable Comparisons: When the Framework’s Diffing Isn’t Enough

When a view’s inputs are complex and expensive to diff, opting into equatable comparison lets SwiftUI use your == implementation as a cheap “no meaningful change” gate before recomputing body.

struct ExpensiveChart: View, Equatable {
    let data: [DataPoint]

    static func == (lhs: Self, rhs: Self) -> Bool {
        lhs.data.elementsEqual(rhs.data) { l, r in
            l.id == r.id && l.value == r.value
        }
    }

    var body: some View { /* ... */ }
}

// Opt in at the call site:
ExpensiveChart(data: data).equatable()

This is particularly useful for chart views, map overlays, or any view that renders from a large dataset. The key rule: your equality check must match rendering semantics. If == returns true when pixels should change, SwiftUI can legitimately skip the update and your UI will look stale.

What to Profile and How

The right tool is Instruments → SwiftUI profiler (available since Xcode 14). Prefer profiling on a real device for frame-time and scrolling behavior, then interact with the problematic flow and look for:

  • Body evaluations that spike during animations or scrolling
  • Core Animation commits queued faster than the display refresh rate
  • Views in the hierarchy with unexpectedly high body count

In my experience, the three root causes are almost always:

  1. Publishing too coarsely (whole-model ObservableObject where property-level @Observable would do)
  2. Inline data transformation in the view body (filter, map, sort — computed every render)
  3. Identity issues on List or ForEach (id: pointing to a non-stable value)

SwiftUI is excellent at keeping up with state changes. Your job is to make sure you’re only invalidating the parts of the view graph that actually depend on those changes.