Mobile Engineering

Swift Observation performance: auditing registrar contention when your model is fast but your dependencies aren’t

Swift Observation can be plenty fast in isolation, yet still trigger stutters when app-level patterns cause high-frequency invalidations and lock contention. Here’s a profiler-first audit you can run in mixed SwiftUI/UIKit apps, plus the three refactors that reliably reduce contention and update fan-out.

Swift Observation isn’t usually your bottleneck; your app patterns are. The failure mode we keep seeing is registrar contention: lots of rapid writes into @Observable models that force the ObservationRegistrar to do work (including locking) even when nobody cares about those writes.

What “registrar contention” looks like in real apps

Observation is now the default model layer for new SwiftUI code, and the Swift Forums is actively discussing performance and modelling improvements (including ways to skip registrar locking for unobserved properties). (forums.swift.org) The nuance: even if Observation gets cheaper in future toolchains, many teams will still carry app-level behaviours that generate invalidations at a rate that makes any model reactive system look slow.

In a mixed SwiftUI/UIKit app, registrar contention tends to show up when:

  • You have a single “god model” (or a small handful) marked @Observable.
  • UIKit (timers, delegates, notifications, KVO bridges, scrolling callbacks) writes into that model at high frequency.
  • SwiftUI views read broad slices of that model, so even “unimportant” writes cause widespread re-evaluation.
  • The system spends time coordinating dependencies (and you see lock pressure) rather than doing useful UI work.

This doesn’t require extreme code. A “currentTime”, “networkState”, “scrollOffset”, “playerPosition”, “lastSeen”, “progress” field updated 30–120 times per second is enough.

The key trap: unobserved writes are not free

The Swift Forums pitch thread (October 2026) makes the point explicitly: with the current model, determining that “nobody observes this property” can require taking the registrar lock and hashing key paths; proposed improvements aim to skip work for unobserved writes by using per-property identifiers and new registrar overloads. (forums.swift.org)

Even if those improvements land, you still want to eliminate unnecessary writes in the first place. The cheapest notification is the one you never send.

The profiler-first audit: what to capture

Do not start by rewriting your models. Start by collecting traces you can repeat after each change.

1) Instruments: SwiftUI template

Apple’s current guidance is straightforward: use Instruments with the SwiftUI template to inspect update behaviour and long view body calculations. (developer.apple.com) This template is designed to show you:

  • Which views are recalculating body frequently.
  • Which recalculations are slow (long updates are highlighted; Apple’s documentation calls out colour thresholds for body calculations, such as >500µs and >1000µs). (developer.apple.com)

Capture one baseline run on a real device (not the simulator) for a scenario your users actually hit: scrolling a complex screen, updating a chart, typing into a form while a background sync runs.

What you’re looking for isn’t “a slow view body”. You’re looking for update rate and fan-out:

  • Lots of body evaluations per second.
  • Updates triggered by data changes that do not affect what’s on screen.
  • Bursts where multiple modules/components update together.

2) Instruments: Time Profiler alongside SwiftUI

Once you see a suspicious interval in the SwiftUI lanes, correlate it in Time Profiler. You want to answer:

  • Are you spending time in view computation, layout, Core Animation commits, or in synchronisation/locking?
  • Are there identifiable hotspots that coincide with model writes?

This is where registrar contention can become visible: the symptom is often “main thread feels busy, but not doing obvious work”.

3) One targeted log: why is this view updating

For a specific view that’s updating too often, use the debugging-only hook Self._printChanges() to get a best-effort explanation of why SwiftUI requested a body update. Apple documents this technique (and the meaning of markers like @self and @identity). (developer.apple.com)

Use it sparingly, and only to answer a concrete question (for example: “Why does this header re-render when playback position changes?”). Treat it as a microscope, not a monitoring system.

What to sample and what counters to watch

You do not need dozens of metrics. For this specific audit, you need a tight loop that links “writes” → “invalidation fan-out” → “slow updates / contention”.

  1. Identify the top 1–3 screens where performance matters.
  2. For each screen, identify the top 1–3 @Observable models involved.
  3. For each model, identify the top 5 properties by write frequency.

Then, in the SwiftUI + Time Profiler trace, look for:

  • View Body update count spikes correlated with a background source (timer, network delegate, playback callback).
  • Platform view updates spikes (often a sign you’re bridging SwiftUI/UIKit and forcing platform-level updates more than you think).
  • Main-thread time spent in synchronisation/locking in the same windows you see high update rates (a common “registrar contention” signature).

If you cannot name the write source for a hotspot, you are not ready to refactor. Go find it first.

The three refactors that usually move the needle

These are the changes that tend to survive contact with shipping apps. They are also the ones that remain relevant even if Observation’s internals improve.

1) Stop writing derived values into @Observable (compute them at the edge)

Failure mode: you store derived UI state in the model (formatted strings, filtered arrays, pre-sorted lists, “isHighlighted”, “canSubmit”), and then you keep it “up to date” with writes.

Why it hurts:

  • It increases write frequency.
  • It broadens the surface area of “stuff that changes”, so more views become dependent.
  • It tends to introduce cascades: write A triggers recompute B, which writes C.

Refactor:

  • Keep the minimal source-of-truth in @Observable.
  • Compute presentation-only derived values in a view model that is not globally observed, or compute in the view with memoisation where appropriate.
  • If you must cache derived data, cache it behind an explicit invalidation boundary (see refactor 2).

When the correct answer is “do not do this”: if the derived state changes at display-link rates (progress, scroll, playback), do not store it in a shared observed model unless the UI genuinely needs every tick.

2) Introduce an explicit “refresh token” boundary for high-frequency sources

Failure mode: UIKit sources write constantly into the same @Observable graph SwiftUI reads.

Examples:

  • CADisplayLink writing currentFrameTime into a shared model.
  • UIScrollViewDelegate writing contentOffset into a model the whole screen reads.
  • A network layer writing per-packet/per-message progress into the same observable object that holds screen state.

Refactor:

  • Decouple the high-frequency stream from the broad model.
  • Create a narrow, purpose-specific observable (or even a non-observed buffer) that only the necessary view reads.
  • Gate updates with a token or coalescing strategy (for example, publish only when a meaningful threshold is crossed: every 250ms, every 1% progress, every N points scrolled).

The trade-off is correctness vs smoothness. If your UI only needs “good enough” progress, coalesce. If it’s a scrubber that must track audio precisely, constrain observation to the one view that needs it, and keep everything else out of the blast radius.

3) Break the “god model” into ownership domains (and avoid cross-domain reads)

Failure mode: one @Observable object owns everything: navigation, session, network state, cache, feature flags, UI state.

Why it hurts:

  • It guarantees large dependency graphs.
  • It turns unrelated writes into global invalidations.
  • It makes lock contention more likely because every write funnels through the same registrar-protected state.

Refactor:

  • Split by ownership: SessionModel, PlaybackModel, FeedModel, EditorModel, etc.
  • Ensure each SwiftUI subtree only reads the models it truly needs.
  • In UIKit bridges, inject narrow dependencies (do not hand a view controller a handle to the entire world).

This refactor is organisational as much as technical: it forces you to state what each screen actually depends on.

A concrete checklist you can run in a day

If you want this to be reproducible (and not a one-off heroic profiling session), run it as a checklist.

  1. Pick one problematic screen and write down the exact user journey to reproduce the stutter.
  2. Capture an Instruments trace using the SwiftUI template, plus Time Profiler, on a real device. (developer.apple.com)
  3. Identify the top 5 views by body evaluation count during the stutter window.
  4. For the top 1–2 views, add Self._printChanges() temporarily to confirm what data changes drive updates. (developer.apple.com)
  5. Identify the top 1–3 properties being written at high frequency (from code inspection or temporary logging around write sites).
  6. Apply exactly one refactor (derived-state removal, refresh boundary/coalescing, or domain split).
  7. Re-run the same trace, same device, same journey. If the update fan-out does not change, revert and try the next refactor.

The discipline here matters: one change, one trace, one conclusion.

Why this still matters with upcoming Observation improvements

The Swift Forums pitch thread is tackling real costs in the current implementation, including work done to discover that nobody observes a property and the resulting lock usage, and proposes new overloads and modelling tweaks to reduce that overhead. (forums.swift.org) Those improvements are welcome.

But if your app writes constantly into shared observable state, you are manufacturing work. A faster registrar can reduce the pain; it can’t fix the fact that you’re invalidating half the UI tree for data that doesn’t matter.

If your UI performance depends on Observation being “free”, you are one architecture mistake away from regressions.

A brief note on mixed SwiftUI/UIKit reality

Most 20–500 person companies are not greenfield. You have legacy view controllers, existing delegate-heavy subsystems, and long-lived global singletons. That’s exactly where registrar contention hides: UIKit produces frequent events; SwiftUI reacts; your model becomes the shared battleground.

The practical goal is not “pure SwiftUI”. It’s clean boundaries:

  • UIKit produces events.
  • A narrow adapter interprets them.
  • Only the views that truly need those events observe them.

Close

Observation can be performant, but you only get that benefit if you stop feeding it pointless writes and stop building models with “everyone reads everything” dependency graphs. The fastest win is an audit: capture a trace, find the high-frequency writes, then apply one of the three refactors that reduce invalidation fan-out and lock pressure.

At Codeversols we do this kind of profiler-first work across iOS, backend and platform engineering. If you want a second set of eyes on a trace and a pragmatic refactor plan that fits a mixed SwiftUI/UIKit codebase, that’s the sort of engagement we take on.

More From Our Blog

Swift Observation performance improvements: audit over-invalidation and unobserved writes before the next toolchain drop
Mobile Engineering • Oct 17, 2026

Swift Observation performance improvements: audit over-invalidation and unobserved writes before the next toolchain drop

Swift Observation is getting active performance/model-shape work again. This audit shows what to measure now—over-invalidation and unobserved writes—so you can prove gains later and avoid new regressions.

Read more →
Android 16 (API 36) edge-to-edge is enforced: the window insets contract your UI must satisfy
Mobile Engineering • Oct 16, 2026

Android 16 (API 36) edge-to-edge is enforced: the window insets contract your UI must satisfy

Targeting Android 16 (API 36) removes the practical opt-out from enforced edge-to-edge. The only stable fix is to define a single owner for each inset (status bars, navigation bars, display cutout, IME) and test for double-application in CI.

Read more →
TestFlight ‘Waiting for Review’ for days? Ship anyway with a dual‑lane release pipeline (and what to automate in App Store Connect)
Mobile Engineering • Oct 15, 2026

TestFlight ‘Waiting for Review’ for days? Ship anyway with a dual‑lane release pipeline (and what to automate in App Store Connect)

When TestFlight Beta App Review and App Store Review queues stall, you don’t need to miss your launch date. This playbook describes a dual‑lane pipeline (internal TestFlight vs external TestFlight vs App Store), deterministic rollout rules, and the App Store Connect automation to keep shipping under review latency.

Read more →