Observation Framework vs Combine for SwiftUI State Management
Property-level tracking makes Observation faster than Combine for complex state.

SwiftUI views are cheap. Apple built the entire framework on the idea that recreating a view struct costs almost nothing, so the framework can throw them away and rebuild them constantly without you noticing. But state has to survive that churn, and the accuracy of the whole update model rests on one thing: SwiftUI needs to know exactly what changed, or it ends up guessing, and guessing means redrawing more than it should.
Combine's ObservableObject protocol was the first serious answer to that problem, and for years it worked fine. Conform a class to ObservableObject, mark a property @Published, and an objectWillChange publisher fires right before that property's value updates. Any view holding that object as a @StateObject or @ObservedObject subscribes to the publisher and re-renders when it fires.
The trouble is that the signal is object-level, not property-level. If a view model has ten @Published properties and a view only reads one of them, the view still re-renders when any of the other nine change, because the publisher doesn't know or care which property a given view actually consumed. A large list bound to a shared view model repaints in full when an unrelated isLoading flag flips somewhere else in the object. Nobody wrote a bug there. The view did nothing wrong, the model did nothing wrong, and the mutation was entirely legitimate. The architecture simply couldn't tell the difference between "this view cares" and "this view doesn't," so it invalidated everything and let SwiftUI's diffing sort out the mess downstream.
Small apps rarely surface this. A settings screen with three toggles never notices the broadcast model's overhead. The problem shows up in large lists, nested reference-type models, and complex dependency graphs, which is to say, in exactly the kind of app that ships to millions of people and needs every frame to land on time.
What the Observation framework actually does under the hood
Apple introduced the Observation framework at WWDC 2023, and it ships starting with iOS 17, iPadOS 17, macOS 14, tvOS 17, and watchOS 10. It's worth noting this isn't a SwiftUI feature bolted on top. Observation lives in the Swift language itself, in its own module outside the standard library, which means it's usable anywhere Swift runs, not just inside SwiftUI view bodies.
The architectural shift is the whole story here. Combine pushes: the object broadcasts a change and every subscriber reacts, whether or not the change is relevant to it. Observation pulls: SwiftUI tracks which specific properties a view's body reads while it executes, then subscribes only to those properties going forward. Change something a view never touched, and that view never hears about it.
The @Observable macro does this at compile time, not at runtime, and Xcode's "Expand Macro" command shows exactly what it generates. Three things happen. First, an ObservationRegistrar gets injected into the class to track which observer cares about which property. Second, every stored property gets rewritten as a computed property, wrapped with access(keyPath:) in the getter and withMutation(keyPath:) in the setter, so every read and write passes through the registrar. Third, the class picks up conformance to the Observable protocol via an extension, which functions more like a marker protocol (similar to Sendable) than something with real requirements.
Practically, this means a view's body registers itself against precisely the properties it reads, no more and no less. Read itemCount, and the view is now watching itemCount. It never hears about totalAmount.
A few consequences follow that weren't possible under ObservableObject. Computed properties can now be observed directly, so something like fullName, derived from firstName and lastName, participates in the tracking system without needing its own storage or its own @Published annotation. Optionals and collections of observable objects are trackable too, closing a gap that used to block certain data shapes entirely under the old model.
One constraint matters: @Observable only applies to classes. Structs already get tracked through @State and @Binding, and wrapping a struct in @Observable doesn't do anything useful, since value types don't need reference-based change tracking in the first place.
The property wrapper surface also shrinks. Every stored property is observable by default now; @Published is gone entirely. @ObservationIgnored opts a property out when you don't want it tracked. @Bindable replaces @ObservedObject specifically for cases where a child view needs a two-way binding into the model; if a view only reads the model, plain let or var access is enough. At the injection point, @State replaces @StateObject and @Environment replaces @EnvironmentObject. Same concepts, considerably fewer wrapper variants to remember.
The performance difference between the two models and how to think about it
The fine-grained model has an obvious performance advantage: a view reading only itemCount doesn't re-render when totalAmount changes, even if both properties live on the same object. That's the entire architectural point, and it scales in the exact direction that matters, since the views that suffered most under Combine's broadcast model were the ones bound to large, busy objects.
Gaurav Harkhani's analysis on Medium put Observation at 30 to 50 percent faster than Combine across most SwiftUI render cycles, attributing the gap to fine-grained invalidation. Treat that figure as directional rather than an Apple-published benchmark. It comes from independent testing, not from Apple's own engineering documentation, but it lines up with what the architecture predicts: Observation's callback mechanism sidesteps the publisher-subscriber machinery Combine carries under the hood, and that machinery has real overhead even for something as trivial as updating one integer.
Two caveats deserve equal airtime, because burying them would misrepresent the current state of the framework. First, Observation doesn't yet support sustained observation across multiple evaluations. Views have to recreate observation operations on every single evaluation cycle, and the developer community has flagged this as a possible performance concern worth watching as adoption grows and edge cases surface. Second, a memory leak existed since Swift 5.9: withObservationTracking subscriptions couldn't clean themselves up without a final mutation triggering the teardown. Point-Free's Perception library addressed this by tying unsubscription to observer deallocation instead, and teams still building on older toolchains remain exposed until they update.
One practical rule follows directly from how access tracking works: split large observable objects into smaller, focused ones. A single monolithic view model holding twenty unrelated properties still produces broad invalidation under @Observable, because a view that reads five of those properties is now watching five keys instead of one, and the odds that any given mutation touches one of those five goes up with the size of the object. Granular tracking only pays off when the objects themselves are granular.
Where Combine still belongs in a modern iOS codebase
Observation replaces Combine's role in SwiftUI's UI binding layer. It does not replace Combine, full stop, and treating it that way misreads what each tool is for.
Combine still owns a set of problems Observation has no answer for. Complex pipelines, debouncing a search field, throttling scroll events, merging multiple asynchronous streams into one, all rely on composable operators that Observation simply doesn't have. Combine also gives precise control over subscription lifecycle: cancellables, backpressure handling, custom publishers built from scratch. None of that maps onto Observation's access-tracking model, because Observation was never designed to transform data, only to report which properties a view read.
Non-SwiftUI contexts are the clearest case. A network layer doing asynchronous data processing, or an event-driven workflow that has nothing to do with rendering a view, doesn't benefit from access tracking at all, since there's no view body reading properties to track in the first place. CurrentValueSubject and the rest of Combine's advanced reactive machinery still fit those contexts well.
Apple's direction on this is not ambiguous. Observation is clearly positioned as the successor for SwiftUI's reactivity model specifically, while Combine's reactive-stream role remains unchanged. Combine keeps its role as the reactive-stream toolkit; Observation takes over UI state. Think of Observation, too, as the long-overdue successor to KVO, applicable to any Swift reference type rather than only NSObject subclasses, and usable across platforms in a way KVO never was.
The heuristic that falls out of all this is simple: if the code drives SwiftUI state, reach for Observation. If it transforms streams or manages async pipelines that live independently of any view, Combine remains the right tool, and there's no architectural pressure pushing it out of that role anytime soon.
Swift 6 strict concurrency and what it changes about how you wire @Observable models
Swift 6 makes strict concurrency a compiler-enforced discipline rather than a runtime hope. Sendable conformances and actor isolation now catch data races at compile time, which changes how @Observable models need to be written, not just how they're consumed.
Every SwiftUI View is implicitly isolated to the main actor, and that isolation cascades down to member properties and methods automatically. The pattern that satisfies the compiler cleanly is @MainActor @Observable class MyModel. Isolating the observable model to the main actor gets you Sendable conformance for free, because the compiler can prove the object's internal state is protected by actor isolation rather than needing to reason about it some other way.
This pairing is particularly well suited to AI and LLM-driven state. A @MainActor @Observable class ChatUIState receiving streaming tokens from an inference call is the idiomatic shape for that problem: updates arrive continuously, they need to land on the main actor to touch the UI safely, and Observation's access tracking means only the views actually displaying the streaming text re-render as new tokens land, not every view that happens to hold a reference to the chat state.
One live issue is worth flagging directly rather than glossing over. As of early 2025, known issues document that adding @Observable to a class already annotated @MainActor can introduce new concurrency errors. Engineers combining both annotations should watch that issue until Apple resolves it upstream, since the failure mode isn't always obvious from the error message alone.
For teams still supporting UIKit alongside SwiftUI, the same @Observable view model works without modification. UIKit doesn't get automatic access tracking the way SwiftUI does, so observation there has to be wired manually, but @MainActor isolation on the model keeps state safe regardless of which UI layer is actually consuming it.
The iOS 17 deployment floor and the ecosystem options for teams that can't drop iOS 16
Observation requires iOS 17 or macOS 14 as a hard minimum. Teams still supporting iOS 16, which as of this writing is a real and common constraint for apps with older installed bases, cannot use native @Observable at all. That's not a soft recommendation, it's a compiler limitation.
Point-Free's Perception library fills that gap. It backports Swift's Observation tools down to iOS 13, macOS 10.15, tvOS 13, and watchOS 6, as an open-source library that lets teams write @Observable-style code today without waiting for their deployment floor to rise. Perception 2.0, released in July 2025, brought continued improvements to the backport for teams targeting older OS versions.
Targeting iOS 16 or earlier through Perception requires additional setup to ensure observation is tracked correctly, since the automatic tracking SwiftUI does natively on iOS 17 isn't available on older OS versions. That wrapper becomes unnecessary, and becomes unnecessary the moment a project raises its deployment target to iOS 17 or above.
Point-Free's Composable Architecture, TCA, integrated Swift 5.9's observation tools directly, using @ObservableState as its analogue to @Observable so that TCA features observe minimal state changes implicitly rather than through the reducer's older Combine-based plumbing. That release also got backported to iOS 16 and earlier through Perception. TCA's trade-off is worth naming honestly: its API surface is opinionated, it has changed fairly often across releases, and the learning curve plus abstraction overhead are real costs that a team has to weigh against the architectural payoff.
Tripadvisor is one data point on that trade-off actually landing. Benoit Sarrazin, an iOS principal engineer there, described the company's migration from MVVM-C toward TCA, a real production team choosing to absorb that learning curve and judging the result worth it.
How to migrate an existing codebase incrementally without breaking production
The mechanical migration is small: three changes per class. Remove the ObservableObject conformance and drop the Combine import if nothing else in the file needs it. Add @Observable to the class declaration and delete every @Published annotation, since all stored properties are tracked automatically now. In every view consuming that model, swap @StateObject for @State, swap @ObservedObject for either @Bindable (if the view needs a two-way binding) or plain property access (if it only reads), and swap @EnvironmentObject for @Environment.
Incremental adoption is not just safe, it's the recommended path. Pick a single data model type, migrate it, verify the app still behaves correctly, and only then extend the change to other models. ObservableObject and @Observable coexist fine in the same project during the transition. What doesn't work is mixing the two patterns on a single type: pick one per class and stick with it, because the compiler won't stop you from trying to combine them and the runtime behavior that results is not something worth debugging.
The most common mistake in a first migration pass has nothing to do with the macro itself. Observable models need to be initialized through @State, not stored as unmanaged plain properties, or the model gets recreated on every view re-initialization instead of persisting across updates. That bug is subtle because the app often still runs, it just resets state it shouldn't.
@ObservationIgnored is the escape hatch for properties that should never trigger a re-render: internal timers, logging counters, non-UI bookkeeping state. Leaving everything observable by default is the right call for most properties on a model, but infrastructure fields like these are the exception, and tagging them keeps the view invalidation surface as small as the architecture intends.
For UIKit bridges or custom rendering loops where SwiftUI's automatic tracking never runs, withObservationTracking is the manual API for hooking into the same system by hand.
And if a migration surfaces a view model that has quietly grown to cover five unrelated concerns over two years of feature work, that's the moment to split it. Observation's granular tracking rewards decomposition immediately, in a way that ObservableObject never did, since a smaller object means fewer property keys any given view is watching, and fewer keys means fewer chances a change elsewhere in the app triggers a re-render it shouldn't.
Making the call: which model fits which codebase right now
Reach for @Observable when the deployment target is iOS 17 or later, since there's no backport complexity to manage and the framework runs natively. It's also the right default for new SwiftUI-first projects started in 2024 or after, and especially for anything where large lists or nested model graphs make render performance a real concern rather than a theoretical one.
Combine still earns its place when the code manages pipelines that Observation has no vocabulary for: debouncing, throttling, merging streams, anything built from composable operators. It's also the correct tool outside SwiftUI entirely, in network stacks, data processing layers, and event-driven backend logic that never touches a view body, and anywhere subscription lifecycle needs fine control that access tracking doesn't offer.
Staying on ObservableObject and @Published still makes sense in one specific circumstance: the deployment target must include iOS 16, and the team has decided against introducing Perception as a dependency. It's also defensible when a codebase is small, stable, and the migration cost clearly exceeds whatever performance benefit Observation would deliver, since not every app is fighting re-render overhead in the first place.
Perception is the answer for teams that want @Observable ergonomics now but can't move the deployment floor yet. Perception 2.0's July 2025 fix for the memory leak edge case removes what used to be the strongest argument against adopting it early.
None of this is really a syntax preference dressed up as an architecture decision. It's a choice between broadcast invalidation, where any change anywhere on an object ripples out to every subscriber regardless of relevance, and access-tracked invalidation, where only the views that actually read a given property ever hear about it changing. Getting that distinction right early is what separates a view hierarchy that scales calmly as an app grows from one that needs a rewrite the moment it hits production traffic.
That distinction matters most right now in AI-native iOS features: streaming LLM output, agent state that updates dozens of times a second, on-device inference results landing in real time. @MainActor @Observable is the right foundation for exactly that kind of workload, since the pull-based model handles high-frequency property updates without over-rendering the UI, and Swift 6's actor isolation rules fit the pattern without a fight.
Sources
- Observation Framework vs Combine: Migration Guide | by Gaurav Harkhani | Medium
- A Deep Dive Into Observation - A New Way to Boost SwiftUI Performance
- Revolutionizing SwiftUI – Goodbye Combine, Hello Observation!
- Observation Framework for SwiftUI | Simform Engineering
- SwiftUI 5 Leaves Combine behind, Extends Animations, and More
- New Frameworks, New Mindset - Unveiling the Observation and SwiftData Frameworks
- SwiftUI: Observable macro under the hood | nsvasilev.com
- techblog.lycorp.co.jp


