Offline-First Apps: Local Storage, Sync and Conflict Resolution

Offline-first app design explained: local databases, an outbox of pending changes, delta sync with cursors, conflict resolution and background sync.

Advanced 3 min read · Updated

An offline-first app treats the device's local database as the primary source the UI reads and writes, and synchronises with the server in the background whenever a connection is available. Users can keep working on a plane, in a basement or on a flaky network, and their changes are never lost. The design centres on four pieces: a local store, an outbox of pending changes, incremental sync from the server, and a rule for resolving conflicts.

Architecture

applyUILocal databaseSQLite · IndexedDBOutboxpending mutationsSync enginepush · pull · retrySync APIchanges since cursor
The UI only talks to the local database. A sync engine pushes queued local changes and pulls server changes since the last cursor.

Writing locally: the outbox

  1. A user action writes to the local database immediately, so the UI updates instantly, and appends a mutation to an outbox in the same local transaction.
  2. Each mutation has a client-generated unique ID (an idempotency key) and, for new records, a client-generated record ID such as a UUID, so the record can be referenced before the server knows about it.
  3. The sync engine sends outbox entries in order when online, retrying with backoff. The server deduplicates by mutation ID, so retries after a lost response are safe.
  4. Once acknowledged, the entry is removed. If the server rejects it (validation, permissions), the app must show the user and roll back or fix the local change.

Reading changes: delta sync

  • The server keeps a monotonically increasing change sequence (or per-table updated-at versions) and exposes "give me changes since cursor X".
  • The client stores the last cursor it applied and pulls only newer changes, in pages. A fresh install does an initial full sync, which may need to be limited to recent or relevant data.
  • Deletes must be represented as tombstones in the change feed, otherwise clients never learn that a record disappeared.
  • Push notifications or a real-time channel can tell the client to pull sooner, but the pull protocol stays the source of truth. See push notifications.

Conflict resolution

Two devices edit the same record while one is offline. When both sync, which wins?

StrategyHowGood for
Last-write-wins (per record)The latest timestamp or server arrival winsSimple settings and records rarely edited concurrently
Field-level mergeMerge per field, so editing the title on one device and the due date on another keeps bothForms, task managers
Server authority with rejectionThe server rejects stale versions; the client refetches and asks the userInventory, bookings, money
CRDTsData types that merge automatically and deterministicallyCollaborative text, lists, counters. See CRDTs
Manual resolutionKeep both versions and let the user chooseDocuments where intent matters

Some operations can't be offline-first: paying, booking the last seat, or anything with a global invariant. Queue them as requests with a clear "pending" state, and confirm with the server before showing success.

Practical concerns

  • Background sync: platforms limit background work to save battery; sync opportunistically when the app is opened, on connectivity changes, and through OS background tasks.
  • Schema migrations for the local database must run safely on old app versions' data, and the sync protocol must tolerate clients on old versions. See mobile release engineering.
  • Storage limits and eviction: decide what to keep locally and how to page in older data.
  • Security: encrypt sensitive local data and wipe it on sign-out.
  • UX: show sync status, pending changes and conflicts clearly, so users trust the app.

Where it’s used

Notes and task apps

Local-first storage with an outbox and field-level merges keeps editing instant across devices.

Field service and logistics

Workers record deliveries and inspections offline; data syncs when coverage returns.

Messaging

Messages are written locally with client IDs and sent from an outbox, showing pending and delivered states.

In an interview

  • Make the local database the UI's source of truth.
  • Use an outbox with client-generated IDs and idempotent server handling.
  • Pull deltas by cursor, including tombstones for deletes.
  • Choose a conflict strategy per data type and keep invariant-critical actions online.

Further reading