Back to Blog

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).

5 min read

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)
  • SettingsModel
  • SubscriptionModel
  • InboxModel

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:

  1. Confirm the view is re-evaluating more than expected.
  2. Identify which observable(s) the screen reads.
  3. Split big models.
  4. Stop passing whole models into leaf views.
  5. Reduce wide computed properties.
  6. Fix list identity and heavy work in body.
  7. Measure again.

If you do those steps, “SwiftUI is slow” mysteriously stops being true.