Offline-First Sync Patterns
The Network is a Lie
Here is a mindset shift that separates good apps from great ones: stop treating offline as an error state. Treat it as the default. Build your app to work without a network first, then layer on sync when connectivity is available. This is offline-first architecture.
Why? Because "online" is not binary. Users are on flaky hotel Wi-Fi, in elevators, on underground trains, behind corporate firewalls that randomly drop requests. Even on a fast connection, a server-first architecture means every action waits for a round trip — 200ms minimum, often more. An offline-first app responds instantly (from local storage), then syncs in the background. The user never waits, and the app never "fails to load."
Linear, Figma, Notion, Google Docs — the apps people love most are the ones that feel instant. They all use offline-first patterns.
Think of offline-first like a notebook and a shared whiteboard. You write in your notebook (local storage) immediately — no waiting for anyone. Periodically, you walk over to the whiteboard (server) and copy your changes up. If someone else has written on the whiteboard since your last visit, you merge their changes with yours. The notebook is always available. The whiteboard is available when you can reach it. Your app works from the notebook by default and syncs to the whiteboard in the background. Conflicts happen when two people wrote in the same spot — you need a strategy to resolve them.
The Architecture
An offline-first app has three layers:
The Operation Queue
The core of any offline-first sync system is an operation queue — a persistent log of every mutation the user makes while offline (or online). Instead of sending mutations directly to the server, you:
- Apply the mutation to local storage immediately (optimistic update)
- Append the mutation to a persistent queue (survives page reload)
- Process the queue in order when the network is available
- Handle failures by retrying or rolling back
class OperationQueue {
constructor(db) {
this.db = db;
this.processing = false;
}
async enqueue(operation) {
const tx = this.db.transaction("pendingOps", "readwrite");
tx.objectStore("pendingOps").put({
id: crypto.randomUUID(),
timestamp: Date.now(),
operation,
retries: 0,
});
await new Promise(r => { tx.oncomplete = r; });
this.processQueue();
}
async processQueue() {
if (this.processing || !navigator.onLine) return;
this.processing = true;
try {
const tx = this.db.transaction("pendingOps", "readonly");
const ops = await new Promise((resolve, reject) => {
const req = tx.objectStore("pendingOps").getAll();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
for (const op of ops) {
try {
await this.sendToServer(op.operation);
const deleteTx = this.db.transaction("pendingOps", "readwrite");
deleteTx.objectStore("pendingOps").delete(op.id);
await new Promise(r => { deleteTx.oncomplete = r; });
} catch (err) {
if (op.retries >= 3) {
await this.moveToDeadLetter(op);
} else {
await this.incrementRetry(op);
}
break; // stop processing — maintain order
}
}
} finally {
this.processing = false;
}
}
async sendToServer(operation) {
const response = await fetch("/api/sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(operation),
});
if (!response.ok) throw new Error(`Sync failed: ${response.status}`);
return response.json();
}
}
Optimistic UI
Optimistic UI means updating the interface immediately based on the expected success of an operation, without waiting for server confirmation. The user sees instant feedback while the actual sync happens in the background.
async function createTodo(text) {
const tempId = `temp-${crypto.randomUUID()}`;
const todo = { id: tempId, text, done: false, synced: false };
// 1. Update local storage immediately
await localDB.put("todos", todo);
// 2. Update UI immediately
renderTodo(todo);
// 3. Queue for sync
await opQueue.enqueue({
type: "CREATE_TODO",
tempId,
payload: { text, done: false },
});
}
// When the server responds, replace the temp ID with the real one
async function handleSyncResponse(tempId, serverResponse) {
const tx = localDB.transaction("todos", "readwrite");
const store = tx.objectStore("todos");
store.delete(tempId);
store.put({ ...serverResponse, synced: true });
// Re-render with real ID
}
Handling Sync Failures
When a sync fails, you have three options:
- Retry — for transient network errors (timeout, 502, 503)
- Rollback — remove the optimistic update and notify the user
- Conflict resolution — the server has a different version of the data
async function handleSyncError(operation, error) {
if (isTransient(error)) {
// Retry with exponential backoff
return { action: "retry", delay: Math.min(1000 * 2 ** operation.retries, 30000) };
}
if (error.status === 409) {
// Conflict — server has different data
const serverVersion = await fetchServerVersion(operation.payload.id);
return resolveConflict(operation.payload, serverVersion);
}
// Permanent failure — rollback
await localDB.delete("todos", operation.payload.id);
notifyUser(`Failed to save "${operation.payload.text}". Please try again.`);
return { action: "discard" };
}
function isTransient(error) {
if (!error.status) return true; // network error
return [408, 429, 500, 502, 503, 504].includes(error.status);
}
Conflict Resolution Strategies
When two clients modify the same data while both are offline, you get a conflict. How you resolve it depends on your data and business requirements.
1. Last Write Wins (LWW)
The simplest strategy. Whichever write has the latest timestamp wins. Easy to implement, but can lose data.
function resolveLastWriteWins(local, remote) {
return local.updatedAt > remote.updatedAt ? local : remote;
}
Use when: Data is not critical, conflicts are rare, or the latest version is always the most correct (like a user's current location).
Problem: If Alice edits a document's title and Bob edits the same document's description at the same time, LWW throws away one edit entirely.
2. Field-Level Merge
Merge at the field level instead of the document level. Non-conflicting field changes merge cleanly. Conflicting fields use LWW or ask the user.
function resolveFieldMerge(base, local, remote) {
const merged = { ...base };
for (const key of Object.keys({ ...local, ...remote })) {
const localChanged = local[key] !== base[key];
const remoteChanged = remote[key] !== base[key];
if (localChanged && !remoteChanged) {
merged[key] = local[key]; // only local changed
} else if (!localChanged && remoteChanged) {
merged[key] = remote[key]; // only remote changed
} else if (localChanged && remoteChanged) {
if (local[key] === remote[key]) {
merged[key] = local[key]; // both changed to same value
} else {
merged[key] = local.updatedAt > remote.updatedAt
? local[key] : remote[key]; // conflict — LWW per field
}
}
}
return merged;
}
Use when: Documents have independent fields that are often edited by different people.
3. CRDTs: Conflict-Free by Design
CRDTs (Conflict-free Replicated Data Types) are data structures that can be merged automatically without conflicts, regardless of the order operations arrive. They are the gold standard for collaborative and offline-first apps.
The key insight: instead of storing the current state and trying to merge states, you store the operations and design the data structure so that operations are commutative (order does not matter) and idempotent (applying the same operation twice has no effect).
G-Counter (Grow-Only Counter)
The simplest CRDT. Each client has its own counter. The merged value is the sum of all counters.
class GCounter {
constructor(nodeId) {
this.nodeId = nodeId;
this.counts = {};
}
increment(amount = 1) {
this.counts[this.nodeId] = (this.counts[this.nodeId] || 0) + amount;
}
value() {
return Object.values(this.counts).reduce((sum, n) => sum + n, 0);
}
merge(other) {
const merged = new GCounter(this.nodeId);
const allNodes = new Set([...Object.keys(this.counts), ...Object.keys(other.counts)]);
for (const node of allNodes) {
merged.counts[node] = Math.max(this.counts[node] || 0, other.counts[node] || 0);
}
return merged;
}
}
LWW-Register (Last Writer Wins Register)
A register (single value) where the value with the highest timestamp wins.
class LWWRegister {
constructor(value, timestamp) {
this.value = value;
this.timestamp = timestamp;
}
set(value) {
return new LWWRegister(value, Date.now());
}
merge(other) {
return this.timestamp >= other.timestamp ? this : other;
}
}
LWW-Map (Last Writer Wins Map)
A map where each key is an independent LWW-Register. Field-level merge for free.
class LWWMap {
constructor() {
this.entries = new Map(); // key -> { value, timestamp }
}
set(key, value) {
this.entries.set(key, { value, timestamp: Date.now() });
}
get(key) {
const entry = this.entries.get(key);
return entry ? entry.value : undefined;
}
merge(other) {
const merged = new LWWMap();
const allKeys = new Set([...this.entries.keys(), ...other.entries.keys()]);
for (const key of allKeys) {
const local = this.entries.get(key);
const remote = other.entries.get(key);
if (!local) merged.entries.set(key, remote);
else if (!remote) merged.entries.set(key, local);
else merged.entries.set(key, local.timestamp >= remote.timestamp ? local : remote);
}
return merged;
}
}
CRDTs in Production
CRDTs are not just academic — they power real products. Figma uses a custom CRDT for its collaborative design tool. Linear uses CRDTs for offline-first issue tracking. Ink and Switch's Automerge and Martin Kleppmann's work on JSON CRDTs provide libraries for building CRDT-based apps.
For browser apps, cr-sqlite extends SQLite with CRDT semantics — you mark tables as "conflict-free" and the library handles merge automatically at the row and column level. Yjs is a high-performance CRDT framework for shared editing (text, JSON, arrays, maps) with bindings for popular editors.
The trade-off: CRDTs add metadata overhead (timestamps, vector clocks, tombstones for deletions) and increase storage size. For a todo app, this is negligible. For a collaborative text editor with thousands of operations, the metadata can grow significantly. Production CRDT implementations include garbage collection to manage this.
Background Sync API
The Background Sync API lets you defer actions until the user has stable connectivity. You register a sync event in the service worker, and the browser fires it when the network is available — even if the user has closed the tab.
// main.js — register a sync
async function saveOfflineData(data) {
await localDB.put("pendingSync", data);
const registration = await navigator.serviceWorker.ready;
await registration.sync.register("sync-pending-data");
}
// sw.js — handle the sync event
self.addEventListener("sync", (event) => {
if (event.tag === "sync-pending-data") {
event.waitUntil(syncPendingData());
}
});
async function syncPendingData() {
const tx = db.transaction("pendingSync", "readonly");
const pending = await promisifyRequest(tx.objectStore("pendingSync").getAll());
for (const item of pending) {
await fetch("/api/sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(item),
});
const deleteTx = db.transaction("pendingSync", "readwrite");
deleteTx.objectStore("pendingSync").delete(item.id);
}
}
Browser Support
| Feature | Chrome | Firefox | Safari |
|---|---|---|---|
| Background Sync (one-off) | 49+ | Not supported | Not supported |
| Periodic Background Sync | 80+ (limited) | Not supported | Not supported |
Background Sync is Chromium-only. For cross-browser offline sync, you need to implement your own sync engine that runs when the app is open.
Pulling Remote Changes
Sync is bidirectional. Pushing local changes is half the story — you also need to pull changes from the server that were made by other clients or the server itself.
Polling
The simplest approach. Periodically fetch changes from the server.
class SyncEngine {
constructor(db, interval = 30000) {
this.db = db;
this.interval = interval;
this.lastSyncTimestamp = 0;
}
start() {
this.pull();
this.timer = setInterval(() => this.pull(), this.interval);
window.addEventListener("online", () => {
this.processQueue();
this.pull();
});
}
async pull() {
if (!navigator.onLine) return;
try {
const response = await fetch(`/api/changes?since=${this.lastSyncTimestamp}`);
const { changes, serverTimestamp } = await response.json();
const tx = this.db.transaction("data", "readwrite");
const store = tx.objectStore("data");
for (const change of changes) {
const local = await promisifyRequest(store.get(change.id));
const merged = this.resolveConflict(local, change);
store.put(merged);
}
this.lastSyncTimestamp = serverTimestamp;
} catch {
// Network error — try again next interval
}
}
resolveConflict(local, remote) {
if (!local) return remote;
if (!local.dirty) return remote; // no local changes, take remote
return fieldMerge(local, remote); // both changed, merge
}
}
Server-Sent Events or WebSockets
For real-time collaboration, polling is too slow. Use server-sent events (SSE) or WebSockets to push changes to clients immediately.
function connectToChangeFeed(onChange) {
const source = new EventSource("/api/changes/stream");
source.addEventListener("change", (event) => {
const change = JSON.parse(event.data);
onChange(change);
});
source.onerror = () => {
setTimeout(() => connectToChangeFeed(onChange), 5000);
};
return source;
}
Production Scenario: Offline-First Todo App
Putting it all together — a complete sync architecture:
class TodoApp {
constructor() {
this.db = null;
this.syncEngine = null;
}
async init() {
this.db = await openDB("todos-app", 1, (db) => {
db.createObjectStore("todos", { keyPath: "id" });
db.createObjectStore("pendingOps", { keyPath: "id" });
db.createObjectStore("syncMeta", { keyPath: "key" });
});
this.syncEngine = new SyncEngine(this.db);
this.syncEngine.start();
this.renderFromLocal();
}
async addTodo(text) {
const todo = {
id: crypto.randomUUID(),
text,
done: false,
createdAt: Date.now(),
updatedAt: Date.now(),
dirty: true,
};
// Local first
await this.localPut("todos", todo);
this.renderTodo(todo);
// Queue for sync
await this.syncEngine.enqueue({
type: "CREATE",
entity: "todos",
payload: todo,
});
}
async toggleTodo(id) {
const todo = await this.localGet("todos", id);
const updated = {
...todo,
done: !todo.done,
updatedAt: Date.now(),
dirty: true,
};
await this.localPut("todos", updated);
this.renderTodo(updated);
await this.syncEngine.enqueue({
type: "UPDATE",
entity: "todos",
payload: { id, done: updated.done, updatedAt: updated.updatedAt },
});
}
async renderFromLocal() {
const tx = this.db.transaction("todos", "readonly");
const todos = await promisifyRequest(tx.objectStore("todos").getAll());
todos.sort((a, b) => b.createdAt - a.createdAt);
for (const todo of todos) this.renderTodo(todo);
}
}
| What developers do | What they should do |
|---|---|
| Syncing entire documents instead of operations or deltas Syncing a 50KB document when you changed one field wastes bandwidth and makes conflicts harder to resolve. If you send the specific operation ('set field X to Y at timestamp T'), the server can merge it with other operations precisely. This also enables offline operation queues — you can batch many small operations into one sync request. | Sync only the changes (operations, field-level diffs, or CRDT states) to minimize bandwidth and enable granular conflict resolution |
| Using timestamps for conflict resolution without accounting for clock skew Client clocks are unreliable — a user's phone might be minutes or hours off. Two clients creating timestamps locally can disagree on order. Server-assigned timestamps fix this for online operations. For offline operations, hybrid logical clocks (HLCs) combine physical time with logical counters to produce timestamps that are always consistent with causal order. | Use server-assigned timestamps, hybrid logical clocks, or vector clocks for ordering events across devices |
| Not marking local changes as dirty for conflict detection Without a dirty flag, when you pull remote changes, you cannot tell whether a local record was modified by the user (needs merge) or was simply the last synced version (safe to overwrite). Always mark records as dirty on local mutation and clear the flag after successful sync. | Track which records have unsynced local changes so the sync engine knows to merge instead of overwrite |
| Relying on Background Sync API for cross-browser offline sync Background Sync is Chromium-only. Firefox and Safari do not support it. Your sync engine must work without it — process the queue on app startup and when the online event fires. Add Background Sync as an enhancement that handles sync when the tab is closed, but never as the only mechanism. | Implement your own sync engine that runs when the app is open, with Background Sync as a progressive enhancement for Chrome |
| Not handling the case where the server rejects an operation Some operations will never succeed — a deleted resource, a permission change, a validation error. After a reasonable number of retries, move the operation to a dead letter queue, rollback the optimistic local change, and inform the user. Never silently lose data. | Implement a dead letter queue for permanently failed operations and notify the user |
Challenge: Design a Conflict Resolution Strategy
Try to solve it before peeking at the answer.
// You are building a collaborative shopping list app.
// Multiple family members can add, remove, and check off items.
// The app must work offline on each person's phone.
//
// Scenario: Mom and Dad are both offline.
// - Mom adds "Milk" and checks off "Bread"
// - Dad adds "Eggs" and removes "Butter"
// - Both come online and sync
//
// Design the conflict resolution strategy.
// Questions to answer:
// 1. What data structure represents the shopping list?
// 2. How do you handle "add" conflicts (both add different items)?
// 3. How do you handle "remove" conflicts (one removes, one checks off)?
// 4. What happens if both add the same item?
// 5. What CRDT would model this correctly?Key Rules
- 1Offline-first means local storage is the primary data source, not a cache. All reads and writes go to local storage first. The network is for sync, not for operation.
- 2Persist the operation queue in IndexedDB, not in memory. Users close tabs, browsers crash, devices restart — the queue must survive all of these.
- 3Optimistic UI updates local state and UI immediately. Sync happens in the background. Rollback only on permanent failure, not on transient network errors.
- 4Last Write Wins (LWW) is simple but loses data. Field-level merge preserves non-conflicting changes. CRDTs eliminate conflicts entirely by design.
- 5Never use client timestamps alone for ordering. Use server-assigned timestamps, hybrid logical clocks, or vector clocks to handle clock skew across devices.
- 6Background Sync API is Chromium-only. Implement your own sync engine as the primary mechanism and use Background Sync as a progressive enhancement.
- 7For collaborative data, use tombstones (soft delete) instead of physical deletion. Hard deletes cannot be replicated — other clients cannot distinguish a delete from never-having-existed.