Migrating Jar's home screen to @Observable
9 min read · Written by Kartikeya Singh
How we moved our busiest screen off ObservableObject — one migration, nine state objects, and 26% fewer view-body invocations for the same pixels.
The home screen is where Jar lives. It's the first thing 20 million savers see: a gold locker with a live balance, a persistent bottom sheet of dynamic feed sections streaming out of our Kotlin Multiplatform core, critical-action cards, a swipe-to-save widget, and a reveal animation choreographed across all of them. Every feature team lands something on it. Every state change in the app eventually echoes through it.
For two years that screen ran on ObservableObject and Combine. We moved it — and the view models behind it — to iOS 17's @Observable macro. This is the story of that migration: why we did it, what actually changes when you swap the macro (more than the compiler tells you), and what we measured on the other side.
The TL;DR: same architecture, same pixels, 26% fewer view-body invocations and 28% less time spent in our view code — with the biggest single win being the screen that hosts everything else re-rendering 86% less often.
The wins, up front
Every view present in both recordings — the same scripted session on both builds, grouped by the part of the screen that owns it. The gold bar is the share of renders the migration eliminated.
| View | Bodies before | After | Renders eliminated | Body time |
|---|---|---|---|---|
| The root and its chrome — bystanders to a 51-property singleton | ||||
| GlobalHeaderCTAWrapperView | 330 | 36 | −89% | 45.87 ms → 10.54 ms |
| GlobalHeaderView | 167 | 20 | −88% | 11.36 ms → 2.90 ms |
| ToolbarBackground | 167 | 20 | −88% | 4.60 ms → 1.46 ms |
| HomeScreenPager root | 169 | 23 | −86% | 97.80 ms → 27.94 ms |
| FullscreenPopup | 608 | 162 | −73% | 20.80 ms → 8.42 ms |
| The persistent sheet — drag no longer touches the feed host | ||||
| HomeFeedSheetContent | 166 | 47 | −72% | 17.92 ms → 6.93 ms |
| PersistentBottomSheet split → SheetSurface | 219 | 49 + 80 | −41% | 37.13 ms → 6.68 + 11.51 ms |
| ScrollViewConfigurator | 1,076 | 877 | −18% | 11.51 ms → 7.12 ms |
| The feed itself — reads only what it renders | ||||
| HomeFeedNonScrollableSection | 225 | 98 | −56% | 7.66 ms → 4.13 ms |
| HomeFeedScreenV2 | 296 | 160 | −46% | 113.68 ms → 76.03 ms |
| HomeFeedCell.Content | 75 | 60 | −20% | 4.95 ms → 4.04 ms |
| HomeFeedCollectionView UIKit boundary | 992 | 882 | −11% | 33.21 ms → 27.24 ms |
| Screens and helpers that only shared the view model | ||||
| SetupRewardsCoinCreditView | 165 | 18 | −89% | 3.47 ms → 0.43 ms |
| VibaWebViewScreen | 165 | 19 | −88% | 52.92 ms → 7.50 ms |
| IntrospectionView | 487 | 79 | −84% | 10.60 ms → 2.72 ms |
| DebounceButton | 186 | 36 | −81% | 10.43 ms → 6.32 ms |
| Held steady — these track genuinely fast-changing state, and should | ||||
| CoinIdleWaveEffect 60fps wave | 4,165 | 3,884 | ≈ | 91.11 ms → 85.48 ms |
| FlowingChevronView | 257 | 242 | ≈ | 15.43 ms → 14.38 ms |
| DraggableCoinV2 gesture-driven | 5 | 5 | ≈ | 8.04 ms → 15.92 ms |
| LottieView Home stopped restarting Lottie. It now plays the whole time, so Instruments counts more frames. Not extra feed redraw. | 379 | 525 | +38% | 57.95 ms → 80.02 ms |
| All app views | 13,228 | 9,834 | −26% | 893.20 → 640.50 ms |
Why we moved
ObservableObject has one notification channel per object: objectWillChange. Any write to any @Published property tells every subscribed view that something changed, and SwiftUI re-evaluates all of their bodies to find out what.
That's a fine contract for a view model with four properties. Our home feed view model is not that. It's the integration point for the whole screen — feed sections, gold price ticks, payment flows, walkthrough state, refresh lifecycle — and it had accumulated 51 @Published properties on a singleton shared by the pager, the feed, the sheet, and half a dozen overlays. A gold-price tick every few seconds re-rendered views that have never displayed a gold price. In Instruments, the SwiftUI template showed our root pager's body running 169 times in a 40-second session in which the pager itself visually changed maybe five times.
@Observable inverts the contract: dependency tracking is per property, per view body. A view that reads viewModel.goldPriceResponse re-renders when that property changes — and only then. The migration is mechanical on the surface (delete @Published, swap the macro) and architectural underneath, which is the part worth writing about.
What we deliberately kept
This was a port, not a rewrite. The home feed's render path — a value-type snapshot committed from KMM emissions, diffed by structure, rendered through a UICollectionView whose cells host SwiftUI content — survived untouched, because it already did its job: UIKit owns scrolling and cell reuse, SwiftUI owns everything drawn.

ObservableObject build paced frames on battery-choked, slower devices.If anything, the migration hardened that boundary — more on that in the traps below.
The mechanical layer
The diff most people picture when they hear "@Observable migration" is real, and it's the easy 20%:
// Before
final class HomeFeedScreenViewModelIos: ObservableObject {
@Published var homeFeedUiState: HomeFeedUiState?
@Published var homeFeedScrollableList: [any DynamicFeatureTypeCard] = []
// ... 49 more @Published properties
private var cancellables: Set<AnyCancellable> = []
}
// After
@MainActor
@Observable
final class HomeFeedScreenViewModelIos {
var homeFeedUiState: HomeFeedUiState?
var homeFeedScrollableList: [any DynamicFeatureTypeCard] = []
@ObservationIgnored
private var cancellables: Set<AnyCancellable> = []
}
The judgment call hiding in there is @ObservationIgnored. Every stored property on an @Observable class is tracked by default — including caches, task handles, and internal bookkeeping no view should ever depend on. We treated the annotation as documentation of intent: tracked means a view is allowed to depend on this. Auditing every stored property across the nine migrated state objects against that rule — 90 @Published removed, 83 properties explicitly opted out with @ObservationIgnored — was slower than the macro swap itself, and more valuable: it surfaced several properties views were reading that they had no business reading.
Alongside: @StateObject → @State, @ObservedObject → @Bindable, @EnvironmentObject → @Environment(Type.self), and the two-parameter onChange everywhere. Then the actual work started.
Trap one: there is no $property anymore
Our screens consumed view-model streams as Combine publishers — for await list in viewModel.$homeFeedScrollableList.values. @Observable has no projected value; the replacement primitive is withObservationTracking, and it is not a stream. It fires once, at willSet, and disarms itself.
The naive loop — register, await change, handle, repeat — has a failure mode that cost us a real bug in testing: our feed paginates, and a page-3 emission that lands while the handler is still processing page 2 fires a tracking that nobody has re-armed yet. The publisher used to buffer that; the naive loop drops it, and the feed sits at eight sections until something else writes.
Our replacement re-arms tracking across the handler and drains immediately if anything changed mid-flight:
func observe<Value>(
_ keyPath: KeyPath<HomeFeedScreenViewModelIos, Value>,
emitInitial: Bool = true,
_ handle: @escaping @MainActor (Value) async -> Void
) async {
var shouldHandleImmediately = emitInitial
while !Task.isCancelled {
if !shouldHandleImmediately {
await withCheckedContinuation { cont in
withObservationTracking { _ = self[keyPath: keyPath] }
onChange: { Task { @MainActor in cont.resume() } }
}
}
// Re-arm BEFORE handling, so a write during `handle` is not lost.
let dirty = OSAllocatedUnfairLock(initialState: false)
withObservationTracking { _ = self[keyPath: keyPath] }
onChange: { dirty.withLock { $0 = true } }
await handle(self[keyPath: keyPath])
shouldHandleImmediately = dirty.withLock { $0 }
}
}
Call sites read almost like the Combine version:
.task { await viewModel.observe(\.homeFeedScrollableList) { await commit($0) } }
Two honest caveats. First, this coalesces: the handler always receives the latest value, not every intermediate one — which is what a rendering pipeline wants, and would be wrong for an event stream. Don't use a pattern like this for analytics or one-shot side effects. Second, there is still a theoretical window between reading the dirty flag and re-registering in which an off-main write can slip through unobserved. We've characterized it, we know the fix (a buffered AsyncStream whose signals survive the gap), and we chose not to land new observer plumbing under a QA-signed release. Migration is as much about sequencing risk as writing code.
Trap two: @State runs your initializer more than you think
This one produced our favorite bug of the migration. @StateObject takes an autoclosure and instantiates lazily, once. @State evaluates its initial value eagerly, on every parent body evaluation — keeping the first and discarding the rest. Swap the wrapper without changing the class and every speculative re-render of the parent constructs a throwaway view model.
Our pager's view model wired its KMM subscriptions in init. After the swap, meta/section responses started landing on dead instances — the KMM round-trip completed, delivered its payload to a discarded object, and the live one never heard about it. On warm launches a cache masked it; after a fresh login the feed simply never arrived.
@Observable @MainActor
final class HomeScreenPagerViewModelIos {
@ObservationIgnored private var hasSetUpObservers = false
// init stays side-effect free — @State may build and discard
// any number of these per parent render.
func setUpObserversIfNeeded() {
guard !hasSetUpObservers else { return }
hasSetUpObservers = true
observeData()
fetchBottomTab()
}
}
// In the view:
.onAppear { viewModel.setUpObserversIfNeeded() }
The rule we now enforce in review: an @Observable class held in @State must have a side-effect-free initializer. Subscriptions, fetches, and singleton registration move to an idempotent activation called from the view's lifecycle.
Trap three: UIKit hosting boundaries don't forward your environment
Each feed cell hosts its SwiftUI content in its own UIHostingController — which means the parent's SwiftUI environment stops at the UIKit boundary and must be re-injected per cell. Under @EnvironmentObject the convenient shortcut was reaching for a shared singleton inside the cell. Under @Environment(Type.self) that shortcut becomes a trap twice over: you can silently bind a second instance of something the screen already injected, and reading an environment value from a stored representable copy outside the update cycle is undefined.
The migration forced us to make the boundary explicit, and the code got better for it. Cells now receive exactly four things — values and closures, never live objects with their own notification fan-out:
struct Content: View {
let cards: [any DynamicFeatureTypeCard]
let shouldShowShimmer: Bool
let triggeredSections: Set<String> // a value, not the manager
let onSwipeCompletion: (Double, Bool, CardEventData, SwipeCardData) -> Void
// Re-inject the instance the screen injected — never .shared —
// so UIKit hosting doesn't split the walkthrough into two objects.
let walkThroughManager: HomeFeedV2WalkThroughManager
var body: some View {
HomeFeedV2SectionRouter(cards: cards, /* ... */)
.environment(\.homeFeedTriggeredSections, triggeredSections)
.environment(walkThroughManager)
}
}
The view model no longer crosses into the collection view at all. Swipe completion travels as a closure defined next to the view model it calls; entrance-animation state travels as a plain Set<String> through a custom EnvironmentKey. When a section's animation triggers, the coordinator reconfigures that one cell — the rest of the feed doesn't hear about it. Under ObservableObject, every cell observing the animation manager re-rendered on every trigger.
What we measured
We profiled the same scripted home-screen session — cold landing, reveal, pull-to-refresh, a full feed scroll, sheet expand — on both builds with Instruments' SwiftUI template, and compared the View Body summaries — the full table sits at the top of this post.
Read the table top-down and it tells one story: the wins concentrate exactly where objectWillChange fan-out used to be worst. The pager re-rendered on every write to a 51-property singleton; now it re-renders when one of the six properties it actually reads changes. The header CTA wrapper and the popup host were bystanders to state they never displayed. Views that genuinely track fast-changing state — the draggable coin and Lottie hosts — kept their counts, as they should.
One number in the table is a design change wearing a metric's clothes: PersistentBottomSheet didn't just re-render less, it got split. Per-property tracking finally made it worthwhile to isolate the drag-following chrome (SheetSurface, tracking sheetHeight at gesture rate) from the feed host (tracking content only), so a drag no longer touches the collection view's host at all. @Observable doesn't only cut renders — it makes fine-grained view decomposition pay off, where under one objectWillChange channel it bought nothing.
What this metric is not. This is fewer SwiftUI invalidations for the same behavior — not a whole-app percentage speedup, and nothing here makes the network or the KMM core faster. Body counts are the mechanism, not the user experience. A body invocation is often microseconds; what users feel is hitches — frames that miss their deadline. This migration reduced the pressure that causes them (28% less body time is 28% less commit-phase work competing with every frame's budget), but our hitch war is its own story, with its own villains — repeatForever animations that refuse to die, timeline ticks rebuilding tile rows, an invisible spinner spinning at 60fps for an entire session. That's part two.What we'd tell you before you start
- Budget for the audit, not the rename. The macro swap is an afternoon. Classifying every stored property as tracked-or-ignored, and every initializer as safe-or-not under
@State's eager evaluation, is the actual migration. withObservationTrackingis a one-shot primitive, not a stream. If you consumed$propertypublishers, design your replacement's buffering semantics deliberately — and test the write-lands-during-handler case, because that's the one that ships a data bug.- Treat every UIKit hosting boundary as an API surface. Pass values and closures through it; re-inject the instances the parent injected. If a hosted subtree reaches for
.shared, you have two objects the day someone scopes that dependency. - Let the profiler arbitrate. Instruments' SwiftUI template made both the "before" case and the payoff legible in an afternoon. Cause attribution on update storms (
@Observable SomeVM.somePropertyshows up by name) is dramatically better than anything we had under Combine. - Sequence risk explicitly. Land the port, harden it, freeze it. We knew a better observer design before we shipped; it waits for the next release instead of landing on a build we had already tested. Boring release engineering is what makes aggressive migrations safe.
Same pixels, less work
The home screen looks identical to the one we shipped before it. That's the point. The savers scrolling it will never know anything changed — except that, a few thousand times a session, their phone now does nothing at all where it used to do work.