Back to Blog

Offline-first on iOS: sync, conflicts, and earning user trust

Offline-first is not a checkbox. It is a contract with the user about what happens when the network disappears, how conflicts get resolved, and whether their data is actually safe.

9 min read

Most “offline support” is a lie told by a loading spinner.

The app caches a few responses, shows them when the network fails, and calls itself offline-first.

It is not.

That is offline-adjacent. Offline-curious at best.

Real offline-first means the app works correctly without a network connection. Data is created, edited, and deleted locally. Sync happens eventually. Conflicts are resolved deliberately. And the user understands what is happening instead of guessing whether their changes survived.

That is a much bigger commitment.

1. Decide what offline-first actually means for your app

Not every app needs the same contract.

A news reader can survive with aggressive caching and a “last viewed” snapshot.

A note-taking app cannot. If the user writes three paragraphs in airplane mode and they disappear after landing, that is not a bug. It is a breach of trust.

Before building anything, define the contract:

  1. what actions must work offline?
  2. how long can local changes wait before sync?
  3. what happens if the server rejects a change?
  4. what does the user see while changes are pending?
  5. what happens when two devices edit the same thing?

If the answers are vague, the implementation will be vague too. And vague sync code is where data loss hides.

2. Local persistence is not an afterthought

You need a local source of truth that does not depend on the server being reachable.

Core Data, SwiftData, GRDB, Realm, or even SQLite directly. Pick one, understand its concurrency model, and treat it as the primary store. The server is a replica, not the boss.

A common mistake is designing the local schema as a mirror of the API response. APIs are shaped for transport. Local storage should be shaped for the queries your app actually runs.

Examples:

  • the API sends flat objects with foreign key strings
  • the local store probably needs relationships, indexes, and fetched properties for offline queries
  • the API may paginate or omit fields for bandwidth
  • the local store needs the full object to render detail screens offline

If your local schema is just Codable structs dumped into a key-value store, you do not have offline support. You have a JSON graveyard.

3. Sync is a state machine, not a network call

The naïve approach:

  1. user makes a change
  2. fire a POST request
  3. if it fails, show an error

That is online-first with error handling. It breaks the moment the user creates two items before the network returns.

A real sync system models local changes as pending operations:

struct PendingChange: Identifiable {
    let id: UUID
    let objectID: String
    let changeType: ChangeType // create, update, delete
    let payload: Data
    let createdAt: Date
    var retryCount: Int
    var status: SyncStatus // pending, syncing, failed
}

Now sync is a background process that:

  1. collects pending changes in order
  2. attempts to push them
  3. marks successes and retries failures with backoff
  4. merges server responses back into local state
  5. never blocks the UI on network latency

This is more code than URLSession.shared.dataTask. That is why most apps skip it until they lose user data.

4. Ordering matters more than people think

If a user creates a folder and then creates a note inside it, those operations have a dependency.

Sending them in parallel to the server will fail because the note references a folder that does not exist upstream yet.

You need one of these strategies:

  • client-generated IDs so the server never has to assign identity mid-sync
  • dependency tracking so operations wait for prerequisites
  • server-side upsert that can handle out-of-order idempotently

Client-generated IDs are usually the least painful. UUIDs are free. Use them.

5. Conflicts are inevitable. Avoiding the conversation is a choice.

Sooner or later, the same record changes in two places.

The wrong response is pretending this will not happen.

The second-wrong response is always taking the server version because it is easier.

Conflict resolution strategies, from lazy to honest:

Last-write-wins by timestamp

Simple, wrong often enough to matter. Clocks drift. Users edit offline for hours. A timestamp is not truth.

Server-wins always

Easy to implement, expensive to explain to a user who just lost their edit.

Merge by field

If two users edited different fields, keep both changes. This works for some document types and breaks for others. Know which one you are building.

Manual resolution

Show the conflict. Let the user choose. This is the most respectful option and also the most work. Use it when data loss would be embarrassing.

Vector clocks or CRDTs

The technically correct answer for collaborative editing. Most product apps do not need this complexity. If you do, you already know.

A good default for most iOS apps:

  • detect conflicts instead of silently overwriting
  • prefer the local version if it has user-visible changes and the server version is older in product terms
  • log every conflict so you know how often your strategy is wrong
  • expose a manual resolution path for the cases that matter

6. The UI should never lie about sync status

Users can tolerate slow sync.

They cannot tolerate invisible sync.

If a change is only local, the user should know. Not with a banner. Not with a tutorial. With a calm, persistent indicator that says “changes saved on device, syncing when online.”

When sync finishes, a brief confirmation is enough. When sync fails, the user needs an action, not a passive error state.

Patterns that work:

  • a small status icon on edited items
  • a global sync bar that appears only when there is pending work
  • pull-to-refresh that actually retries failed operations, not just fetches
  • settings that show last successful sync time

Patterns that erode trust:

  • optimistic updates that revert without explanation
  • “saved” checkmarks that mean “sent to the server” but not “persisted locally”
  • silent retries that burn battery overnight
  • error toasts that disappear before the user reads them

7. Background sync has rules you do not control

iOS gives you background tasks, but it does not guarantee when they run.

BGAppRefreshTask and BGProcessingTask are helpful for opportunistic sync, but they are not reliable enough for urgent delivery.

Push notifications can trigger a background fetch, but Apple throttles them. And the user can disable background refresh for your app entirely.

What this means:

  • design for delayed sync, not instant sync
  • use local notifications to tell the user when important operations finally complete
  • never assume a background task will run because you scheduled it
  • keep retry budgets conservative. The OS will punish a greedy app.

If your product requires real-time sync, you need WebSockets or push-triggered fetches while the app is foregrounded. Background-only real-time sync is not a thing on iOS.

8. Test the unhappy path deliberately

Most offline testing is:

  1. turn on airplane mode
  2. tap one button
  3. turn airplane mode off
  4. hope

That is not testing. That is a demo.

Real tests include:

  • creating, editing, and deleting multiple items offline
  • going offline mid-sync
  • killing the app with pending changes
  • restoring from backup with stale local data
  • sync conflicts where both versions have legitimate edits
  • server errors that reject a batch after partial success
  • database migrations when the local schema is ahead of the server

If you do not test the failure paths, your users will.

9. Migrations and schema evolution are part of the contract

Local databases have schemas. Schemas change. Unlike server databases, you cannot run migrations behind a deployment gate.

A user might skip three app versions and then update. Their local database is four migrations behind.

Plan for this:

  • version your local schema explicitly
  • keep migrations reversible where possible
  • test migrations on realistic data sizes, not empty databases
  • have a graceful degradation path if a migration fails

If a migration corrupts local state on update day, all the offline-first work was wasted.

10. Security does not pause for airplane mode

Local data is still user data. It needs the same protections as data in transit.

At minimum:

  • encrypt the local database if it contains sensitive information
  • use the keychain for credentials and sync tokens
  • do not log pending changes with full payloads in crash reports
  • respect the user’s device passcode and biometric settings

“It is only local” is not a security policy.

11. The practical baseline I would ship

For most product apps, this is a defensible starting point:

  1. local database as the primary source of truth
  2. client-generated IDs for all created objects
  3. pending change queue with retry and backoff
  4. background sync via BGProcessingTask for non-urgent work
  5. foreground sync on app launch and resume
  6. conflict detection with server-wins or manual resolution
  7. visible sync status in the UI, never hidden
  8. explicit tests for offline create, edit, delete, and conflict paths

That is not the most advanced sync architecture possible.

It is one that will not lose user data while you are busy adding AI features.

12. The real goal is user trust, not technical elegance

Offline-first is not impressive because it is hard.

It is important because it is a promise.

The promise is: your data is safe even when the world is not cooperating.

When that promise breaks, users do not file detailed bug reports about sync semantics. They delete the app and pick one that kept their notes.

So start with the contract. Build the state machine. Handle conflicts honestly. Tell the user what is happening. Test the failures.

And if you are not willing to do that, do not call your app offline-first.

Call it cached.

Users know the difference.