Skip to content
macOSDeep Dive Published Updated 6 min readViews unavailable

CloudKit CKSyncEngine on macOS: Persisted State and Crash Recovery

Persist CKSyncEngine state with local data, scope checkpoints to each account and database, and test serialized events and crash recovery.

CKSyncEngine is Apple’s higher-level coordinator for moving a macOS app’s local and remote CloudKit record changes. It schedules fetches and sends when system conditions allow, tracks server-side sync state, and asks the app’s delegate for the records it should send. The app still owns its local data, its conflict policy, and the durable relationship between that data and the engine’s checkpoint. This article focuses on CKSyncEngine event delivery and state serialization; the related zone-sync article covers the lower-level zone-change token and tombstone workflow.

That last boundary is the part most likely to fail in a restart test. A record can be saved locally, a sync event can update the engine’s opaque state, and the process can exit between two persistence operations. If the next launch restores a cursor-like engine state that no longer corresponds to the local records or pending edits, the app may skip work, resend stale data, or need a full reconciliation. Treat the local model and latest serialized engine state as one recovery unit.

Pick the database scope and owner before creating the engine

Apple documents CKSyncEngine for private and shared databases and explicitly says not to use it for an app’s public database. The database scope is part of the engine’s identity: a private store for one signed-in iCloud account is not interchangeable with a shared database or another account’s private data. Store checkpoints with the identity and schema version your app uses to find the matching local store.

Create one engine for each database you intend to synchronize, and keep its delegate and local persistence coordinator alive for the duration of that engine. Apple’s configuration guidance calls for a separate engine for each database; keep one app-owned coordinator for that database scope rather than recreating engines from views or refresh controls.

Automatic sync is conditional, not a deadline. Apple’s documentation says the engine’s schedule depends on conditions such as network availability, account sign-in, battery, and system load. If an interaction genuinely requires a fresh fetch or prompt send, use the manual operation outside the delegate event currently being handled. Design the UI to represent “queued” or “last synced” rather than promising that a periodic cycle will run by a fixed time.

Persist the newest opaque serialization

CKSyncEngineState tracks information such as database and zone change tokens, subscription identifiers, the latest user record ID, and pending changes. Apple exposes a serialized representation so the app can restore that state on a later launch. The serialization is opaque: persist and restore it as provided; do not parse it, edit its internals, or turn individual fields into your own tokens.

The delegate receives state updates as events. On the first initialization, pass nil for stateSerialization; on subsequent launches, pass the most recently committed serialization for the same database and local account. In a state-update event, replace the previously stored snapshot with the event’s newest serialization. Apple explicitly assigns the app responsibility for persisting it across launches.

For example, a persistence layer can make the checkpoint boundary explicit:

func handleEvent(_ event: CKSyncEngine.Event,
                 syncEngine: CKSyncEngine) async {
    switch event {
    case .stateUpdate(let update):
        do {
            try await checkpointStore.commit(
                localStore: await modelStore.snapshot(),
                engineState: update.stateSerialization
            )
        } catch {
            await syncHealth.recordCheckpointFailure(error)
        }
    default:
        await route(event, through: modelStore)
    }
}

checkpointStore and the commit method above are app-defined. If the model store and checkpoint share a transactional database, commit them together. If they live in different stores, keep a durable outbox or generation marker so a restart can detect and reconcile an interrupted pair. Do not silently ignore serialization-write failures: after the app reports a successful local edit, it must know whether that edit and the engine’s current checkpoint are durable.

The sample code Apple publishes is useful for learning the callback shape, but its README and implementation identify themselves as a sample and include abbreviated error recovery. Production persistence should make write failures observable and should test interruptions between local record saves and state updates rather than copying a sample’s convenience behavior as a durability guarantee.

Keep event handling ordered and idempotent

Apple documents that the engine delivers delegate events serially and does not deliver its next event until the current event handler finishes. Keep each handler bounded, persist the resulting local mutation before acknowledging it in your own model, and avoid invoking fetchChanges or sendChanges from inside handleEvent when that call could generate more engine events. Trigger an explicit sync from a separate coordinator path after event handling has returned.

For remote record changes, make applying the change to the local model idempotent. A process can stop after changing a row but before saving the new engine serialization; on relaunch the engine may present work again. Use record IDs and an application-level version or merge rule to ensure that applying the same server event twice does not duplicate side effects such as notifications, append-only audit rows, or user-visible activity.

Do not assume the sync engine resolves every application conflict. Apple calls out CKError.serverRecordChanged as an app-specific case: compare the client record, server record, and ancestor, apply the product’s merge rule to the current server version, and retry if appropriate. “Last writer wins” may be acceptable for a preference, but not necessarily for a document, financial record, or collaborative edit. Make that decision in the data model, not in generic retry code.

Separate account changes from ordinary restarts

CKSyncEngine monitors account status and reports account-change events, but Apple leaves the app responsible for updating its local persistence. When the account changes, the engine resets its internal state and clears its pending database and record-zone changes; do not assume an engine-queued change will survive that transition. Keep any unsent user work in an app-owned durable outbox or local model, keyed to the account that created it, and explicitly decide whether to retain, discard, or export it under the app’s data policy. Never replay a previous account’s private mutations into the newly signed-in account. Scope local records and serialized state to the account and database that produced them. On sign-out or account replacement, stop treating the previous account’s private data as the current user’s cache; reconcile its outbox separately, then initialize the new account’s engine with only matching state or nil for a fresh store.

Use a recovery checklist for launch and account transitions:

  1. Load the local store and its checkpoint version before initializing the engine.
  2. Confirm the checkpoint belongs to the current database scope and account context.
  3. Initialize with the last committed serialization, or nil only for a genuinely new state.
  4. Persist state-update events and apply fetched changes through the same durable recovery boundary.
  5. Surface storage, permission, account, and CloudKit errors distinctly; retry transient failures without discarding the checkpoint.

Test by terminating the app after a local edit, during an event handler, immediately after a state write, and during an account change. Reopen with no network, then reconnect and verify the app converges without lost edits or duplicate local side effects. Apple’s sample requires macOS 14 or later for its Mac target and notes that remote-notification behavior should be tested on a real Mac rather than assumed from a simulator run. Keep this integration test separate from UI tests that only verify rendered records.

Related:

Sources:

Comments