SwiftUI Observation performance: stop unwanted re-renders and measure what matters
Practical patterns for @Observable, avoiding accidental invalidations, and proving performance wins with measurement (not vibes).
SwiftUI performance problems almost never start with “SwiftUI is slow.”
They start with something much more embarrassing:
You changed one field… and half the screen re-rendered.
I’ve shipped that bug. It looks like “SwiftUI is janky”, but the root cause is usually: your state invalidations are too broad.
With the modern Observation stack (@Observable), you have two jobs:
- Make state changes precise (so you don’t invalidate the world).
- Measure the fix (so you don’t ship placebo optimizations).
A useful mental model (without philosophy)
SwiftUI is a function from state → UI. Observation adds a recorder:
- it tracks what properties your view read
- it invalidates views when those properties change
That’s it. That’s the whole trick.
If you read too much state, you’ll invalidate too much UI.
First: make re-renders visible
Before you “optimize”, confirm you’re actually re-evaluating too much.
Render probe (fast + disposable)
import SwiftUI
struct RenderProbe: View {
let name: String
var body: some View {
let _ = Self._printChanges() // prints when this view reevaluates
return Color.clear
.frame(width: 0, height: 0)
.accessibilityHidden(true)
}
}
Drop it into the suspicious subtree:
var body: some View {
VStack {
RenderProbe(name: "InboxView")
content
}
}
You’re not trying to reach “zero updates”. You’re trying to make updates predictable.
Real profiling: Instruments + signposts
Console output gets you oriented. Instruments gets you truth.
- Time Profiler
- SwiftUI / Rendering instrument (depending on your Xcode)
- Points of Interest (signposts) around user actions
We’ll add a signpost helper later.
The most common Observation performance bug
The “god model”
People migrate to @Observable and create one model for everything:
@Observable
final class AppModel {
var user: User?
var settings: Settings
var subscription: SubscriptionState
var inbox: [Message]
var isSyncing: Bool
// …and a lot more
}
Then they pass AppModel everywhere.
One innocent write:
model.isSyncing = true
…can cause a bunch of unrelated UI to re-evaluate because that UI reads something from model somewhere.
Fix: split by responsibility (and by UI locality)
Prefer smaller, boring models:
SessionModel(auth/user)SettingsModelSubscriptionModelInboxModel
Rule of thumb:
- If two screens don’t need the same state, they shouldn’t share the same observable.
- If a view needs 2 values, don’t inject a model with 30.
Accidental tracking: computed properties that read too much
Observation tracks property reads. That’s good… until you create “helpful” properties that touch half your model.
@Observable
final class InboxModel {
var messages: [Message] = []
var filter: Filter = .all
var visibleMessages: [Message] {
apply(filter, to: messages)
}
var debugSummary: String {
"count=\(messages.count) filter=\(filter)"
}
}
If your UI reads debugSummary (even in a debug footer), you just told Observation: “this view depends on messages and filter”, and any helper it touches.
Fix: keep tracked reads minimal
- Keep computed properties small and intentional.
- Avoid UI-facing “summary” properties that touch many fields.
- If something is debug-only, keep it out of the main UI tree.
Tip: for values that should not participate in tracking, look into @ObservationIgnored (useful for caches, formatters, and other non-UI state).
Prefer value types at the edges
If a view only needs a snapshot, pass a snapshot.
Bad
struct Row: View {
var model: InboxModel
var body: some View { /* ... */ }
}
Better
struct Row: View {
let title: String
let subtitle: String
let isUnread: Bool
}
This helps performance, testability, and it keeps your UI from becoming tightly coupled to your entire app model.
The hot path: lists
Lists amplify every mistake.
1) Stable identity
List(model.visibleMessages, id: \.id) { msg in
MessageRow(message: msg)
}
If you use indexes as identity, you’ll eventually animate the wrong row and/or trigger extra churn.
2) Don’t do heavy work in body
If you’re formatting dates, parsing markdown, or building attributed strings in body, you pay that cost on every update.
Fix it by:
- caching formatters
- precomputing display strings in the model
- doing expensive work once per item (or off-main)
Measure the win (or don’t claim it)
Here’s a tiny signpost wrapper to bracket the interaction the user feels:
import os
private let log = OSLog(subsystem: "dev.vburojevic.website", category: "ui")
func signpost(_ name: StaticString, _ block: () -> Void) {
os_signpost(.begin, log: log, name: name)
block()
os_signpost(.end, log: log, name: name)
}
Use it around the mutation:
Button("Apply filter") {
signpost("ApplyInboxFilter") {
model.filter = .unread
}
}
Then compare before/after in Instruments (Points of Interest). Look for:
- total interaction time
- CPU spikes
- surprise work during the update (image decoding, layout thrash, parsing)
A checklist that doesn’t insult your intelligence
When a screen feels slow:
- Confirm the view is re-evaluating more than expected.
- Identify which observable(s) the screen reads.
- Split big models.
- Stop passing whole models into leaf views.
- Reduce wide computed properties.
- Fix list identity and heavy work in
body. - Measure again.
If you do those steps, “SwiftUI is slow” mysteriously stops being true.