Offline-first apps feel “instant” because local data is the default and the network is treated as an occasional helper. The hard part isn’t caching—it’s what happens when two devices edit the same thing and sync later. This guide shows a practical, low-drama way to handle sync retries, idempotency, and conflict resolution so users don’t lose work (or trust).
Quickstart
If you want an offline-first app that users trust, start with these high-impact moves. They work for notes apps, task managers, “draft” flows, and most CRUD-style mobile products.
1) Make local the source of UX truth
Every tap should update local state immediately (optimistic UX). Sync is an asynchronous side effect.
- Write to a local database first (SQLite/Room/Core Data/Realm)
- Show “Pending” state per item (not a global spinner)
- Keep a visible “Last synced” indicator somewhere non-intrusive
2) Add an outbox (queue) of operations
Don’t sync “the whole object” on every change. Sync operations with unique IDs.
- Store pending ops (create/update/delete) in an outbox table
- Assign each op an idempotency key (UUID)
- Retry safely: same op ID must be safe to send again
3) Detect conflicts explicitly (don’t “guess”)
Conflicts happen when the server (or another device) changed the same record since your last known version.
- Store a per-record revision (monotonic integer) or ETag
- Use conditional updates (If-Match / expected revision)
- Handle 409/412 as a first-class, designed flow
4) Choose a merge policy per field
Most apps don’t need CRDTs everywhere. They need a few simple, predictable rules.
- Append-only: comments, log entries → merge by concatenation
- Last-write-wins: cosmetic fields → accept server timestamp
- Human-required: money, permissions → show a merge UI
Offline-first isn’t “works without internet.” It’s: “Your work is safe, and we’ll reconcile later.” Build your sync system to keep that promise—even when the network is flaky and devices disagree.
Minimum UI states (steal this)
| State | When it happens | What the user sees |
|---|---|---|
| Pending | Local change saved, not uploaded yet | Small “Pending” chip or subtle icon next to item |
| Syncing | Outbox is being processed | Optional: spinner only on affected items |
| Synced | Server acknowledged op | No badge; optional “Last synced” timestamp |
| Needs review | Conflict that can’t be auto-merged | Actionable message: “Resolve changes” + diff |
Overview
“Sync conflicts” are not just a backend problem. They’re the intersection of: data model (how you represent changes), protocol (how you exchange them), and UX (how you explain outcomes).
Offline-first vs offline-capable
They look similar until something goes wrong.
- Offline-capable: app works briefly without internet, but expects “reconnect soon.”
- Offline-first: app is designed as if offline is normal; sync is best-effort and recoverable.
What this post covers
- A mental model for sync (two timelines + reconciliation)
- Outbox + idempotency: safe retries without duplicates
- Conflict detection using revisions/ETags
- Merge strategies that don’t surprise users
- Common mistakes + a scan-fast cheatsheet
Typical conflict types (and why users hate them)
| Conflict type | What it looks like | Why it hurts | Best default approach |
|---|---|---|---|
| Concurrent edits | Two devices edit the same fields | “I saved this… why did it change?” | Three-way merge or explicit “choose version” UI |
| Edit vs delete | One device deletes, another edits | Work disappears or resurrects unexpectedly | Tombstones + “restore” flow (time-limited) |
| Reordering | Lists with drag-and-drop order | Order oscillates between devices | Use stable ordering keys (fractional index) or server authoritative order |
| Derived fields | Counters, totals, unread counts | Numbers drift and feel “buggy” | Compute on server or from append-only events |
A sync system is “good” when users can’t tell it exists—until it needs to explain something, and then it does so clearly.
Core concepts
Before code, align on vocabulary. Offline-first apps become dramatically easier when everyone agrees on what “state” means and what you’re actually syncing.
Two timelines: local truth vs server truth
Think of your app as maintaining two histories:
- Local timeline: what the user did (writes that must never be lost).
- Server timeline: the shared, multi-device world (writes from everyone).
Sync is the act of exchanging “what happened” between timelines and reconciling when the stories differ.
Key building blocks
Local database
Your offline-first app is not “cache + API.” It’s a real local datastore with a sync layer on top.
- Supports queries and indexes (not just key/value)
- Can store metadata: revision, sync status, last modified
- Can store an outbox reliably across app restarts
Outbox (operation log)
Instead of repeatedly sending the full record, store operations representing intent.
- Op types: create/update/delete (or patch)
- Each op has a unique ID (idempotency key)
- Ops are replayable in order (or per-entity order)
Conflict detection: revisions, not timestamps
A conflict is a situation where your update was based on an older version than the server currently has. The most reliable way to detect that is a server-managed revision (or ETag), not device clocks.
Revision strategies you’ll actually use
| Strategy | How it works | Good for | Watch out for |
|---|---|---|---|
| Monotonic integer | Server increments rev on each write | Most CRUD records | Requires conditional writes (expected rev) |
| ETag | Server returns opaque token per version | HTTP-native sync APIs | Don’t “compute” it client-side |
| Vector clock / version vector | Track per-replica versions | True multi-master merge | More complexity; justify it first |
Merge policy: auto when safe, ask when it matters
The goal isn’t “never ask the user.” The goal is: only ask when the app can’t decide without lying. Choose merge behavior per field and per entity type.
Users forgive “please resolve this.” They don’t forgive “my text disappeared.” If your conflict strategy can drop edits, it needs a visible audit trail or a merge UI.
Step-by-step
This is a practical offline-first sync design you can implement in any mobile stack. It avoids fancy distributed systems until you’ve earned them.
Step 1 — Model your data for syncing (IDs + metadata)
The minimum per-record fields
- id (UUID): stable across devices
- rev (int or ETag): server version you last synced
- updatedAtServer: server timestamp for ordering & UI (not for conflict detection)
- syncStatus: synced | pending | error | needs-review
Don’t forget deletes
Deletes must sync too. If you simply remove the row locally, you can’t communicate intent to the server.
- Use a tombstone flag or a delete operation in the outbox
- Keep tombstones for a grace period (for multi-device reconciliation)
- Offer “Undo delete” if your product can (users love this)
Step 2 — Write locally, enqueue intent (the outbox pattern)
The outbox is where offline-first apps become reliable. Every local write produces an operation that can be retried safely until acknowledged.
Prefer operations like “set title to X” or “append item Y” over “replace entire record.” Ops are easier to merge and can be idempotent.
Example: enqueue ops and sync safely (TypeScript)
This snippet shows the core mechanics: write to local DB, store an outbox op with an idempotency key, then process the queue with safe retries and conflict handling.
type SyncStatus = "synced" | "pending" | "error" | "needs-review";
type Note = {
id: string; // UUID
rev: number; // last known server revision
title: string;
body: string;
updatedAtServer?: string;
syncStatus: SyncStatus;
};
type OutboxOp = {
opId: string; // idempotency key (UUID)
entity: "note";
entityId: string;
kind: "create" | "update" | "delete";
baseRev: number; // rev the user edited against
patch: Record<string, unknown>;
createdAt: number;
};
// Pseudo DB interface (SQLite/Room/Core Data wrapper).
const db = {
async getNote(id: string): Promise<Note | null> { return null; },
async upsertNote(note: Note): Promise<void> {},
async insertOutbox(op: OutboxOp): Promise<void> {},
async listOutbox(limit = 50): Promise<OutboxOp[]> { return []; },
async deleteOutbox(opId: string): Promise<void> {},
async markNoteStatus(id: string, status: SyncStatus): Promise<void> {},
async updateNoteFromServer(note: Note): Promise<void> {},
};
function uuid(): string {
// Replace with a real UUID generator in your stack.
return Math.random().toString(16).slice(2) + "-" + Date.now().toString(16);
}
export async function updateNoteLocally(noteId: string, patch: Partial<Pick<Note, "title" | "body">>) {
const current = await db.getNote(noteId);
if (!current) throw new Error("Note not found");
// 1) Apply change locally (optimistic UX).
const next: Note = { ...current, ...patch, syncStatus: "pending" };
await db.upsertNote(next);
// 2) Enqueue intent.
const op: OutboxOp = {
opId: uuid(),
entity: "note",
entityId: noteId,
kind: "update",
baseRev: current.rev,
patch,
createdAt: Date.now(),
};
await db.insertOutbox(op);
}
export async function syncOnce(apiBase: string, authToken: string) {
// Process outbox first (push), then pull. (Order can vary by product.)
const ops = await db.listOutbox(50);
for (const op of ops) {
try {
// Idempotency: include opId so server can de-dupe retries.
const res = await fetch(`${apiBase}/sync/ops`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${authToken}`,
"Idempotency-Key": op.opId,
},
body: JSON.stringify(op),
});
if (res.status === 409 || res.status === 412) {
// Conflict: server has a newer revision than op.baseRev.
await db.markNoteStatus(op.entityId, "needs-review");
// In a real app, store conflict payload for a merge UI.
continue;
}
if (!res.ok) {
// Transient: keep op in outbox; show subtle per-item error if it persists.
await db.markNoteStatus(op.entityId, "error");
continue;
}
// Server returns the updated entity with new rev.
const updated: Note = await res.json();
await db.updateNoteFromServer({ ...updated, syncStatus: "synced" });
await db.deleteOutbox(op.opId);
} catch {
// Network down: do nothing; outbox remains.
// A scheduler can retry with exponential backoff + jitter.
return;
}
}
// Then pull deltas (new changes from other devices).
// Keep a device-level sync token (cursor) in your settings table.
}
Step 3 — Design your sync API: push ops + pull deltas
Avoid “download everything” sync. Instead, use: push (client outbox ops) and pull (server deltas since a cursor).
Push (client → server)
- Accept idempotency keys (de-dupe retries)
- Validate expected revision (conflict detection)
- Return updated entity + new revision
- Prefer “patch” operations for merge friendliness
Pull (server → client)
- Return changes since syncToken (cursor)
- Include deletions (tombstones) in the delta
- Support pagination
- Make responses cacheable where possible
Step 4 — Use conditional writes to surface conflicts early
Conflicts aren’t errors to hide—they’re business logic to handle. Conditional writes make them explicit and predictable.
Example: ETag / If-Match conflict flow (curl)
The client reads a note and gets an ETag. Later it tries to update with If-Match.
If the note changed elsewhere, the server rejects the write so you can merge safely.
# 1) Read current version
curl -i https://api.example.com/notes/123 \
-H "Authorization: Bearer $TOKEN"
# Response headers include:
# ETag: "rev-42"
# 2) Update only if we're still on rev-42
curl -i -X PUT https://api.example.com/notes/123 \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-H 'If-Match: "rev-42"' \
-H 'Idempotency-Key: 7f3b8f2a-3f2a-4b86-9c4c-7a3fd4a5d7d1' \
--data '{"title":"Offline notes","body":"Updated while offline"}'
# If the server has moved on, you get a conflict:
# HTTP/1.1 412 Precondition Failed (or 409 Conflict)
# Body may include the current server version + new ETag:
# { "current": { ... }, "etag": "rev-43" }
Step 5 — Resolve conflicts with a three-way merge (when possible)
The most user-friendly auto-merge is usually a three-way merge: compare (A) the base version you edited, (B) your local edits, and (C) the remote/server version. If edits touch different fields, merge automatically. If they touch the same field, escalate.
Where three-way merge shines
- Records with distinct fields (title/body/tags)
- Edits that rarely collide (notes, profiles, settings)
- Products that benefit from “it just works” without surprises
Example: minimal three-way merge for JSON-like records (Python)
This merge keeps both sides when they edit different fields. If the same field changed differently, it reports a conflict so you can show a merge UI or apply a policy.
from typing import Any, Dict, List, Tuple
def three_way_merge(
base: Dict[str, Any],
local: Dict[str, Any],
remote: Dict[str, Any],
mergeable_lists: bool = False,
) -> Tuple[Dict[str, Any], List[str]]:
"""
Returns (merged, conflicts).
Conflicts contain dot-paths to fields that changed differently on local vs remote.
"""
merged: Dict[str, Any] = {}
conflicts: List[str] = []
keys = set(base.keys()) | set(local.keys()) | set(remote.keys())
for k in sorted(keys):
b = base.get(k, None)
l = local.get(k, None)
r = remote.get(k, None)
# Same outcome everywhere.
if l == r:
merged[k] = l
continue
# Local didn't change vs base, remote did.
if l == b and r != b:
merged[k] = r
continue
# Remote didn't change vs base, local did.
if r == b and l != b:
merged[k] = l
continue
# Optional: merge lists by concatenation when that's a valid business rule.
if mergeable_lists and isinstance(l, list) and isinstance(r, list) and l != r:
merged[k] = list(dict.fromkeys(r + l)) # de-dupe while preserving order
continue
# Both changed differently: conflict.
merged[k] = l # keep local by default (UX: preserve user edits), but flag conflict.
conflicts.append(k)
return merged, conflicts
# Usage:
# base = {"title": "A", "body": "hello"}
# local = {"title": "A", "body": "hello offline"}
# remote = {"title": "A (edited elsewhere)", "body": "hello"}
# merged, conflicts = three_way_merge(base, local, remote)
Step 6 — Make retries boring: exponential backoff + jitter + idempotency
Mobile networks are unreliable. Your sync loop should expect: dropped requests, timeouts, captive portals, OS background limits, and “connected” states that don’t actually reach your API.
Retry rules that prevent pain
- Use exponential backoff with jitter (avoid “thundering herd”)
- Retry only on transient codes/timeouts (not on validation errors)
- Cap maximum delay and show per-item error when it persists
- Never duplicate side effects: idempotency keys are mandatory
A clean “sync trigger” list
- App foreground (with a small debounce)
- Connectivity regained (but verify real reachability)
- User pull-to-refresh (always offer manual control)
- Periodic background task (best-effort, OS permitting)
When conflicts occur, preserve what the user meant (their local edits) and surface what changed remotely. Even if you auto-merge, keep a small audit trail so “weird changes” are explainable.
Common mistakes
These are the traps that create “sync feels random” experiences. The fixes are usually small—but they need to be intentional.
Mistake 1 — Treating offline as an error state
If the app blocks actions when offline, users learn they can’t trust it on trains, planes, or flaky Wi-Fi.
- Fix: local-first writes; sync later via outbox.
- Fix: per-item pending indicators (not a global “offline” screen).
Mistake 2 — Retrying without idempotency
A timeout doesn’t mean the server didn’t process the request. Without idempotency keys, retries create duplicates.
- Fix: every mutating request includes an idempotency key.
- Fix: server stores/recognizes keys for a window (e.g., days).
Mistake 3 — Using device timestamps to resolve conflicts
Device clocks drift. Time zones exist. Users change time. Last-write-wins based on local time will betray you.
- Fix: detect conflicts with server revisions (rev/ETag).
- Fix: if you use LWW, use server timestamps, not device timestamps.
Mistake 4 — Syncing full objects instead of intent
“Replace record” updates are easy to implement and hard to merge. They amplify conflicts.
- Fix: patch fields or store ops (set, append, delete).
- Fix: merge per field; escalate only on true collisions.
Mistake 5 — Ignoring deletes (no tombstones)
If deletes aren’t part of your delta, records will “resurrect” from other devices or old caches.
- Fix: represent deletes as ops or tombstones.
- Fix: include deletions in pull responses for a retention window.
Mistake 6 — Hiding conflicts instead of designing them
Silently picking one version creates invisible data loss. Users experience it as “the app is buggy.”
- Fix: create a “Needs review” state with a clear call to action.
- Fix: provide a small diff (what changed locally vs remotely).
Never delete user input without telling them. If your sync system can discard edits, it must either (a) ask first, or (b) keep a recoverable history.
FAQ
Do I need CRDTs for offline-first apps?
Not usually. For many mobile apps, revisions + three-way merge + a small “resolve conflict” UI is enough. CRDTs shine when you need frequent concurrent edits to the same content (collaborative text, shared boards) and want automatic convergence. Start simple, measure real conflict frequency, then upgrade if the product truly needs it.
What’s the best default conflict strategy?
Use server-managed revisions to detect conflicts, then apply a three-way merge that auto-merges safe fields and escalates true collisions. This minimizes prompts while avoiding silent data loss.
How do I handle “edit vs delete” conflicts?
Represent deletes explicitly (tombstones or delete ops). If a remote delete arrives while the user edited offline, prefer preserving user intent: keep the local version as “restored” (new revision) or prompt the user with “This item was deleted elsewhere — restore or discard your changes?”
How do I sync large datasets without draining battery?
Use delta sync with a cursor token, paginate responses, and avoid syncing on every keystroke. Batch writes into the outbox and flush on reasonable triggers (foreground, connectivity regain, manual refresh, periodic background task).
What should I do when the app is online but requests fail?
Treat “online” as a hint, not a guarantee. Handle timeouts and 5xx with backoff + jitter, and verify reachability to your API if needed. Always keep changes locally and keep the outbox intact until you get an acknowledgment.
How do I test sync conflicts reliably?
Create a deterministic test harness: two simulated devices (or two installs), a fixed base record, then apply concurrent edits and sync in different orders. Validate invariants like “no duplicate ops,” “no silent data loss,” and “conflicts enter needs-review when appropriate.”
Cheatsheet
Scan this before you implement sync, and again when you’re debugging “weird” production reports.
Offline-first sync checklist
- Local DB is the UX source of truth
- Every write creates an outbox op
- Every op has an idempotency key
- Server supports conditional writes (rev/ETag)
- Pull uses a cursor token + includes deletions
- Retry uses exponential backoff + jitter
- UI shows per-item Pending / Needs review
Conflict resolution quick map
- Different fields changed: auto-merge (three-way)
- Same field changed differently: needs-review UI
- Append-only lists: merge by concatenation + de-dupe
- Derived counters: recompute from events or server
- Deletes: tombstones + optional restore flow
HTTP status codes to treat as “sync logic” (not generic errors)
| Status | Meaning in sync | What to do |
|---|---|---|
| 200/201 | Write accepted | Update local record rev; remove op from outbox |
| 409 | Conflict | Fetch current, attempt merge, or mark needs-review |
| 412 | Precondition failed (ETag mismatch) | Same as conflict; don’t retry blindly |
| 401/403 | Auth problem | Pause sync, re-authenticate, keep outbox intact |
| 429 | Rate-limited | Back off (respect Retry-After if provided) |
| 5xx | Server trouble | Retry with backoff; show subtle status if prolonged |
- Pending: “Saved on this device. Syncing when online.”
- Error: “Can’t sync right now. Your changes are safe.”
- Needs review: “Updated in two places. Choose what to keep.”
Wrap-up
Offline-first apps win trust by being calm in chaos: they accept input instantly, retry safely, and explain conflicts clearly. You don’t need exotic tech to get there—just a few strong fundamentals: local-first writes, an outbox with idempotency, revision-based conflict detection, and merge policies that never hide data loss.
If you implement only three things
- Outbox + idempotency keys (safe retries, no duplicates)
- Conditional writes (rev/ETag to detect conflicts)
- Needs-review UX (clear resolution when auto-merge can’t be trusted)
Next steps: if you want the rest of the mobile “reliability stack” to feel just as boring (in a good way), check the related posts below—especially retries/timeouts and CI/testing strategy.
Quiz
Quick self-check (demo). This quiz is auto-generated for mobile / development / offline.