Skip to content

Offline-First Sync Patterns

advanced22 min read

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.

Mental Model

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:

  1. Apply the mutation to local storage immediately (optimistic update)
  2. Append the mutation to a persistent queue (survives page reload)
  3. Process the queue in order when the network is available
  4. 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();
  }
}
Quiz
Why must the operation queue be persistent (stored in IndexedDB, not just in memory)?

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:

  1. Retry — for transient network errors (timeout, 502, 503)
  2. Rollback — remove the optimistic update and notify the user
  3. 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);
}
Quiz
A user creates a todo while offline. They close the browser, reopen it 2 hours later (still offline), then come online. What should happen?

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;
  }
}
Quiz
Alice and Bob both edit a shared document offline. Alice changes the title, Bob changes the description. Both sync when they come online. With a field-level merge using LWW per field, what happens?
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

FeatureChromeFirefoxSafari
Background Sync (one-off)49+Not supportedNot supported
Periodic Background Sync80+ (limited)Not supportedNot 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.

Quiz
You register a Background Sync event and the user closes the tab. The network comes back 10 minutes later. What happens in Chrome?

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 doWhat 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

Challenge: Conflict Resolution

Try to solve it before peeking at the answer.

javascript
// 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

Key Rules
  1. 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.
  2. 2Persist the operation queue in IndexedDB, not in memory. Users close tabs, browsers crash, devices restart — the queue must survive all of these.
  3. 3Optimistic UI updates local state and UI immediately. Sync happens in the background. Rollback only on permanent failure, not on transient network errors.
  4. 4Last Write Wins (LWW) is simple but loses data. Field-level merge preserves non-conflicting changes. CRDTs eliminate conflicts entirely by design.
  5. 5Never use client timestamps alone for ordering. Use server-assigned timestamps, hybrid logical clocks, or vector clocks to handle clock skew across devices.
  6. 6Background Sync API is Chromium-only. Implement your own sync engine as the primary mechanism and use Background Sync as a progressive enhancement.
  7. 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.