Offline-First Mobile Sync: Local DB, Outbox Queue, and Server Pull
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.
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.
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.
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.
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.
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.
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.
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.
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.
Related Articles
Explore this topic