Όλα τα άρθρα
9 Οκτωβρίου 2026

Offline-first iOS apps: sync, conflict resolution and background fetch

Build an offline first iOS app: keep a local store as the source of truth, sync with an outbox, pick a conflict rule and treat background fetch as a hint.

Hands typing on a white wireless keyboard on a red desk, a developer working on an offline first iOS app

An offline-first iOS app reads and writes against a local database and treats the network as a background job. To build one, you need four things: a local store that is the source of truth, an outbox of pending changes, an explicit conflict rule, and background triggers you never rely on alone.

Most apps fail at the last one. iOS doesn't promise to run your sync code. The design has to work even when it never does. This guide walks through each piece in the order you would build it.

Start with a local store as the source of truth

Every screen should read from the local store and every user action should write to it first. Apple's WWDC19 session on Core Data with CloudKit describes this as a local replica of the data, and gives the reason: local reads take milliseconds at worst, while network fetches can take seconds or minutes. The user never waits on a request to see their own data.

Pick the store first

The store you choose decides which sync options you have. Core Data can mirror to CloudKit with NSPersistentCloudKitContainer. SwiftData and plain SQLite leave the sync layer to you. We compared the persistence options in SwiftData vs Core Data; read that before you commit, because migrating a synced store later is expensive.

Track what changed

Add sync state to every synced record so the app can answer "what still needs to go to the server?" without guessing. A workable minimum:

  • A stable client-generated ID (a UUID), so a record can be created offline.
  • An updatedAt timestamp and a device identifier.
  • A sync state: pending, synced or conflicted.
  • A deletion marker (a tombstone) instead of a hard delete, so deletions can sync too.

Send changes with an outbox and a background URLSession

An outbox is a table of pending mutations: create this record, change this field, delete that one. Each entry carries a unique request ID so the server can ignore a retry it has already applied. The sync engine drains the outbox in order, marks entries done on success and retries failures with a growing delay.

Use a background session for heavy work

For large uploads and downloads, use a background URLSession. Apple's WWDC23 session on resumable transfers states that a background session handles resumption automatically for both download and upload tasks, if the server supports it. It also waits for connectivity, and it runs outside your app's process, so transfers continue if the app is suspended or terminated.

The same session gives a clear split. Use a background session for large transfers that need to persist when a user leaves your app, and for work that is not urgent, such as nightly backups. For small or urgent requests, use a standard session. Setting isDiscretionary lets the system pick efficient moments, and allowsConstrainedNetworkAccess = false keeps you out of Low Data Mode.

Two practical details come from the API rather than the talk. Background sessions need a delegate, and the system can relaunch your app to deliver results, so you must recreate the session with the same identifier and call the stored completion handler from urlSessionDidFinishEvents(forBackgroundURLSession:). If your outbox state lives behind an actor, our Swift 6 strict concurrency migration guide covers how to keep delegate callbacks and the sync engine from racing each other.

Split initial sync from incremental sync

Developers on the Apple forums describe a pattern of a background session for the first, heavy sync and a refresh task for later incremental ones. That's forum-level advice, not official guidance. It does match how the pieces behave: the first sync moves a lot of data, and later ones fetch only what changed since the last cursor.

Decide how conflicts get resolved

A conflict happens when two devices change the same record before either has synced. You cannot avoid them, so choose the rule on purpose.

Node map diagram with three parts: Last writer wins, Model data and Let the server arbitrate
Node map: Decide how conflicts get resolved.

Last writer wins

The simplest rule keeps the change with the latest timestamp. Apple ships it for CloudKit mirroring: the WWDC19 session says conflict resolution is implemented automatically by NSPersistentCloudKitContainer using a last writer wins merge policy. For one person editing their own data on an iPhone and an iPad, that is usually fine.

It breaks down for shared data. Last writer wins drops the other edit silently, and it assumes device clocks agree. If either assumption is shaky, use a server-assigned version number instead of a client timestamp.

Model data to avoid collisions

The same session makes a useful distinction: collaboration is not conflict resolution. Instead of fighting over one long text field, split content into related objects, order contributions deterministically (by date, for example), and record the device that made each change. Apple describes a causal tree built this way as a rough sketch of a conflict-free replicated data type (CRDT). Most apps don't need a full CRDT. Merging by field, so that a title edit on one device and a note edit on another both survive, covers the common cases.

Let the server arbitrate

For a custom backend, send the version you last saw with each change. If the server's version is newer, it returns an HTTP 409, and the client merges and retries. This keeps the rule in one place and lets you change it without shipping an app update.

One CloudKit caveat: a developer who hit "Unable to handle conflict reported by NSCloudKitMirroringDelegate" was told that NSPersistentCloudKitContainer does not support unique constraints. That answer comes from a forum thread, so check it against current documentation, but plan to deduplicate in your own code.

Background fetch is a hint, not a schedule

The WWDC25 session on finishing tasks in the background is blunt: background execution isn't guaranteed, it's opportunistic, often discretionary, and tightly managed. Low Power Mode, Background App Refresh and Low Data Mode all affect scheduling.

Use the right task type

BGAppRefreshTask is meant for silently fetching content before it is used, and frequently used apps get more scheduling opportunities. BGProcessingTask suits heavier work such as database maintenance, and it can require external power and network connectivity. For short work that must finish once started, beginBackgroundTask buys a little extra time.

To use these, register the task identifier at launch and list it in the BGTaskSchedulerPermittedIdentifiers Info.plist key. Then submit a request with an earliestBeginDate. That date is a lower bound, not an appointment.

Handle expiration cleanly

When the system wants the time back, it calls your expiration handler. Apple's advice is to respond promptly: think of the handler as a chance to flip a variable so the task stops gracefully. Whatever happens, call setTaskCompleted. Always. Because the window can close at any moment, save incremental progress early and often and keep work atomic, so the next run picks up where this one stopped.

Add triggers that do not depend on the scheduler

Run a sync every time the app enters the foreground, and again when connectivity returns. A silent push can tell the app that the server has new data; the push notification setup guide covers the APNs side, but Apple notes that background pushes are always considered discretionary, so treat them as another hint.

In iOS 26, BGContinuedProcessingTask lets work a person started in the foreground, such as a large export, continue after they leave the app. It must begin with an explicit user action and report progress, so it fits user-initiated jobs, not automatic sync.

Test the offline paths on a device

Most offline bugs only show up on real hardware with real network failures. Run through these before every release:

  1. Turn on airplane mode, make edits, force-quit the app, then reconnect and confirm the outbox drains.
  2. Throttle the connection with the Network Link Conditioner and interrupt a large upload midway.
  3. Edit the same record on two devices while both are offline, then bring them online in each order.
  4. Test a release build, not just a debugger session. A developer on the Apple forums reported background transfers that worked under Xcode but not in a release build; that is anecdotal, but cheap to rule out.

If you are building this into a product and want a second opinion on the sync design, our team does this work as part of iOS development, and you can reach us through /contact.

Takeaway

Write locally, send through an outbox, decide your conflict rule before you ship, and use background fetch as a bonus on top of foreground and reconnect syncs. If the app is correct when no background task ever runs, it will be correct when one does.

Frequently asked questions

What does offline-first mean for an iOS app?

It means the app reads and writes a local store and treats the network as a background concern. The user never waits on a request to see or save their own data, and sync runs whenever a connection and a background opportunity allow.

Can iOS guarantee background sync will run?

No. Apple describes background execution as opportunistic and often discretionary, and settings like Low Power Mode and Background App Refresh change what runs. Sync on foreground and on reconnect as well.

Should I use CloudKit or build my own sync?

Use CloudKit mirroring through NSPersistentCloudKitContainer when the data belongs to one user's iCloud account and last writer wins is acceptable. Build your own sync when you need a shared backend, field-level merges or your own conflict rules.

Is last writer wins good enough?

For one person's data across several devices, often yes. For shared or collaborative data it silently drops edits, so model the data to avoid collisions or merge by field.