How @Observable Actually Works - A Complete Teardown of Swift's Observation Framework

February 25, 2026

How @Observable Actually Works - A Complete Teardown of Swift's Observation Framework

How @Observable Actually Works - A Complete Teardown of Swift's Observation Framework

Most iOS developers have migrated their view models from ObservableObject to @Observable because Apple said it's faster and cleaner. Far fewer can answer what happens under the hood: why does the compiler need a thread-local variable? What is an ObservationRegistrar, exactly? Why does reading a property in a View body create a relationship that lasts beyond that render cycle? And what does "granular tracking" actually mean at the machine level?

This article tears the framework apart layer by layer — from the single macro annotation you write, all the way down to the thread-local access list that makes SwiftUI's per-property invalidation possible. By the end, you won't just use @Observable. You'll understand it well enough to debug it, extend it, and explain it in an Apple interview.


The Problem @Observable Was Built to Solve

Before diving into internals, it's worth being precise about what was broken before iOS 17.

The ObservableObject Broadcast Model

ObservableObject is a Combine protocol. Every conforming class gets a synthesized objectWillChange: ObservableObjectPublisher. When you annotate a property with @Published, the compiler synthesizes a willSet observer that calls objectWillChange.send() before the value changes.

In SwiftUI, a view subscribed to an ObservableObject (via @ObservedObject, @StateObject, or @EnvironmentObject) receives every objectWillChange emission — regardless of which property changed. The view's body is re-evaluated wholesale.

// ObservableObject: broadcast model — any property change = full view re-render
class FeedViewModel: ObservableObject {
    @Published var posts: [Post] = []           // triggers re-render
    @Published var isLoading = false             // also triggers re-render
    @Published var downloadProgress: Double = 0  // fires 60 times/sec during download
                                                 // and re-renders the ENTIRE view tree
}

The downloadProgress scenario is a real production killer. A view displaying a progress bar using downloadProgress will cause every sibling view that shares the same ObservableObject reference to re-evaluate its body — including expensive list rows, header views, and navigation state. Teams worked around this by splitting view models into smaller objects, introducing artificial seams in their domain model to manage rendering performance.

There was also a deeper structural bug: nested ObservableObject references were invisible to the outer subscription. A parent view model that held a child ObservableObject property would not automatically forward the child's changes to subscribing views. You had to wire up cancellables manually.

class ParentViewModel: ObservableObject {
    // ⚠️ Changes to childVM's @Published properties do NOT
    // propagate to views observing ParentViewModel automatically
    var childVM = ChildViewModel()
}

What SE-0395 Set Out to Fix

Swift Evolution proposal SE-0395 introduced a completely different model: access-tracked, pull-based, per-property observation. Instead of objects broadcasting changes, SwiftUI records what it reads during a render pass, then watches only those specific key paths for mutations. No broadcast. No over-invalidation. No Combine dependency.

The goals were:

  1. Granular invalidation — only views that read a changed property re-render
  2. Automatic nested tracking — reading into nested observable objects just works
  3. No boilerplate — no @Published, no protocol conformance, no cancellables
  4. Cross-platform — no dependency on Combine, which isn't available on all Swift platforms

Step 1: What @Observable Actually Is

@Observable is a Swift macro — specifically a member macro that also attaches conformance. It is not a property wrapper. It is not a protocol. It is a compile-time code generator that rewrites your class.

When you write:

@Observable
final class ProfileViewModel {
    var username: String = "simran"
    var followerCount: Int = 0
    var bio: String = ""
}

The Swift compiler expands that into something equivalent to:

final class ProfileViewModel: Observable {

    // 1. The registrar — the heart of the system
    @ObservationIgnored
    private let _$observationRegistrar = ObservationRegistrar()

    // 2. Internal helpers that every property uses
    internal nonisolated func access<Member>(
        keyPath: KeyPath<ProfileViewModel, Member>
    ) {
        _$observationRegistrar.access(self, keyPath: keyPath)
    }

    internal nonisolated func withMutation<Member, T>(
        keyPath: KeyPath<ProfileViewModel, Member>,
        _ mutation: () throws -> T
    ) rethrows -> T {
        try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
    }

    // 3. Each stored property is rewritten as a computed property
    //    backed by a private underscored stored property

    @ObservationIgnored
    private var _username: String = "simran"

    var username: String {
        get {
            access(keyPath: \.username)   // notify: "someone is reading username"
            return _username
        }
        set {
            withMutation(keyPath: \.username) {
                _username = newValue      // notify: "username is about to mutate"
            }
        }
    }

    @ObservationIgnored
    private var _followerCount: Int = 0

    var followerCount: Int {
        get {
            access(keyPath: \.followerCount)
            return _followerCount
        }
        set {
            withMutation(keyPath: \.followerCount) {
                _followerCount = newValue
            }
        }
    }

    // ... same for bio
}

extension ProfileViewModel: Observation.Observable {}

You can verify this yourself: in Xcode, right-click the @Observable annotation → Expand Macro.

Three things are injected into your class:

  1. ObservationRegistrar — the state-tracking core. It is the single source of truth for which views depend on which key paths.
  2. access(keyPath:) — called in every property getter. It says "someone just read this key path."
  3. withMutation(keyPath:_:) — called wrapping every property setter. It says "this key path is about to be mutated."

The @ObservationIgnored on the registrar and the backing properties prevents them from being recursively tracked — that would create an infinite loop.


Step 2: The ObservationRegistrar — How Dependencies Are Recorded

The ObservationRegistrar is a struct defined in the Swift standard library. Its internal implementation uses a thread-safe lock (_ManagedCriticalState backed by a Mutex in Swift 6) to protect a dictionary that maps ObjectIdentifier + KeyPath pairs to sets of observation callbacks.

The Access Phase

Here is what happens, step by step, when SwiftUI renders your view:

struct ProfileView: View {
    @Environment(ProfileViewModel.self) var vm

    var body: some View {
        // When SwiftUI evaluates this body, it wraps execution
        // in something equivalent to:
        //
        // withObservationTracking {
        //     Text(vm.username)  ← this getter fires access(keyPath: \.username)
        // } onChange: {
        //     // SwiftUI schedules this view for re-render
        // }
        Text(vm.username)
    }
}

During withObservationTracking(_:onChange:):

  1. SwiftUI installs a thread-local access list — a _AccessList stored in _ThreadLocal.value. This is just a dictionary local to the current thread's call stack.
  2. Your view's body is executed.
  3. Every property getter calls access(keyPath:) on the registrar. The registrar checks whether there's an active _AccessList on the current thread. If yes, it adds an entry: (ObjectIdentifier(self), keyPath) → onChange closure.
  4. After body finishes, the access list is harvested. The registrar now holds a record: "this onChange closure should fire when username changes on this specific ProfileViewModel instance."

The critical insight: the access tracking is purely based on what was actually read. If your body reads vm.username but not vm.followerCount, only changes to username will trigger a re-render. Changes to followerCount are completely invisible to this view.

The Mutation Phase

When vm.username = "new_name" is called anywhere in your app:

  1. withMutation(keyPath: \.username, mutation) is called on the registrar.
  2. The registrar looks up all onChange closures registered for (ObjectIdentifier(vm), \.username).
  3. It calls each closure. In SwiftUI's case, this schedules the dependent view for invalidation on the main run loop.
  4. The closure is a one-shot registration: after it fires, the dependency is cleared. The next render will re-register via withObservationTracking again.

This one-shot behavior is important: it means each render pass is a fresh subscription. There are no stale subscriptions from views that have left the hierarchy.


Step 3: Computed Properties — Tracked for Free

One of the most useful things @Observable gives you is automatic tracking of computed properties — something that required objectWillChange.send() gymnastics in the ObservableObject era.

@Observable
final class CartViewModel {
    var items: [CartItem] = []

    // No special annotation needed!
    // SwiftUI correctly re-renders views reading `totalPrice`
    // when `items` changes — because the getter reads items,
    // which calls access(keyPath: \.items)
    var totalPrice: Double {
        items.reduce(0) { $0 + $1.price }
    }
}

This works because totalPrice's getter calls items's getter, which calls access(keyPath: \.items). The thread-local access list records the \.items dependency. When items mutates, totalPrice-reading views are correctly invalidated.

The implication for architecture is significant: you can build rich computed properties without worrying about whether SwiftUI will notice. The framework sees through the computation and tracks the actual stored properties it depends on.


Step 4: Nested Observables — The Fixed Problem

Recall the ObservableObject nested object problem. With @Observable, this is solved automatically and elegantly:

@Observable
final class UserSettings {
    var theme: Theme = .light
    var fontSize: Double = 16
}

@Observable
final class AppViewModel {
    var settings = UserSettings()   // nested @Observable
    var currentTab: Tab = .home
}
struct ThemeToggleView: View {
    @Environment(AppViewModel.self) var appVM

    var body: some View {
        Toggle("Dark Mode", isOn: $appVM.settings.theme)
        // Reading appVM.settings triggers access(keyPath: \.settings) on AppViewModel
        // Reading .theme triggers access(keyPath: \.theme) on the UserSettings instance
        // Both key paths are recorded in the access list
        // Changing settings.theme invalidates ONLY this view
    }
}

The access list captures both the outer and inner key path accesses. The registrar for AppViewModel records the \.settings read; the registrar for UserSettings records the \.theme read. A change to settings.theme invalidates only views that read that specific key path — not views that only read appVM.currentTab.


Step 5: @ObservationIgnored — Opting Out of Tracking

Not every property should drive view updates. Internal caches, delegate references, and logging counters are examples of state that changes frequently but should never cause SwiftUI invalidation.

@Observable
final class ImageLoader {
    var image: UIImage?               // tracked — drives UI
    var isLoading = false             // tracked — drives UI

    @ObservationIgnored
    var cache: NSCache<NSURL, UIImage> = .init()  // not tracked — internal detail

    @ObservationIgnored
    weak var delegate: ImageLoaderDelegate?        // not tracked — avoid cycles
}

When @ObservationIgnored is applied, the @Observable macro does not rewrite that property into a computed property with access/withMutation wrappers. It stays as a plain stored property. The registrar never sees reads or writes to it, so no views are ever linked to it.

This is a meaningful performance tool. A cache that's written to on every network response but never read directly in a view body should absolutely be @ObservationIgnored.


Step 6: withObservationTracking — The Low-Level Escape Hatch

SwiftUI calls this internally, but you can call it yourself. It gives you a way to react to observable changes outside of SwiftUI — in a view model, coordinator, or test:

func startMonitoring(vm: ProfileViewModel) {
    withObservationTracking {
        _ = vm.username  // establish dependency by reading
    } onChange: {
        // Called once when vm.username changes
        print("username changed!")
        // ⚠️ This fires ONCE then stops.
        // Re-call withObservationTracking here if you want continuous monitoring
        startMonitoring(vm: vm)  // restart for continuous observation
    }
}

The one-shot behavior is by design. It prevents runaway observation loops and forces you to be deliberate about continuous monitoring. For long-lived observation, you loop explicitly.

In Swift 6.2, there is a new Observations async sequence type that wraps this pattern cleanly:

// Swift 6.2+ — streaming observable changes as an async sequence
for await _ in vm.observations(of: \.username) {
    print("username changed to \(vm.username)")
}

Swift 6.2 enables streaming transactional state changes of observable types using the new Observations async sequence type, where updates include all synchronous changes to the observable properties and the transaction ends at the next await that suspends — avoiding redundant UI updates and ensuring code reacts to a consistent snapshot of values.


Step 7: The @Bindable Property Wrapper — How Bindings Work

With ObservableObject, you got bindings for free from @ObservedObject via the $ prefix. With @Observable, regular properties aren't property wrappers, so $vm.username doesn't compile. That's where @Bindable comes in.

struct ProfileEditView: View {
    @Bindable var vm: ProfileViewModel   // ← creates binding capability

    var body: some View {
        TextField("Username", text: $vm.username)  // ← $vm.username is a Binding<String>
    }
}

@Bindable is a property wrapper that generates Binding<T> on demand via subscript(dynamicMember:). When you write $vm.username, it synthesizes:

Binding(
    get: { vm.username },
    set: { vm.username = $0 }
)

The get and set both go through access and withMutation on the registrar. The binding is itself observation-aware.

When do you need @Bindable? Only when you need a Binding<T> — i.e., when passing to a TextField, Toggle, Slider, etc. Reading properties for display never requires it.


Step 8: @State, @Environment, and Ownership

The property wrappers you use to own and inject @Observable types have changed from the ObservableObject era. Here's the complete mapping:

Use case Old (ObservableObject) New (@Observable)
Own an object in a view @StateObject @State
Receive an object passed in @ObservedObject (plain let or var)
Read from environment @EnvironmentObject @Environment(Type.self)
Create bindings $object.property via @ObservedObject @Bindable var obj$obj.property

The reason @State replaces @StateObject is subtle but important. @State has always provided stable storage — it caches a value across view re-renders, preventing the object from being destroyed when the parent re-renders. For @StateObject, this behavior was part of the property wrapper's implementation. For @Observable classes, @State provides the same stable storage — the class instance lives as long as the view is in the hierarchy.

struct AppRootView: View {
    // @State creates and owns the model for the lifetime of this view
    @State private var profileVM = ProfileViewModel()

    var body: some View {
        ProfileView()
            .environment(profileVM)  // inject for descendants
    }
}

Step 9: What Doesn't Work (and Why)

Understanding the constraints of @Observable is as important as knowing its capabilities.

Structs Are Not Supported

@Observable only works on classes. This is fundamental: observation tracking requires a stable object identity (ObjectIdentifier) to link key paths to registrar callbacks. Value types are copied on assignment — there is no stable identity to track.

// ❌ Will not compile
@Observable
struct Settings {
    var theme: Theme = .light
}

If you need to observe a value type's changes, wrap it in a class.

Thread Safety Is Your Responsibility for UI

The ObservationRegistrar is internally thread-safe — reads and writes to the registration dictionary are protected by a mutex. However, mutating observable properties on a background thread and expecting SwiftUI to safely update the UI is not guaranteed. The safer pattern remains mutating @Observable properties on the @MainActor.

@MainActor
@Observable
final class FeedViewModel {
    var posts: [Post] = []

    func loadPosts() async {
        let fetched = await api.fetchPosts()
        // Already on MainActor — safe to mutate
        posts = fetched
    }
}

Observation Is One-Shot Per withObservationTracking Call

As discussed above, the onChange closure fires exactly once, then the observation is torn down. This surprises many developers who expect Combine-like continuous streaming. The solution is to either re-register manually, use the Swift 6.2 Observations async sequence, or simply rely on SwiftUI — which re-registers on every render pass automatically.


Step 10: Performance — What You Actually Get

When a view reads viewModel.name, the getter records the dependency. When name changes, the registrar invalidates exactly the views that read that key path, and nothing else — this is the core of granular invalidation and explains the performance wins on lists and complex hierarchies.

To make this concrete, consider a profile screen with many subviews:

ProfileScreen
├── HeaderView          reads: username, avatarURL
├── StatsRow            reads: followerCount, followingCount
├── BioSection          reads: bio
└── PostGrid            reads: posts (array), isLoading

With ObservableObject: every @Published mutation fires objectWillChange.send() and potentially re-evaluates all four subview bodies.

With @Observable: updating isLoading only invalidates PostGrid, because it's the only view whose body read isLoading. HeaderView, StatsRow, and BioSection receive zero re-render signals.

Measured in Instruments' SwiftUI profiling template, using the Observation framework reduces view redraw count from 23 to 18 in a representative task-editing scenario, while average duration also decreases — demonstrating that updating views only when observed properties read by the body change rather than when any property changes has measurable real-world impact.

For high-frequency updates like download progress (60 fps), the improvement can be dramatic. Rather than invalidating an entire navigation hierarchy, only the specific progress view receives re-render signals.


The Mental Model in One Diagram

┌─────────────────────────────────────────────────────────────────────┐
│                        RENDER PHASE                                  │
│                                                                       │
│  SwiftUI calls withObservationTracking {                              │
│      view.body executes                                               │
│          vm.username getter → access(\.username) called              │
│              → thread-local _AccessList records (vm, \.username)     │
│          vm.bio getter     → access(\.bio) called                    │
│              → thread-local _AccessList records (vm, \.bio)          │
│  } onChange: { schedule view re-render }                              │
│                                                                       │
│  ObservationRegistrar now holds:                                      │
│  { (vm, \.username) → [onChange], (vm, \.bio) → [onChange] }         │
└─────────────────────────────────────────────────────────────────────┘
                              │
                              │ later...
                              ▼
┌─────────────────────────────────────────────────────────────────────┐
│                        MUTATION PHASE                                 │
│                                                                       │
│  vm.username = "newsimran"                                            │
│      → withMutation(\.username) called                                │
│          → registrar looks up (vm, \.username)                        │
│          → finds onChange closure → calls it                          │
│          → SwiftUI schedules view re-render                           │
│          → dependency is cleared (one-shot)                           │
│                                                                       │
│  vm.followerCount += 1    ← NO views were reading this!              │
│      → withMutation(\.followerCount) called                           │
│          → registrar looks up (vm, \.followerCount)                   │
│          → finds NO registered closures → nothing happens             │
└─────────────────────────────────────────────────────────────────────┘

Migration Cheatsheet: ObservableObject@Observable

// BEFORE — ObservableObject
class ProfileViewModel: ObservableObject {
    @Published var username: String = ""
    @Published var followerCount: Int = 0
}

struct ProfileView: View {
    @StateObject var vm = ProfileViewModel()
    @ObservedObject var sharedVM: ProfileViewModel
    @EnvironmentObject var globalVM: ProfileViewModel
}

// AFTER — @Observable
@Observable
class ProfileViewModel {
    var username: String = ""
    var followerCount: Int = 0
}

struct ProfileView: View {
    @State var vm = ProfileViewModel()          // @StateObject → @State
    var sharedVM: ProfileViewModel              // @ObservedObject → plain property
    @Environment(ProfileViewModel.self) var globalVM  // @EnvironmentObject → @Environment
}

For bindings, add @Bindable where needed:

struct EditView: View {
    @Bindable var vm: ProfileViewModel

    var body: some View {
        TextField("Username", text: $vm.username)
    }
}