Observations AsyncSequence: Transactional State Tracking for `@Observable`
You have an @Observable model driving a SwiftUI view, and you also need to react to property changes outside the view
layer — maybe to persist state, synchronize with a server, or feed an analytics pipeline. Before Swift 6.2, you were
stuck choosing between Combine publishers (a dependency SwiftUI itself is moving away from), KVO (Objective-C runtime
required), or hand-rolled withObservationTracking loops that re-register on every single change. None of these options
give you what you actually want: a clean, transactional stream of state snapshots that coalesces rapid-fire mutations
into a single delivery.
This post covers the new Observations type introduced in Swift 6.2, how it bridges the Observation framework to
AsyncSequence, and the specific transactional guarantees it provides. We will not cover the broader Observation
framework basics or AsyncSequence fundamentals — those are prerequisites. If you need a refresher, start with
The Observation Framework and
AsyncSequence and AsyncStream.
Contents
- The Problem
- Enter
Observations - Transactional Semantics
- Advanced Usage
- Performance Considerations
- When to Use (and When Not To)
- Summary
The Problem
Consider a Pixar-style movie production tracker. You have an @Observable model that tracks a film’s progress, and you
want to persist changes whenever key properties mutate.
@Observable
final class MovieProduction {
var title: String
var animationProgress: Double // 0.0 to 1.0
var renderStatus: RenderStatus
var lastModified: Date
init(title: String) {
self.title = title
self.animationProgress = 0.0
self.renderStatus = .idle
self.lastModified = .now
}
}
enum RenderStatus: String, Sendable {
case idle, rendering, completed, failed
}
The obvious first attempt with withObservationTracking is clumsy. You get notified that something will change, but
not what changed or what the new values are. Worse, the tracking only fires once — you have to re-register after every
notification.
func startTracking(_ production: MovieProduction) {
withObservationTracking {
// Access the properties you care about
_ = production.animationProgress
_ = production.renderStatus
} onChange: {
// This fires BEFORE the value actually changes
// You don't have the new value yet
// And you must re-register manually
DispatchQueue.main.async {
startTracking(production) // Re-register
persistState(production) // Hope the value updated by now
}
}
}
This is brittle. The onChange closure fires on an arbitrary thread before the mutation lands. You are racing against
the state change itself. And if animationProgress and renderStatus change in the same transaction, you get two
separate callbacks with no way to coalesce them.
Enter Observations
Swift 6.2 introduces the Observations type,
which wraps an @Observable object and produces an AsyncSequence of state snapshots. You describe which properties
you care about in a closure, and the framework delivers coalesced snapshots whenever those properties change.
func trackProduction(_ production: MovieProduction) async {
for await (progress, status) in Observations(of: production) {
// Capture the properties you want to track
($0.animationProgress, $0.renderStatus)
} {
// This closure receives the coalesced snapshot
await persistSnapshot(progress: progress, status: status)
}
}
That is the entire API. No manual re-registration, no racing against the mutation, no Combine import. The sequence yields values after the transaction commits, so you always see consistent state.
How It Works Under the Hood
Observations uses the same observation infrastructure that SwiftUI relies on. When you access properties on the
closure parameter ($0), the framework records which key paths you touched. It then registers for changes on exactly
those key paths. When any tracked property mutates, the framework waits for the current transaction to complete,
evaluates your closure again to capture the new snapshot, and yields it downstream.
The type signature is straightforward:
// Simplified for clarity
struct Observations<Subject: Observable, Element: Sendable>: AsyncSequence {
init(
of subject: Subject,
_ transform: @Sendable (Subject) -> Element
)
}
The transform closure serves double duty: it tells the framework which properties to observe (through property access
tracking), and it shapes the output element. You can return a tuple, a custom struct, or a single value — whatever your
consumer needs.
Basic Usage Patterns
The simplest case tracks a single property:
let production = MovieProduction(title: "Toy Story 5")
// Track just the render status
for await status in Observations(of: production) {
$0.renderStatus
} {
switch status {
case .rendering:
showProgressIndicator()
case .completed:
notifyDirector(production.title)
case .failed:
triggerReRender()
case .idle:
break
}
}
You can also project into a custom type for richer downstream processing:
struct ProductionSnapshot: Sendable {
let progress: Double
let status: RenderStatus
let lastModified: Date
}
for await snapshot in Observations(of: production) {
ProductionSnapshot(
progress: $0.animationProgress,
status: $0.renderStatus,
lastModified: $0.lastModified
)
} {
await syncToCloud(snapshot)
}
Tip: Prefer projecting into a
Sendablestruct when the snapshot crosses isolation boundaries. Tuples work for simple cases, but named fields are clearer in production code.
Transactional Semantics
The word “transactional” is doing real work here. When multiple observed properties change within the same synchronous
scope, Observations coalesces them into a single emission.
let production = MovieProduction(title: "Inside Out 3")
// Somewhere in your code, both properties change together:
func completeRender(_ production: MovieProduction) {
production.animationProgress = 1.0 // Change 1
production.renderStatus = .completed // Change 2
production.lastModified = .now // Change 3
// All three mutations land as ONE snapshot
}
If you were using withObservationTracking directly, you would receive up to three separate notifications. With
Observations, you get exactly one element containing the final state after all mutations in that transaction complete.
This matters in real production code. Imagine an animation pipeline where progress and status are tightly coupled.
Processing intermediate states — say, progress = 1.0 while status is still .rendering — leads to bugs.
Transactional delivery eliminates that entire class of inconsistency.
Transaction Boundaries
A transaction boundary is the end of the synchronous scope where mutations happen. In practice:
- Mutations within a single synchronous function body form one transaction.
- Mutations across
awaitpoints form separate transactions (eachawaitis a potential suspension, so the framework delivers what it has). - SwiftUI animation transactions (
withAnimation) interact naturally — the observation snapshot lands after the animation transaction commits.
func updateProductionPhases(
_ production: MovieProduction
) async {
// Transaction 1: these two mutations coalesce
production.animationProgress = 0.5
production.renderStatus = .rendering
await processFrameBatch() // Suspension — transaction boundary
// Transaction 2: these mutations form a new snapshot
production.animationProgress = 1.0
production.renderStatus = .completed
}
Note: The exact transaction coalescing behavior follows the same rules as SwiftUI’s observation-driven view updates. If SwiftUI would re-render once for a set of mutations,
Observationsyields once.
Advanced Usage
Filtering Redundant Snapshots
Observations emits a new element whenever any tracked property changes, even if your projected value is the same.
Since the result conforms to AsyncSequence, you can chain standard sequence operators to filter duplicates.
// Only react when status actually changes
for await status in Observations(of: production) {
$0.renderStatus
}.removeDuplicates() {
handleStatusChange(status)
}
Warning:
removeDuplicates()requires the element to conform toEquatable. If you are projecting into a custom struct, make sure it conforms.
Combining Multiple Observable Objects
You might need to track changes across two or more @Observable objects. Observations scopes to a single subject, so
combine them by merging two sequences:
@Observable
final class RenderFarm {
var availableNodes: Int = 12
var currentLoad: Double = 0.0
}
func monitorDashboard(
production: MovieProduction,
farm: RenderFarm
) async {
async let productionStream: Void = {
for await snapshot in Observations(of: production) {
($0.animationProgress, $0.renderStatus)
} {
await updateDashboard(
progress: snapshot.0,
status: snapshot.1
)
}
}()
async let farmStream: Void = {
for await load in Observations(of: farm) {
$0.currentLoad
} {
await updateLoadIndicator(load)
}
}()
_ = await (productionStream, farmStream)
}
Cancellation and Lifecycle
Observations respects structured concurrency. When the enclosing Task is cancelled, the sequence terminates cleanly.
This makes it straightforward to tie observation to a view’s lifecycle:
struct ProductionDashboardView: View {
var production: MovieProduction
var body: some View {
VStack {
ProgressView(value: production.animationProgress)
Text(production.renderStatus.rawValue)
}
.task {
// Automatically cancelled when the view disappears
for await (progress, status) in Observations(
of: production
) {
($0.animationProgress, $0.renderStatus)
} {
await analyticsService.log(
progress: progress,
status: status
)
}
}
}
}
Tip: The
.taskmodifier cancels itsTaskwhen the view is removed from the hierarchy.Observationshandles this gracefully — no cleanup code needed on your part.
Working with @MainActor-Isolated Models
When your @Observable type is isolated to @MainActor, accessing properties inside the Observations closure
inherits that isolation context. The emitted elements are Sendable, so they can cross isolation boundaries safely.
@MainActor
@Observable
final class StudioDashboard {
var activeProductions: [String] = []
var totalBudget: Decimal = 0
}
// The Observations closure runs on @MainActor because
// StudioDashboard is MainActor-isolated. The emitted value
// crosses to whatever actor consumes it.
func syncBudget(_ dashboard: StudioDashboard) async {
for await budget in Observations(of: dashboard) {
$0.totalBudget
} {
await cloudSync.updateBudget(budget)
}
}
Performance Considerations
Observations is lightweight by design. The framework uses the same observation registrar that powers SwiftUI’s view
invalidation, which Apple has heavily optimized since iOS 17.
Key characteristics:
- No polling. Observation is event-driven, not timer-based. Zero CPU cost while properties remain unchanged.
- Coalesced delivery. Rapid-fire mutations within a transaction result in a single snapshot evaluation, not N evaluations.
- Minimal allocation. The snapshot closure is evaluated lazily, only when a change is detected. Unchanged intervals allocate nothing.
One thing to watch: the transform closure runs every time a tracked property changes. If your closure performs expensive computation (aggregating large collections, computing derived values), that cost is paid on every emission.
// Potentially expensive if frameStatuses has thousands of elements
for await completionRate in Observations(of: pipeline) {
Double($0.frameStatuses.filter { $0 == .completed }.count)
/ Double($0.frameStatuses.count)
} {
updateProgressBar(completionRate)
}
For cases like this, prefer tracking a pre-computed property on the model instead of computing the derived value inside
the Observations closure.
Apple Docs:
Observations— Observation framework
When to Use (and When Not To)
| Scenario | Recommendation |
|---|---|
Reacting to @Observable changes outside SwiftUI views | Use Observations. This is the primary use case. |
| Persisting or syncing state on model property changes | Use Observations with a projected snapshot struct. |
Simple SwiftUI view updates from @Observable | Skip Observations. SwiftUI tracks @Observable automatically. |
Migrating from Combine’s objectWillChange publisher | Strong replacement. Post-change snapshots beat pre-change notifications. |
| Streaming data from a server or file system | Use AsyncStream or a custom AsyncSequence instead. |
| Cross-process or cross-module observation | Not supported. Use distributed actors or notifications. |
| Per-property observation with different handlers | Spin up separate Observations sequences per key path. |
Note:
Observationsis available starting with Swift 6.2 (Xcode 26). If you need to support earlier toolchains,withObservationTrackingpaired with anAsyncStream.Continuationis your fallback pattern — but you lose transactional coalescing.
Summary
Observationsbridges the Observation framework toAsyncSequence, giving you a clean, structured way to react to@Observablechanges outside of SwiftUI views.- The transform closure defines both what to observe (through property access tracking) and how to shape the emitted element.
- Transactional coalescing ensures rapid-fire mutations within a synchronous scope produce a single snapshot, eliminating inconsistent intermediate states.
- The sequence respects structured concurrency — it terminates cleanly when the enclosing
Taskis cancelled. - For SwiftUI view updates, you do not need
Observations. SwiftUI already handles@Observabletracking automatically. UseObservationsfor side effects: persistence, analytics, network sync, and cross-actor communication.
If you want to see how Observations fits into the broader landscape of Swift 6.2 changes, head over to
Swift 6.2 New Features for the full picture. For architectural patterns around data
flow in SwiftUI, see SwiftUI Data Flow Patterns.