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

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 Sendable struct 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 await points form separate transactions (each await is 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, Observations yields 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 to Equatable. 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 .task modifier cancels its Task when the view is removed from the hierarchy. Observations handles 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)

ScenarioRecommendation
Reacting to @Observable changes outside SwiftUI viewsUse Observations. This is the primary use case.
Persisting or syncing state on model property changesUse Observations with a projected snapshot struct.
Simple SwiftUI view updates from @ObservableSkip Observations. SwiftUI tracks @Observable automatically.
Migrating from Combine’s objectWillChange publisherStrong replacement. Post-change snapshots beat pre-change notifications.
Streaming data from a server or file systemUse AsyncStream or a custom AsyncSequence instead.
Cross-process or cross-module observationNot supported. Use distributed actors or notifications.
Per-property observation with different handlersSpin up separate Observations sequences per key path.

Note: Observations is available starting with Swift 6.2 (Xcode 26). If you need to support earlier toolchains, withObservationTracking paired with an AsyncStream.Continuation is your fallback pattern — but you lose transactional coalescing.

Summary

  • Observations bridges the Observation framework to AsyncSequence, giving you a clean, structured way to react to @Observable changes 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 Task is cancelled.
  • For SwiftUI view updates, you do not need Observations. SwiftUI already handles @Observable tracking automatically. Use Observations for 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.