Skip to content

@FetchAll synchronous main-thread reads cause UI lag during CloudKit SyncEngine activity #447

@joakim-hersche

Description

@joakim-hersche

Environment

  • sqlite-data version: 1.6.1 (via Swift Package Manager)
  • Xcode: 26.4 / iOS Simulator 26.4
  • Swift 6, default-isolation=MainActor, strict concurrency checking enabled
  • Target: iOS 26.4 (arm64, iPhone 17 Pro simulator, real iPhone confirms same behavior)
  • Companion packages: swift-structured-queries 0.31.1, swift-dependencies 1.12.0, swift-sharing 2.8.0, GRDB 7.10.0

Summary

On an app that uses @FetchAll + SyncEngine for CloudKit sync, the UI freezes for ~500ms–1s on every user interaction that triggers keyboard presentation, alert appearance, or focus transition (e.g. tapping into a TextField, opening a .alert with TextField, opening a SwiftUI.Menu). Writes themselves (try await db.write { ... }) are fast — ~20–60ms end-to-end, confirmed via timing instrumentation.

The lag disappears completely when SyncEngine is not constructed at app init. Writes still work via the local SQLite db, and every write still triggers @FetchAll observation fire, but there's no lag. The lag only appears when SyncEngine is running in the background doing CloudKit fetches.

The iOS runtime emits this diagnostic repeatedly while the lag is occurring:

Potential Structural Swift Concurrency Issue: unsafeForcedSync called from Swift Concurrent context.

The warning continues to appear even when SyncEngine is disabled, which suggests @FetchAll itself is doing the unsafeForcedSync call on its read path — but without SyncEngine, there's no frequent background db-change to trigger the sync read, so it's invisible in practice.

Hypothesis

@FetchAll appears to perform a synchronous main-thread read of the database via GRDB's sharing/reader machinery whenever it observes a change. When SyncEngine is running, every CloudKit willFetchChanges / didFetchChanges / willSendChanges / sentDatabaseChanges cycle modifies the local db, which causes @FetchAll to re-fetch — on the main actor, synchronously. Multiple rapid fires compound into observable UI lag.

With SyncEngine disabled, the only db changes are user writes (rare compared to sync activity), so the single @FetchAll re-fetch per interaction is imperceptible. With SyncEngine enabled, background fetches happen continuously and each one pays the synchronous-main-read cost, stacking up into noticeable freezes on UI transitions.

Reproduction

  1. Construct a SyncEngine for a db with a handful of tables and a few dozen rows:
    let engine = try SyncEngine(
      for: db,
      tables: Tag.self, Node.self, NodeTag.self
    )
    prepareDependencies { $0.defaultSyncEngine = engine }
  2. Build any view that uses @FetchAll(SomeTable.order(by: \.sortOrder)) var rows: [SomeTable] to render a list or summary.
  3. In that view or a child view, add a .alert("Rename", isPresented: $showing) { TextField("name", text: $draft); Button("Save") { ... } }.
  4. Tap a button that flips showing = true.
  5. Observe the delay between the tap and the keyboard becoming ready to accept input.

Expected

Keyboard should appear within a frame or two of the tap (~100ms). SyncEngine background work should be fully off-main.

Actual

~500ms–1000ms stall between tap and keyboard-ready. The delay scales with how recently CloudKit sync was active. "Second attempt feels less laggy" because the background fetch cycle has settled.

What didn't fix it (confirmed)

  • Memoizing @FetchAll-derived computed properties with @State + .onChange(of: allNodes). Reduced the number of derived-computation runs per render but didn't address the underlying synchronous read.
  • Running the derivation in Task.detached inside .task(id: allNodes). Moved the derivation off-main successfully but introduced a separate bug (stale values passed through NavigationStack cached destinations). Reverted.
  • startImmediately: false on the SyncEngine init + explicit start() from a later .task. Addresses the cold-start sync hit only; ongoing lag remains.

What fixed it (unfortunately)

  • Not constructing the SyncEngine at all (skipping the prepareDependencies { $0.defaultSyncEngine = ... } registration). Falls back to the no-op stub in DefaultSyncEngine.previewValue. UI becomes fully smooth immediately. This is what we've shipped as the current stable state — single-device only, no cross-device sync.

Asks

  1. Can @FetchAll's read path be made fully asynchronous so that observation fires don't block the main actor? Or is there a configuration option to throttle/debounce observation fire frequency?
  2. Is there a way to run SyncEngine's background CloudKit fetch work so that the downstream @FetchAll observers are batched or deferred instead of firing synchronously on each individual record-zone change?
  3. Is the unsafeForcedSync warning expected in the current implementation, or does it indicate a known issue that's being worked on?

Happy to provide an Instruments trace (Time Profiler, Swift Concurrency, System Trace) if it would help narrow down the blocking call. Let me know which would be most useful and I'll get one recorded.

Thanks for the library — the DSL story is fantastic, we just ran into a wall on the sync side and want to understand the right way through it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions