Home / Blog / Building an offline-first app on CloudKit

Building an offline-first app on CloudKit

How to build an app that keeps working without the internet and auto-syncs when it comes back. What I learnt across two projects on CloudKit.

I sync user data with CloudKit in Dentii and Snoozio. Both have to work offline-first: users keep using the app on a plane, on the metro, and on flaky wifi. Across the two apps I picked up a few lessons, and this is what I learnt.

Offline-first CloudKit comes down to four core pieces: local cache, sync engine, conflict resolution, and error handling. Here they are in order.

1. The local cache is mandatory

CloudKit by itself is not offline-first. Reaching a CKRecord needs the network. For offline, everything has to also live locally.

Pre-iOS 17: CoreData + CloudKit mirror (NSPersistentCloudKitContainer)
iOS 17+: SwiftData + CloudKit (isCloudKitEnabled: true)

Both approaches treat local storage as the source of truth. The data is on the device even when the app is closed. CloudKit syncs it across devices over the network.

Critical: the local-cache schema has to match the CloudKit schema exactly. You added a field locally? You have to add it in the CloudKit dashboard too. Otherwise sync silently breaks.

2. Setting up the sync engine

CloudKit has three sync mechanisms:

CKFetchChanges: pulls records that changed since the last sync. Triggered by a push notification.

CKSubscription: you get a silent push when a record changes. iOS wakes the app in the background and you trigger a sync.

NSPersistentCloudKitContainer (Core Data): Apple does all of the above automatically. You just configure the container correctly.

I’ve used all three. My recommendation: where possible, lean on Apple’s automatic sync. Only build manual sync if you have a genuinely unusual requirement.

3. Conflict resolution strategy

Offline, the user edits a record on device 1. At the same time, they edit it on device 2. When they sync, there are two versions, which wins?

CloudKit defaults to last-write-wins. Whichever device syncs later wins. That often causes problems, the older device’s edits vanish.

Better strategies:

Field-level merge: timestamp every field and let the latest writer per field win. More complex, but minimises data loss.

User-prompted resolution: on conflict, ask the user “version A or B?” Fine for critical data (financial, medical).

Operational transformation: record changes as operations and apply them on both sides. Needed for Google Docs-style collaborative editing, overkill for simple apps.

In Dentii I used field-level merge. Every brushing session is timestamped and merges automatically across devices. In Snoozio last-write-wins is enough, sleep data usually comes from a single device.

4. Error handling

CloudKit has its own set of error codes:

  • Quota exceeded: the user’s iCloud storage is full. The app’s storage request was rejected.
  • Network unavailable: offline. Queue for sync, retry later.
  • Server conflict: record version mismatch. Refetch and merge.
  • Record not found: you tried to update a deleted record. Delete it locally too.
  • Permission failure: user hasn’t signed into iCloud or has disabled access. Surface it in the UI.

Each one needs its own recovery path. What I hit most often:

switch error.code {
case .networkUnavailable, .networkFailure:
    // Add to retry queue
case .serverRecordChanged:
    // Conflict resolution
case .quotaExceeded:
    // Warn the user
case .notAuthenticated:
    // Prompt iCloud sign-in
default:
    // Log, retry with backoff
}

5. Background sync setup

For sync to run when the app is in the background:

Register a periodic sync with BGTaskScheduler (iOS 13+):

BGTaskScheduler.shared.register(
    forTaskWithIdentifier: "com.app.sync",
    using: nil
) { task in
    handleBackgroundSync(task: task as! BGAppRefreshTask)
}

Apple gives you two to three background executions a day. Don’t plan to do all your sync in one shot, set realistic expectations.

You also get silent pushes from CKSubscription. The app wakes up and syncs. That’s the most reliable background sync method.

6. Testing

CloudKit testing is hard because production and development environments are separate. My strategy:

  1. iCloud testing in the development environment. Create iCloud test accounts and sign into the simulator with them.
  2. Conflict simulation. Two simulators, one account, offline edits on both, then sync.
  3. Network degradation. Simulate slow and flaky connections with Network Link Conditioner.
  4. Production smoke test. Before release, a few hours of testing in the production environment across two devices.

UX notes

Designing an offline-first app, UX details matter:

Show a sync indicator. Users should be able to answer “is my data syncing?” visually.

Show the last sync time. “Last sync: 2 minutes ago”. Reduces uncertainty.

Offer a manual refresh. Pull-to-refresh or a sync button. When users don’t trust the automatic sync, they can force it.

Be explicit about offline state. “Offline, your data is saved on this device”. Users shouldn’t think they’ll lose what they’ve written.

Takeaway

Offline-first CloudKit is a one or two week effort, but it pays off. The UX is uninterrupted even in airplane mode. Done right on the sync side, users don’t even notice CloudKit exists, they just see “the data keeps working”.

The biggest mistake is skipping the local cache and doing everything over the network. Start with an offline-first mindset and let the network be the optimisation. Go the other way around and retrofitting offline support is three times the work.

Have a project on this topic?

Leave a brief summary — I’ll get back to you within 24 hours.

Get in touch