Skip to content

Offline-First Mobile Sync: Local DB, Outbox Queue, and Server Pull

Core Concept LearningJuly 6, 20265 min readUpdated July 21, 2026

Mobile users lose signal in elevators, on flights, and in rural areas, but they still expect edits to survive. An offline-first architecture writes locally, records each mutation in an outbox, and reconciles with the server when connectivity returns. The outbox is also a core event-driven architecture pattern, adapted here to a device database.

For mobile and backend developers who know basic CRUD, this article traces the full push–apply–pull loop. You will be able to design retry-safe writes and a visible 409 conflict path instead of silently overwriting an offline edit. The conflict policy is a practical application of strong vs eventual consistency.

Offline-first sync: push outbox then pull — bidirectional ownership
Offline-first sync: push outbox then pull — bidirectional ownership

Local DB and the Outbox Queue

On mobile, the UI should never block on the network. When a user creates a task, edits a note, or deletes a record, the app writes to a local database (SQLite, Realm, WatermelonDB, or IndexedDB on hybrid apps) in the same transaction that updates the UI.

Every mutation also appends a row to a pending changes table — the mobile outbox. Each entry records the operation type (create, update, delete), entity id, payload, client-generated timestamp, and a monotonic sequence number. If the entity write and outbox insert share one local transaction, the outbox is your best-effort durability against network loss — not against disk wipe, buggy transactions, or silent local drops. The local DB is the source of truth for what the user sees until sync completes.

Every mobile change is saved locally and queued in the outbox
Every mobile change is saved locally and queued in the outbox

Quick reference

  • Create, update, delete all write locally first — instant UI feedback.
  • Outbox row: op type, entity id, payload, sequence, created_at.
  • Use a single DB transaction: entity change + outbox insert.
  • Client-generated UUIDs avoid id conflicts on create.
  • Show sync status in UI: synced, pending, failed.
  • Never call the API directly from a button handler in offline-first apps.

Remember this

Write locally and enqueue every mutation — the outbox is your offline safety net.

Push Pending Changes When Online

When the device detects a connection — network callback, reachability ping, or app foreground — the sync engine reads the outbox in order (by sequence number) and POSTs each change to your server API. Process one batch or one item at a time depending on your conflict strategy.

If the server returns an error or the request times out, retry with backoff — do not drop the item. Exponential backoff (1s, 2s, 4s…) prevents hammering a recovering server. Only remove an outbox row after the server acknowledges success. Failed items after N retries surface in the UI so the user can retry or discard.

On reconnect: push pending changes in order, retry on failure
On reconnect: push pending changes in order, retry on failure

Quick reference

  • Push in sequence order — updates before deletes on same entity matter.
  • Retry on 5xx, timeout, and network errors — not on 4xx validation errors.
  • Idempotency keys on server prevent duplicate applies on retry.
  • Batch small changes to reduce round trips when many items queued.
  • Pause push when battery saver or metered connection if configured.
  • Log sync failures for support — include outbox id and error response.
Naive — fire-and-forget API calls
1// Lost on network failure — no retry, no ordering2async function saveTask(task) {3  await fetch("/api/tasks", { method: "POST", body: JSON.stringify(task) });4}
Outbox — durable, ordered, retriable
1async function saveTask(task) {2  await db.transaction(async (tx) => {3    await tx.tasks.insert(task);4    await tx.outbox.insert({ op: "create", entity: "task", payload: task, seq: nextSeq() });5  });6  syncEngine.schedulePush(); // runs when online7}

Remember this

Push the outbox in order when online — retry failures, never silently drop pending changes.

Server Applies and Marks Synced

The server validates each pushed change and applies it to the authoritative database. Use a client mutation ID for idempotency and a record version (or opaque ETag) for optimistic concurrency; return the canonical ID, version, and updated_at on success.

Conflict chain: device A edits version 7 offline while device B commits version 8. A later sends baseVersion: 7; the server's conditional update matches no row and returns 409 Conflict with the current server value. The client must keep A's outbox item, mark it conflict, and offer reload, field merge, or explicit overwrite. Detect conflicts with 409 metrics, prevent silent loss with version preconditions, and recover only after the user or a deterministic merge policy creates a new mutation against version 8.

Zoom into one offline edit that conflicts with a server update
Zoom into one offline edit that conflicts with a server update

Quick reference

  • POST /sync/push or per-resource endpoints with mutation id header.
  • Server returns canonical id + updated_at for creates.
  • Mark outbox row synced only after 2xx response.
  • 400/403 → failed state, user must fix or discard.
  • 409 conflict → trigger conflict resolution flow.
  • Server updated_at becomes the version cursor for pull.
Server — reject a stale base version
1const result = await db.tasks.update(2  { id: change.entityId, version: change.baseVersion },3  { ...change.patch, version: change.baseVersion + 1 }4);5 6if (result.updatedRows === 0) {7  const current = await db.tasks.find(change.entityId);8  return Response.json(9    {10      error: "version_conflict",11      current,12      attemptedPatch: change.patch,13    },14    { status: 409 }15  );16}
Client — preserve conflict for recovery
1const response = await push(outboxItem);2 3if (response.status === 409) {4  const conflict = await response.json();5  await db.outbox.update(outboxItem.id, {6    status: "conflict",7    serverSnapshot: conflict.current,8  });9  showConflictEditor(outboxItem.payload, conflict.current);10  return; // do not delete or blindly retry this mutation11}12 13if (!response.ok) throw new Error(`sync_failed:${response.status}`);14await db.outbox.delete(outboxItem.id);

Remember this

Server is authoritative on apply — mobile marks outbox items synced only after confirmed success.

Pull Changes from the Server

Push alone is not enough. When someone edits data on the web app, mobile needs those changes too. After a successful push (or on a timer), the mobile app calls a pull endpoint with its last_synced_at timestamp — the server returns every record updated after that time.

Merge pulled rows into the local database. Update last_synced_at to the newest updated_at you received (or the server's sync cursor). Run pull after push in the same sync session so you do not miss changes that arrived while you were uploading. Tombstones or deleted_at fields handle deletes on the server side.

Pull server changes newer than last_synced_at
Pull server changes newer than last_synced_at

Quick reference

  • GET /changes?since=2026-07-06T10:00:00Z or cursor token.
  • Store last_synced_at locally — persist across app restarts.
  • Merge strategy: upsert by id, apply server updated_at.
  • Include deleted records in pull response for local removal.
  • Pull on app launch, after push, and on periodic background sync.
  • Paginate large deltas — page until no more results.

Remember this

Pull everything newer than last_synced_at — web edits flow down to mobile automatically.

The Complete Sync Loop

A typical sync session runs both directions: push pending outbox items until empty or blocked, then pull server changes since the last cursor, then update the UI. Repeat on connectivity changes, app resume, and background fetch intervals.

A 409 is blocked work, not a transient outage. Pause later mutations for the same entity, continue independent entities, and surface the conflict. Last-write-wins is acceptable only when lost edits are explicitly harmless; otherwise use server-wins, a user prompt, or field-level merge. Recovery ends when a new mutation references the latest server version and succeeds.

Bidirectional sync: push local outbox, pull remote changes
Bidirectional sync: push local outbox, pull remote changes

Quick reference

  • Sync order: push outbox → pull changes → refresh UI.
  • Conflict: compare updated_at or version vector.
  • Background sync: iOS BGAppRefresh, Android WorkManager.
  • Optimistic UI: show local state, reconcile after sync.
  • Empty outbox + fresh pull = fully synced state.
  • Test with airplane mode — the only realistic offline test.

Remember this

Push then pull — local outbox up, server changes down, last_synced_at advances each cycle.

Key takeaway

Offline-first sync is a loop: commit local state and outbox together, push idempotently, stop stale writes with a version precondition, then pull from a server cursor. A conflict stays visible until policy or a person resolves it.

Practice (30 min): create one task at version 1, go offline, and queue an edit based on version 1. Change the server copy to version 2, reconnect, and verify all four outcomes: the push returns 409; the outbox row remains with status: "conflict"; the UI shows both versions; and resolving the edit sends a new mutation against version 2 that succeeds. Record the 409 count and confirm a retry never duplicates a create.

Share:

Related Articles

A travel checkout may call a public weather API, your own booking API, and a partner airline API. All three could use RE

Read

This guide is for backend engineers who know HTTP and database transactions but need to decide where six microservice pa

Read

As relational databases grow beyond millions to billions of rows, single-table query performance degrades due to massive

Read

Keep learning

Follow a structured path or browse all courses to go deeper.