Mobile Engineering

Swift Observation performance: why ‘unobserved writes’ still stall your app (and how to catch them)

Swift Observation is getting faster, but real apps can still hitch when they write to @Observable state at high frequency that no view ever reads. This piece explains the registrar-lock failure mode and gives a practical profiling and refactor playbook.

Your SwiftUI app can still stutter even after moving to Observation, because you’re doing lots of writes nobody reads. The runtime still has to decide whether anyone cares, and in many production models that decision involves taking the registrar lock.

That mismatch is why the latest Observation performance work (pitched on Swift Forums on 6 October 2026) is interesting but not sufficient on its own: it can reduce overhead, yet it won’t save you from an architecture that treats a hot stream of events as “UI state”. (forums.swift.org)

What Observation is optimised for (and what it is not)

Observation’s core win over ObservableObject is granularity. SwiftUI tracks which properties your view reads while computing body, and it uses that dependency set to decide what to invalidate when the model changes. Apple’s WWDC23 “Discover Observation in SwiftUI” talk describes this access tracking model: views re-render when the properties they accessed change, not whenever “something somewhere” on the object changes. (developer-rno.apple.com)

That makes a lot of apps faster by default.

But it also creates a new trap: you can end up with a single @Observable object that contains both:

  • “cold” state that the UI reads occasionally (selected tab, filters, user profile)
  • “hot” state you mutate constantly (progress ticks, sensor updates, WebSocket heartbeats, clocks, counters)

When you mutate the hot state, SwiftUI often doesn’t care — because no view reads it. Yet Observation still has to run mutation bookkeeping to find that out.

This is the story behind the unobserved write: the write that is correct, frequent, and almost entirely irrelevant to the UI, but still expensive enough to show up as dropped frames.

The concrete failure mode: registrar lock contention from irrelevant writes

In today’s Observation model, a mutation of an @Observable property typically routes through registrar hooks (conceptually “will set” / “did set” style entry points). The Swift Forums pitch spells out the cost shape plainly:

  • every mutation calls willSet and didSet
  • if the registrar only has a key path to work with, it may have to form the key path and then take the registrar’s lock and hash the key path — twice — just to learn that nobody observes that property (forums.swift.org)

That’s the stall. Not because SwiftUI is re-rendering everything, but because your model is spending time inside observation plumbing.

Why it shows up in real apps

The pitch calls out what most teams do in practice: a view reads a few properties, while the app mutates many more. (forums.swift.org)

That “mutates many more” includes the classic engineering decisions you make for sane code organisation:

  • you keep async workflows and their progress on the same model object as the UI
  • you store transient “last received timestamp”, “last sync tick”, “retry counter” next to durable state
  • you cache derived things (“sorted items”, “grouped sections”, “filtered list”) on the model to keep view code clean

If any of those are updated at 30–120Hz, the registrar path can become visible.

What the Oct 2026 pitch changes (and what it doesn’t)

The 6 October 2026 pitch proposes a set of additive improvements with a clear design goal: “the cost of observation should track what is actually observed”. (forums.swift.org)

The headline change relevant to this article is property indices. The macro can pass a stable small integer for each tracked property, allowing the registrar to use a per-object bitmask fast path: if the bit for that index is clear, the registrar can return without taking the lock. The pitch explicitly describes this as enabling “mutating a property nobody observes [to] skip the registrar’s lock”. (forums.swift.org)

That is genuinely good.

But it doesn’t make high-frequency irrelevant writes free. Two reasons:

  • You can still be paying your own costs: work inside didSet, allocations, copying, decoding, logging, main-thread hops.
  • If the property becomes observed (even accidentally), you’re back on the slow path. “Accidentally observed” includes debug UI, hidden views, or a refactor that reads the property once in body.

So yes, runtime work is improving. You still need a playbook for architecting and verifying hot write paths.

How to detect you have an unobserved-write problem

If you only take one thing from this piece, make it this: treat observation overhead like any other performance question. Profile it, do not guess.

Here’s a practical workflow that works well with busy teams.

  1. Reproduce the hitch in a release-like build
  • Profile a Release configuration (or at least “optimised”) build.
  • Reproduce a small, deterministic scenario: scrolling a list while a background stream updates, dragging while progress ticks, typing while network state changes.

(If you need a refresher on basic Instruments workflow, the Xcode “Product > Profile” flow is the standard starting point.) (github.com)

  1. Use Time Profiler to confirm where the CPU time goes

You’re looking for a call tree dominated by:

  • ObservationRegistrar work
  • key-path formation or hashing
  • locking primitives (even if they appear indirectly)

Apple’s public docs only describe ObservationRegistrar at a high level (“Provides storage for tracking and access to data changes”), so you will be leaning on symbols and call stacks rather than textual documentation here. (developer.apple.com)

  1. Prove the writes are unobserved (don’t assume)

An “unobserved write” is not “a property I think nobody reads”. It’s “a property no view reads on the code paths users actually hit”.

Two ways to prove it:

  • Temporarily add logging/counters to the getter (or wrap reads in a function) and validate it never fires during the hitch scenario.
  • In SwiftUI, search for the property usage across body, computed properties used by body, and any .onChange(of:)/derived value paths.

Be careful: in Observation, the shape of the line of code can matter. For example, whole-value assignment vs in-place mutation can go through different accessor paths and trigger different notification behaviour, as documented in a recent deep dive on @Observable code generation. (wesleymatlock.com)

  1. Check for “observed by accident” patterns

The common foot-guns we see in code reviews:

  • Debug overlays reading internal counters.
  • A view reads a property “just to decide whether to show something”, even if it’s almost always false.
  • A computed property used by body depends on a hot stored property.

At this stage, your goal is simple: identify the handful of stored properties that are written very frequently, and map which views (if any) read them.

Refactor playbook: make hot writes not look like UI state

Once you’ve found a hot write path, you generally have four options. The right one depends on whether the UI should react to every write, some writes, or none.

1) Partition your state so hot writes don’t share an observable surface

The simplest fix is structural: split the mega-model.

  • Put UI-driving state in one @Observable type.
  • Put hot, internal, or ephemeral state elsewhere: a separate non-observable type, an actor, or a service object.

Trade-off: you lose the convenience of “everything is in one place”. You gain predictable invalidation and you stop paying observation overhead for internal churn.

When the answer is “do not do this”: do not split state just to follow a pattern. Split it when you have measured hot writes and can name the stalling property.

2) Replace stored hot values with derived state

A surprisingly common performance bug is “I cache a derived value on the model and keep it up to date on every input write”. If the UI only needs the derived value, you’ve made the worst of both worlds: frequent writes and lots of surface area.

The Oct 2026 pitch proposes @ObservationDerived explicitly for this: a stored value that is computed from other observable state, recomputed only when its inputs change. (forums.swift.org)

Even without that macro available to you today, the design principle stands:

  • Prefer computing derived values at the edge (in the view or in a view-specific adapter) when they are cheap.
  • If derived values are expensive, compute them on a schedule (throttle), or compute them in response to the right events (not every tick).

Trade-off: you may do more work on render, so you must keep render-path work bounded.

3) Throttle or coalesce write frequency

If the UI doesn’t need 60 updates per second, don’t produce 60.

  • Coalesce bursts (e.g. update once per run loop, once per 100ms, or “only on meaningful change”).
  • Move from “write every sample” to “accumulate then publish”.

The pitch also proposes “persistent listeners” with a .coalesced option to deliver a burst of writes once. That’s aimed at library authors, but it reflects the same underlying truth: coalescing is often what users actually want. (forums.swift.org)

When the answer is “do not do this”: don’t throttle correctness. If the UI is a live instrument panel that must track every sample, throttling is the wrong tool; partitioning is usually the correct one.

4) Change event resolution (accept coarser invalidation)

Sometimes you don’t need per-property precision; you need “something in this group changed”. The pitch proposes .object resolution and grouping for change events (@Observable(resolution: .object) and @ObservationTracked(group:)). (forums.swift.org)

This can reduce bookkeeping overhead, but it’s a trade: you can cause more view invalidation.

Use it when:

  • you already re-render a whole section on any change
  • your model has many tiny fields that are always consumed together

Avoid it when:

  • you have a large view tree where extra invalidations are visible
  • you’re trying to “paper over” a hot write path rather than redesigning it

A quick checklist for production teams

  • Identify the top 3 properties by write frequency during the hitch.
  • For each, answer: is it read by any view during the hitch scenario?
  • If not read, remove it from the observable surface (partition) or stop writing it (coalesce/throttle).
  • If read, decide whether the UI truly needs every write; if not, coalesce.
  • Re-run Time Profiler after each change. Keep the diff small and attributable.

Where Codeversols fits (briefly)

If you’re building a SwiftUI product where performance is a feature — live collaboration, streaming data, charts, audio, sensors — this class of issue tends to show up around the same time you hit product–market fit and real traffic. At Codeversols we do software engineering across mobile, web, cloud and AI, and we’re usually most useful when a team wants a tight profiling-to-refactor loop: reproduce, measure, change one thing, measure again.

Close

Observation is getting faster, and the Oct 6, 2026 pitch is directionally right: make unobserved writes cheap by design. (forums.swift.org)

But your app won’t become smooth by waiting for runtime improvements. If you write to @Observable state at high frequency, treat it as a performance-critical API: measure, identify unobserved hot writes, partition state, and publish only what the UI truly needs.

More From Our Blog

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 →
Swift 6 strict concurrency default isolation footguns: how teams accidentally @MainActor their whole app (and a CI check to prove it)
iOS / Swift • Oct 14, 2026

Swift 6 strict concurrency default isolation footguns: how teams accidentally @MainActor their whole app (and a CI check to prove it)

Swift 6’s strict concurrency checks get teams over the line, but one build setting can quietly turn large parts of your app into MainActor-only code. Here’s how that happens, how it shows up as UI jank, and a simple CI audit that catches “main-actor creep” early.

Read more →