@Query solved a very specific problem elegantly: a SwiftUI view declares the data it needs, and SwiftData keeps that data current. The trouble starts when the consumer is not a view. A map camera controller, a UIKit data source, a game system, or a synchronization engine still needs to notice store changes, but none of those objects owns a SwiftUI view lifecycle.

Apple’s 2027 platform releases add two answers. ResultsObserver is an observable fetch for code outside a SwiftUI view. HistoryObserver is a signal that relevant persistent-history transactions have arrived. They sound similar because both react to a changing store, but they solve different architectural problems. Picking the wrong one either makes simple state unnecessarily transactional or turns a transactional workflow into an unreliable refetch.

This article treats both APIs as a preview. They were introduced at WWDC26 and Apple’s documentation still marks them as beta, so require the matching 2027 SDK and platform releases, confirm the final availability annotations, and retest the examples before shipping. The design principles are useful today; the API surface may still change.

1. Start with the question your code is asking

Before choosing an observer, phrase the requirement in one sentence:

  • “What models match this query now?” Use @Query in a SwiftUI view or ResultsObserver elsewhere.
  • “What changed since I last processed the store?” Use SwiftData History, with HistoryObserver as the new notification mechanism.

That distinction is more useful than “UI versus background.” A non-UI pricing engine may only need the current set of active products, which is a results problem. A visible sync-status screen may need to process every unuploaded transaction, which is a history problem.

ResultsObserver<Element, SectionName> owns a live collection for a fetch. It supports the familiar SwiftData query primitives: a predicate, sort descriptors, and optional sectioning. Its results property changes when matching models are inserted, deleted, updated into or out of the predicate, or changed by another context or process.

HistoryObserver, by contrast, exposes an observable eventCounter. When relevant transactions become available, the counter increments. The counter is a wake-up signal, not the changes themselves. Your consumer still calls ModelContext.fetchHistory and advances its durable token after processing.

2. Use ResultsObserver as an observable fetch

Imagine a trip-planning app with a map controller that derives its camera bounds from every saved trip. The controller is useful from SwiftUI, UIKit, previews, and tests, so putting its fetch in a view would couple persistence to presentation.

The essential API, following Apple’s WWDC26 sample, looks like this:

import MapKit
import Observation
import SwiftData

@Model
final class Trip {
    var name: String
    var latitude: Double
    var longitude: Double

    init(name: String, latitude: Double, longitude: Double) {
        self.name = name
        self.latitude = latitude
        self.longitude = longitude
    }
}

@Observable
@MainActor
final class MapCameraController {
    private let resultsObserver: ResultsObserver<Trip, Never>
    private var observationToken: ObservationTracking.Token?

    private(set) var bounds: MapCameraBounds?

    init(modelContext: ModelContext) throws {
        resultsObserver = try ResultsObserver<Trip, Never>(
            modelContext: modelContext
        )

        bounds = Self.calculateBounds(for: resultsObserver.results)

        observationToken = withContinuousObservation(options: [.didSet]) {
            [weak self] _ in
            guard let self else { return }
            self.bounds = Self.calculateBounds(for: self.resultsObserver.results)
        }
    }

    private static func calculateBounds(
        for trips: FetchResultsCollection<Trip>
    ) -> MapCameraBounds? {
        guard !trips.isEmpty else { return nil }

        let coordinates = trips.map {
            CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude)
        }
        let points = coordinates.map(MKMapPoint.init)
        let first = points[0]
        let rect = points.dropFirst().reduce(
            MKMapRect(x: first.x, y: first.y, width: 0, height: 0)
        ) { partial, point in
            partial.union(MKMapRect(x: point.x, y: point.y, width: 0, height: 0))
        }

        return MapCameraBounds(centerCoordinateBounds: rect)
    }
}

The important lifecycle detail is the ObservationTracking.Token. Apple’s new withContinuousObservation API returns that token, and observation continues only while it is retained. Storing it on the controller makes the controller’s lifetime the observation lifetime. Releasing the controller releases the token; you do not need a separate forever-running task.

This example is deliberately main-actor isolated. A ModelContext is actor-bound, and the derived MapCameraBounds drives UI. Do not “optimize” it by sending live Trip models to a detached task. If the calculation becomes expensive, copy the primitive coordinates into a Sendable value and perform pure computation off actor—the same boundary described in SwiftData stores shared by apps and extensions.

Filter at the store, not after observation

For a dashboard that only needs active records, initialize the observer with a FetchDescriptor that contains the predicate and sort order. Apple’s documented initializer is:

let descriptor = FetchDescriptor<Trip>(
    predicate: #Predicate { $0.latitude != 0 || $0.longitude != 0 },
    sortBy: [SortDescriptor(\Trip.name)]
)

let observer = try ResultsObserver<Trip, Never>(
    fetchDescriptor: descriptor,
    modelContext: modelContext
)

Avoid observing every row and filtering the collection in your state object. A precise predicate reduces materialization, invalidation, and derived-state work. Index fields that appear frequently in predicates and sorts, then use Instruments’ persistence tooling to verify the actual fetch rather than assuming a tidy abstraction is cheap.

3. Keep @Query where it is already the best fit

ResultsObserver is not a more advanced replacement for @Query. Apple describes them as roughly equivalent under the hood. The choice is about ownership:

ToolOwns current resultsBest lifecycleTypical consumer
@QueryYesSwiftUI viewList, detail, or dashboard view
ResultsObserverYesAn object you ownState object, UIKit/AppKit controller, game system
HistoryObserverNo; signals historyService or actorSync, audit, cross-process ingestion
fetchHistoryReturns transactionsExplicit processing runDurable incremental change consumer

If a view merely renders trips, @Query remains the clearest code. It participates automatically in SwiftUI’s environment and view lifecycle. Moving it into a view model for architectural fashion adds ownership and failure handling without creating value.

Reach for ResultsObserver when the derived state has a meaningful life outside that view: map bounds shared by multiple screens, a UIKit diffable data source, or application state used by several presentation frameworks. That distinction mirrors the broader principle in Swift Observation and @Observable state management: put observable state where its real owner lives, not wherever observation syntax is shortest.

4. Use HistoryObserver when transactions matter

Current results erase the path taken to reach them. If a task is inserted and then deleted between two refreshes, a results consumer may only see “nothing.” A synchronization engine instead needs the insert and delete transactions, their authors, and a checkpoint that survives process termination.

HistoryObserver listens for remote-change notifications, filters relevant persistent history, and increments eventCounter. Apple documents filters for model types and transaction authors. A server sync service can exclude writes attributed to the server so it does not upload them back.

There are two kinds of token here, and treating them as interchangeable creates a relaunch bug:

  • HistoryObserver accepts historyTokens keyed by store identifier. It uses those per-store positions to decide whether an incoming remote-change notification contains relevant transactions. If your observation layer persisted those positions, pass that dictionary back when constructing the next observer. Passing nil makes the observer start with an empty set and capture a token on the first notification for each store.
  • Your sync engine owns a durable application checkpoint. It records the last transaction the server actually acknowledged. That checkpoint is the source of truth for fetching the next batch after a crash or relaunch.

An observer notification is only a wake-up. It is not proof that all older work was processed, and there may be unprocessed history at launch without any new notification. Therefore start() must always request a drain from the durable application checkpoint. Initializing HistoryObserver with its saved per-store tokens improves notification continuity, but never replaces that startup drain.

The observation bridge can stay on the main actor if that is where its container was created. Its only side effect is sending a Sendable wake-up to a dedicated actor:

import Observation
import SwiftData

@MainActor
final class HistoryWakeBridge {
    private let modelContainer: ModelContainer
    private let coordinator: HistoryDrainCoordinator
    private var historyObserver: HistoryObserver?
    private var observationToken: ObservationTracking.Token?

    init(
        modelContainer: ModelContainer,
        coordinator: HistoryDrainCoordinator
    ) {
        self.modelContainer = modelContainer
        self.coordinator = coordinator
    }

    func start(
        observerTokens: [String: any HistoryToken]? = nil
    ) throws {
        let observer = try HistoryObserver(
            historyTokens: observerTokens,
            observedModels: [Trip.self],
            authors: ["App"],
            modelContainer: modelContainer
        )
        historyObserver = observer

        observationToken = withContinuousObservation(options: [.didSet]) {
            [weak self] _ in
            guard let self else { return }
            _ = observer.eventCounter
            Task {
                await self.coordinator.requestDrain()
            }
        }

        // Drain persisted work even if no new eventCounter change arrives.
        Task {
            await coordinator.start()
        }
    }

    func stop() {
        observationToken?.cancel()
        observationToken = nil
        historyObserver = nil
        Task {
            await coordinator.stop()
        }
    }
}

HistoryDrainCoordinator should serialize work independently of the UI. The following complete orchestration uses application-owned protocols, so it makes no assumptions about Apple’s store-specific transaction aliases. A concrete HistoryBatchSource implementation performs ModelContext.fetchHistory using the decoded durable checkpoint. RemoteSink must make acknowledge idempotent, and CheckpointStore.save must durably persist the token encoded in the batch.

import Foundation

struct OutboundHistoryBatch: Sendable {
    let operations: [Data]
    let checkpoint: Data
}

protocol HistoryBatchSource: Sendable {
    func nextBatch(after checkpoint: Data?) async throws
        -> OutboundHistoryBatch?
}

protocol CheckpointStore: Sendable {
    func load() async throws -> Data?
    func save(_ checkpoint: Data) async throws
}

protocol RemoteSink: Sendable {
    func acknowledge(_ operations: [Data]) async throws
}

actor HistoryDrainCoordinator {
    private let source: any HistoryBatchSource
    private let checkpoints: any CheckpointStore
    private let sink: any RemoteSink

    private var wakeGeneration: UInt = 0
    private var worker: Task<Void, Never>?

    init(
        source: any HistoryBatchSource,
        checkpoints: any CheckpointStore,
        sink: any RemoteSink
    ) {
        self.source = source
        self.checkpoints = checkpoints
        self.sink = sink
    }

    func start() {
        requestDrain()
    }

    func requestDrain() {
        wakeGeneration &+= 1
        guard worker == nil else { return }

        worker = Task {
            await runWorker()
        }
    }

    func stop() async {
        let runningWorker = worker
        runningWorker?.cancel()
        await runningWorker?.value
        worker = nil
    }

    private func runWorker() async {
        while !Task.isCancelled {
            let generationAtStart = wakeGeneration

            do {
                try await drainUntilEmpty()
            } catch is CancellationError {
                break
            } catch {
                // Record the failure. Do not lose a wake-up that arrived during I/O.
                if generationAtStart != wakeGeneration {
                    continue
                }
                // A retry policy may call requestDrain() after its backoff expires.
                break
            }

            guard !Task.isCancelled else { break }

            // An event that arrived while awaiting I/O gets a follow-up drain.
            if generationAtStart == wakeGeneration {
                worker = nil
                return
            }
        }

        worker = nil
    }

    private func drainUntilEmpty() async throws {
        var checkpoint = try await checkpoints.load()

        while !Task.isCancelled {
            guard let batch = try await source.nextBatch(after: checkpoint) else {
                return
            }

            try Task.checkCancellation()
            try await sink.acknowledge(batch.operations)
            try Task.checkCancellation()

            // Advance only after the remote side acknowledges the batch.
            try await checkpoints.save(batch.checkpoint)
            checkpoint = batch.checkpoint
        }

        throw CancellationError()
    }
}

This actor guarantees one processor per coordinator. Repeated notifications only increment wakeGeneration; they do not start parallel uploads. The worker fetches batches until the source says history is empty. If a notification arrives during an awaited fetch, upload, or checkpoint write, the changed generation forces one more full drain before the worker exits. Cancellation is checked before the acknowledgement and checkpoint boundary, and stop() waits for the worker to finish unwinding.

There is still an unavoidable crash window after the server accepts a batch but before the local checkpoint is saved. That is why acknowledgement must be idempotent—normally with a stable transaction or operation identifier. On relaunch, start() drains from the older checkpoint, the server accepts the duplicate as already applied, and only then does the app advance the checkpoint. If history cleanup has expired that checkpoint, handle historyTokenExpired as an explicit recovery path, such as a reconciled full snapshot; never silently skip to the latest token.

Transaction aliases depend on the store’s HistoryProviding implementation. Apple’s DefaultStore provides history, while a custom data store must adopt HistoryProviding. Keeping fetching behind HistoryBatchSource lets the coordinator remain actor-safe and testable without pretending every store uses DefaultHistoryTransaction.

5. Handle background imports and external processes deliberately

Apple documents ResultsObserver as responding to local changes, other contexts in the same container, external processes, and CloudKit synchronization. That makes it attractive for a main app displaying writes made by a widget or App Intent. It does not make multi-process persistence coordination disappear.

An app and extension still need compatible schemas, the same App Group configuration, and a single migration owner. The extension must not race the app to migrate a shared file. Observation begins after a process has safely opened the store; it is not a file lock or readiness protocol.

Choose the reaction based on meaning:

  • Recompute visible derived state from ResultsObserver.results when only the latest truth matters.
  • Wake a history processor when every transaction matters.
  • Ask WidgetKit to reload timelines when a widget snapshot must be regenerated; an in-app observer cannot keep an extension alive.
  • Treat CloudKit delivery as eventual. An observer reports what reached the local store, not what exists remotely at that instant.

For large background imports, save bounded batches in short-lived contexts. Each save may cause an observable change. If recomputing derived state is costly, debounce at the consumer boundary or reduce the fetch scope. Do not attempt to deduplicate by model instance identity across contexts. Use a stable domain identifier or PersistentIdentifier, and pass immutable snapshots across actors.

6. Prevent duplicate work and observation storms

An incremented eventCounter means “check history,” not “run exactly once.” Multiple notifications may be coalesced, another event may arrive during processing, and the process may terminate after applying work but before saving a checkpoint. Correctness must come from token-based, idempotent processing rather than notification counts.

Similarly, a ResultsObserver update should be considered invalidation of derived state. Avoid triggering an unconditional save from its callback: the save changes the store, which can invoke the observer again. If reacting must write, compare the intended value with persisted state, tag transaction authors where appropriate, and make the transformation converge.

Keep these performance rules close to the code review checklist:

  1. Make predicates selective and sorts index-friendly.
  2. Observe only model types and authors relevant to a history consumer.
  3. Coalesce expensive presentation calculations, but never discard unprocessed history.
  4. Retain exactly one observation token per lifecycle owner.
  5. Release tokens when a feature stops; accidental long-lived observers retain work even when no screen needs it.
  6. Measure fetch counts, row materialization, and callback frequency with realistic imports.

This is also where Swift concurrency techniques for avoiding UI freezes matter: observation callbacks should schedule bounded work, not parse a large response or transform thousands of records on the main actor.

7. Test behavior, not framework implementation

Create an in-memory ModelContainer, construct the observer and consumer on their intended actor, and mutate the store through the paths production uses. Tests should prove outcomes:

  • Inserting a matching model updates the derived result.
  • Updating a model into or out of a predicate changes membership.
  • Deleting a model clears stale derived state.
  • A write from a second context becomes visible.
  • Author and model filters ignore irrelevant history.
  • Replaying a transaction batch is idempotent.
  • Cancelling or releasing the observation token stops feature-level reactions.

Continuous observation is asynchronous from the test’s point of view. Use Swift Testing confirmation APIs or an AsyncStream exposed by your adapter rather than sleeps. A fixed delay creates a test that is both slow and nondeterministic.

Keep a separate integration suite for App Group processes and CloudKit. An in-memory store cannot prove extension delivery, file coordination, entitlements, or remote propagation. Launch the app and extension against a disposable group store, write from each process, and verify recovery after termination between processing and token persistence.

Because these are beta APIs, compile the observer-specific tests in CI with the exact Xcode beta you support. A green test on one seed is not a compatibility guarantee for the next.

8. Plan an honest fallback for shipping systems

Most production apps in 2026 cannot raise their deployment target to Apple’s 2027 releases. Isolate the preview implementation behind an availability-gated composition root rather than spreading checks throughout business logic.

For older systems:

  • Keep @Query in SwiftUI views.
  • In non-view state objects, perform an explicit ModelContext.fetch and refresh on controlled events such as a completed import, scene activation, or your repository’s change signal.
  • For cross-process and incremental workflows, use SwiftData History directly and persist history tokens.
  • Preserve the same consumer interface so a future ResultsObserver or HistoryObserver implementation changes wiring, not domain behavior.

Avoid claiming source compatibility until Apple publishes final SDKs. The beta documentation explicitly says the APIs may change. Use #available for runtime selection only after final availability versions are known; conditional compilation may also be necessary while building the same source with older SDKs.

Key Takeaways

  • ResultsObserver is a live, observable fetch for code outside SwiftUI; it is not a universal replacement for @Query.
  • HistoryObserver wakes a persistent-history consumer but does not replace durable processing tokens or idempotency.
  • Retain the ObservationTracking.Token for exactly as long as observation should remain active.
  • Filter early, index real query paths, and move only Sendable snapshots across actor boundaries.
  • External-process observation does not solve schema migration, extension lifetime, or CloudKit latency.
  • Both observers are beta APIs for Apple’s 2027 platform releases. Keep shipping fallbacks until your deployment target and final SDK support them.

Conclusion

SwiftData’s new observers fill a genuine gap. ResultsObserver lets a state object own a continuously updated fetch without pretending to be a SwiftUI view. HistoryObserver removes the fragile notification plumbing around persistent history while preserving the transactional model that synchronization requires.

The useful architectural move is not replacing every fetch. It is matching the observer to the question: current results or changes since a checkpoint. Once that choice is explicit, lifecycle, concurrency, testing, and fallback design become much easier to reason about—even before the 2027 APIs are ready for your production deployment target.

Sources