Skip to content
Back to blog

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

July 6, 20266 min read

Mobile users lose signal in elevators, on flights, and in rural areas — but they still expect your app to work. An offline-first architecture writes every create, update, and delete to a local database immediately, queues changes in an outbox, and syncs with your server when connectivity returns.

This article walks through the full loop: local writes, ordered push with retries, server application, marking items synced, and pulling remote changes so edits made on the web show up on mobile too.

Local DBOutboxPush APIServerPull APIMobile writes immediately; sync runs when connectivity returns
Offline-first sync: write locally, push outbox, pull server changes

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. The outbox is your guarantee that no user action is lost while offline. The local DB is the source of truth for what the user sees until sync completes.

CreateUpdateDeleteLocal DB\n+ OutboxUI stays fast — no waiting for the network on every tap
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.

1OnlineDetect connection2PushOrdered queue3RetryOn 5xx / timeout4SyncedMark complete
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.
Before
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}
After
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 API receives each pushed change, validates it, and applies it to the authoritative database. Use idempotency keys (client mutation id) so the same outbox item can be retried without double-inserting. Return the server-assigned id and `updated_at` timestamp in the response.

The mobile app marks the outbox row as synced on success and updates the local entity with server fields (canonical id, server timestamp). If the server rejects a change (validation error, permission denied), mark the outbox item as failed and show the user an actionable error — do not retry forever on 400-class responses.

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.

Mobile App\nlast_synced_atGET /changes\n?since=timestampMerge into\nlocal DBWeb edits and other devices appear on mobile after the next pull
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 all pending outbox items until empty or blocked, then pull server changes since `last_synced_at`, then update the UI. Repeat on connectivity changes, app resume, and background fetch intervals.

Watch for conflicts — two devices editing the same row offline. Common strategies: last-write-wins (simple, lossy), server-wins, client-prompt, or field-level merge. Document your choice; users care when their edit disappears. For most CRUD apps, last-write-wins with server `updated_at` comparison is enough to start.

MobilePush outboxPull changesServer API
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

Share:

Offline-first mobile sync is a loop, not a one-time API call. Write every change to local storage and the outbox immediately. When online, push the queue in order with retries until the server confirms each item. Then pull changes newer than `last_synced_at` so web and other devices stay in sync. Get this loop right and your app feels instant offline and trustworthy online — get it wrong and users lose data in a tunnel.

Related Articles

Microservices are not a single tool — they are an ecosystem. You need containers to package services, databases to store

Read

Every new project faces the same question: one big application or many small services? The answer is rarely binary. A mo

Read

Every public API needs rate limiting — to prevent abuse, protect downstream services, and ensure fair usage across tenan

Read

Keep learning

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