CloudKit Zone Change Sync: Durable Tokens, Tombstones, and Local Transactions
Build a recoverable CloudKit cache with separate database and zone tokens, atomic local application, deletion tombstones, and bounded change fetching.
CloudKit change fetching is a synchronization protocol for maintaining a local cache, not a guarantee that two devices will mutate shared state in the same order. The client supplies an opaque server change token, processes changed records and deletions, and saves a newer token so the next fetch begins from that point. Correctness depends on applying record changes and checkpointing the token as one recoverable local transaction.
This article focuses on record-zone changes with CKFetchRecordZoneChangesOperation. The operation is appropriate for private and shared databases; Apple documents that the public database does not support this record-zone change operation. For applications that prefer a higher-level synchronization coordinator, evaluate CloudKit’s sync engine APIs separately. Do not mix a database-change token with a record-zone token: they describe different scopes and are not interchangeable.
Model zones before storing tokens
CloudKit records live in a database and a record zone. A record ID includes a record name and zone ID, so a local cache key must preserve both. Do not assume all records belong to the default zone, particularly in a shared database where zone membership can change as shares are accepted or removed.
The first fetch for a zone passes no prior token and discovers the change history the operation returns. Subsequent requests pass the last committed zone token. Treat the token as opaque. Do not compare its bytes, use it as a timestamp, or infer user-visible ordering from its contents. Persist it securely and version the local storage format around the token together with the database and zone identifiers it belongs to.
Database-level change fetching has a separate role: it reports zone-level changes needed to discover which zones exist or were removed. In a shared database, zone membership can change as the user joins or leaves shares. A robust shared-database client reconciles the set of zones and then fetches record changes for the relevant zones. A private-database client can likewise use database changes when it creates or removes custom zones.
Apply records and tokens atomically
The dangerous checkpoint is saving a token before the corresponding records have been committed locally. If the process crashes after persisting the new token but before saving a changed record, the next fetch can skip that record forever. Use one local database transaction or a durable staging log so record mutations, deletion tombstones, and the final zone token move forward together.
During a fetch, the operation may report token updates and multiple batches. Keep intermediate progress separately from the last fully committed token. One recovery strategy is to stage all incoming changes for a zone generation, apply each batch idempotently, and only advance the durable checkpoint when that batch’s data is committed. If the operation fails mid-flight, resume from the last committed checkpoint or perform a documented full reconciliation. Avoid treating a callback that merely supplied a token as proof that all earlier writes reached durable storage.
import CloudKit
struct ZoneCheckpoint: Codable {
let databaseScope: String
let zoneName: String
let ownerName: String
let archivedToken: Data?
let localGeneration: Int
}
func recordKey(for id: CKRecord.ID) -> String {
"\(id.zoneID.ownerName)/\(id.zoneID.zoneName)/\(id.recordName)"
}
The key includes zone owner and zone name as well as record name; a real local schema should use a typed composite key rather than relying on string concatenation. The example intentionally models token bytes as archived data because the token is an opaque secure-codable object. The storage transaction must couple that value to the matching database scope and zone.
Deletes are first-class changes
Change feeds include record deletions. A local mirror must remove the row or retain a tombstone according to its own conflict and undo policy. A tombstone can be required when local edits are pending or when the deletion has to propagate to another local subsystem. If the client ignores deletion callbacks, deleted CloudKit records can remain visible indefinitely in its cache.
Do not infer that a missing record in one response was deleted. A response can be paginated, filtered by configuration, or interrupted. Use the explicit deletion result and the zone’s final completion boundary. Preserve the deleted record’s identity and type as required by the application schema, since the full server record may no longer be available.
Pagination, retries, and operation lifetime
CloudKit can return zone changes in batches. Respect the operation’s moreComing or equivalent result indication and continue fetching until the zone reaches its final token. Do not schedule a tight retry loop after a transient error; use a bounded retry policy and let system push notifications act as hints that remote state may have changed. Notifications are not the change log itself. Fetch from the saved token to determine authoritative updates.
Callbacks execute away from the main thread. Keep callback processing small, serialize mutations for the same zone, and hand UI updates to the main actor after the local transaction commits. Do not issue overlapping operations that race to update the same zone checkpoint. A per-zone actor or operation coordinator can own the current token, in-flight generation, and retry state.
The operations API exposes distinct results for individual record changes, deletions, zone completion, and operation completion. Handle per-record failures without silently advancing the checkpoint past data that the local model requires. If a record is invalid for the current app schema, quarantine or report it according to an explicit migration policy rather than crashing the entire sync loop.
Conflict and local-edit policy
Fetching remote changes is only one half of synchronization. Local writes can conflict with records changed on another device. Define whether the product uses server-wins, client-wins, field-level merge, or an explicit user conflict. Do not overwrite an unsaved local edit merely because a fetched record has a later server token; tokens indicate change history, not semantic authority for your domain.
Use a stable record identity and a versioned local model. When applying a remote record, distinguish remote fields from local-only state such as upload status, derived indexes, and user interface preferences. A server record missing a field does not automatically imply that a local-only field should be erased. Track dirty local mutations until the server acknowledges or a conflict policy resolves them.
Shared database edge cases
Shared database zones are dynamic. The client should not assume the set of zones stays fixed across app launches or device changes. Handle zone addition, deletion, permission changes, share revocation, and account changes as normal synchronization events. When access changes, clear local representations that the user is no longer entitled to keep, following the app’s data policy.
CloudKit account status and network availability can also change while work is in flight. Persist enough state to resume after the user signs in, reconnects, or relaunches the app. Do not block app startup waiting for a full historical fetch. Show cached data with a synchronization state if the product permits offline use, and distinguish stale cache from failed persistence.
Acceptance tests
Test initial full-zone fetch, one changed record, one deleted record, multi-batch response, crash before local commit, crash after local commit but before checkpoint, token expiration or reset handling, account switch, zone removal, shared-zone addition, transient network failure, and concurrent local edits. After every injected crash, assert that no acknowledged change is skipped and replayed changes do not create duplicates.
Log database scope, zone ID, local generation, batch count, received changes, tombstones, last committed token presence, operation duration, and error category. Do not log record payloads or token contents. Alert when a zone repeatedly fails at one checkpoint or when the local cache generation falls behind a full reconciliation threshold.
The durable rule is simple: tokens are per-scope checkpoints, record changes and deletion handling are durable before a checkpoint advances, and retries are idempotent. CloudKit transports changes; the application must make its local state recoverable under interruption and conflict.
Related:
- Core Data on macOS: Background Contexts and Persistent History
- SwiftData Schema Migrations on macOS: Versioned Models and Recovery
Sources: