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
- 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 }
- Build any view that uses
@FetchAll(SomeTable.order(by: \.sortOrder)) var rows: [SomeTable] to render a list or summary.
- In that view or a child view, add a
.alert("Rename", isPresented: $showing) { TextField("name", text: $draft); Button("Save") { ... } }.
- Tap a button that flips
showing = true.
- 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
- 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?
- 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?
- 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.
Environment
sqlite-dataversion: 1.6.1 (via Swift Package Manager)default-isolation=MainActor, strict concurrency checking enabledswift-structured-queries0.31.1,swift-dependencies1.12.0,swift-sharing2.8.0,GRDB7.10.0Summary
On an app that uses
@FetchAll+SyncEnginefor 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 aTextField, opening a.alertwithTextField, opening aSwiftUI.Menu). Writes themselves (try await db.write { ... }) are fast — ~20–60ms end-to-end, confirmed via timing instrumentation.The lag disappears completely when
SyncEngineis not constructed at app init. Writes still work via the local SQLite db, and every write still triggers@FetchAllobservation fire, but there's no lag. The lag only appears whenSyncEngineis running in the background doing CloudKit fetches.The iOS runtime emits this diagnostic repeatedly while the lag is occurring:
The warning continues to appear even when
SyncEngineis disabled, which suggests@FetchAllitself is doing theunsafeForcedSynccall on its read path — but withoutSyncEngine, there's no frequent background db-change to trigger the sync read, so it's invisible in practice.Hypothesis
@FetchAllappears to perform a synchronous main-thread read of the database via GRDB's sharing/reader machinery whenever it observes a change. WhenSyncEngineis running, every CloudKitwillFetchChanges/didFetchChanges/willSendChanges/sentDatabaseChangescycle modifies the local db, which causes@FetchAllto re-fetch — on the main actor, synchronously. Multiple rapid fires compound into observable UI lag.With
SyncEnginedisabled, the only db changes are user writes (rare compared to sync activity), so the single@FetchAllre-fetch per interaction is imperceptible. WithSyncEngineenabled, background fetches happen continuously and each one pays the synchronous-main-read cost, stacking up into noticeable freezes on UI transitions.Reproduction
SyncEnginefor a db with a handful of tables and a few dozen rows:@FetchAll(SomeTable.order(by: \.sortOrder)) var rows: [SomeTable]to render a list or summary..alert("Rename", isPresented: $showing) { TextField("name", text: $draft); Button("Save") { ... } }.showing = true.Expected
Keyboard should appear within a frame or two of the tap (~100ms).
SyncEnginebackground 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)
@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.Task.detachedinside.task(id: allNodes). Moved the derivation off-main successfully but introduced a separate bug (stale values passed throughNavigationStackcached destinations). Reverted.startImmediately: falseon theSyncEngineinit + explicitstart()from a later.task. Addresses the cold-start sync hit only; ongoing lag remains.What fixed it (unfortunately)
SyncEngineat all (skipping theprepareDependencies { $0.defaultSyncEngine = ... }registration). Falls back to the no-op stub inDefaultSyncEngine.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
@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?SyncEngine's background CloudKit fetch work so that the downstream@FetchAllobservers are batched or deferred instead of firing synchronously on each individual record-zone change?unsafeForcedSyncwarning 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.