Skip to content

Offline-First Architecture

advanced16 min read

The Network Is a Lie

Most web apps treat the network as a given. They show spinners when it's slow and error pages when it's gone. But here's reality: your users are on subway commuter trains, in elevators, in conference halls with overloaded WiFi, on flaky mobile connections that drop every 30 seconds.

Offline-first flips the assumption: design for no network as the default. The network is an enhancement, not a requirement. When the network is there, use it to sync. When it's not, the app keeps working.

This isn't just about "showing a cached page." It's about letting users create, edit, and interact — then syncing changes when connectivity returns. Google Docs, Figma, Notion — they all do this. Your app can too.

Mental Model

Think of offline-first like writing in a notebook on a plane. You keep working without WiFi. When you land, you sync your notes to the cloud. If someone else edited the same note while you were flying, you resolve the conflict. The notebook (local storage) is the source of truth during the flight. The cloud (server) is the source of truth once you're online. The tricky part is merging the two.

The Three Pillars

An offline-first app rests on three technologies:

We covered service workers and caching in the previous topics. Here we focus on the data layer — how to store, queue, and sync user data offline.

Architecture Overview

User Action
    |
    v
Local State (IndexedDB)  <-- reads always come from here
    |
    v
Mutation Queue (IndexedDB)  <-- writes go here
    |
    v
Sync Engine  <-- processes queue when online
    |
    v
Server API
    |
    v
Local State (update with server response)

Reads are always local. Writes go to a local queue first, then sync to the server. The UI reads from local state, so it's always fast — no network latency for reads, ever.

Quiz
In an offline-first architecture, where does the UI read data from?

Queuing Mutations

When the user creates, edits, or deletes something, don't send it to the server immediately. Store the mutation locally first:

async function addTodo(todo) {
  const db = await openDB('app', 1);

  const tx = db.transaction(['todos', 'syncQueue'], 'readwrite');

  const id = crypto.randomUUID();
  const record = { ...todo, id, updatedAt: Date.now(), synced: false };

  await tx.objectStore('todos').put(record);
  await tx.objectStore('syncQueue').put({
    id: crypto.randomUUID(),
    type: 'CREATE_TODO',
    payload: record,
    timestamp: Date.now(),
    retries: 0,
  });

  await tx.done;

  if (navigator.onLine) {
    syncQueue();
  }

  return record;
}

The key pattern: one transaction writes both the local data and the sync queue entry. If either fails, both roll back. The UI immediately reflects the new todo because it reads from IndexedDB, not the server.

The Sync Engine

async function syncQueue() {
  const db = await openDB('app', 1);
  const queue = await db.getAll('syncQueue');

  for (const mutation of queue.sort((a, b) => a.timestamp - b.timestamp)) {
    try {
      const response = await processMutation(mutation);
      await applyServerResponse(db, mutation, response);
      await db.delete('syncQueue', mutation.id);
    } catch (error) {
      if (isRetryable(error)) {
        mutation.retries++;
        await db.put('syncQueue', mutation);
        break;
      }
      await handlePermanentFailure(db, mutation, error);
    }
  }
}

function isRetryable(error) {
  return !navigator.onLine || error.status >= 500 || error.name === 'TypeError';
}

Mutations are processed in order (timestamp-sorted). Network errors pause the queue. Permanent failures (400, 409) are handled individually — maybe the item was deleted on the server, or there's a conflict.

Background Sync Integration

Register a sync event so the browser retries even after the tab is closed:

async function registerSync() {
  const registration = await navigator.serviceWorker.ready;
  await registration.sync.register('sync-mutations');
}

// In the service worker
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-mutations') {
    event.waitUntil(syncQueue());
  }
});

The browser fires the sync event when it detects connectivity. If syncQueue() fails (throws or the Promise rejects), the browser will retry later with exponential backoff.

Quiz
Why do we sort mutations by timestamp before syncing?

Conflict Resolution

The hardest part of offline-first. What happens when two people edit the same item while offline?

Strategy 1: Last Write Wins

The simplest approach. Each record has an updatedAt timestamp. When syncing, the latest timestamp wins.

async function resolveConflict(local, server) {
  return local.updatedAt > server.updatedAt ? local : server;
}

Pros: Simple, deterministic. Cons: Data loss — the older edit is silently discarded.

Strategy 2: Server Wins

The server version is always authoritative. Local changes are overwritten on sync.

Good for: collaborative apps where the server maintains consensus, or where local changes are low-value (e.g., read state).

Strategy 3: Merge

Apply both sets of changes. This requires understanding the data structure:

async function mergeDocuments(local, server, base) {
  const merged = { ...base };

  for (const key of Object.keys(local)) {
    if (local[key] !== base[key] && server[key] === base[key]) {
      merged[key] = local[key];
    } else if (server[key] !== base[key] && local[key] === base[key]) {
      merged[key] = server[key];
    } else if (local[key] !== base[key] && server[key] !== base[key]) {
      if (local[key] === server[key]) {
        merged[key] = local[key];
      } else {
        merged[key] = await promptUserToResolve(key, local[key], server[key]);
      }
    }
  }

  return merged;
}

Three-way merge requires keeping a base version (the state before both edits). Compare local and server changes against the base to determine what changed where.

Strategy 4: CRDTs

Conflict-free Replicated Data Types are data structures that can be merged automatically without conflicts. Think of them as "math that guarantees convergence."

Examples: counters (G-Counter), sets (OR-Set), text (Yjs, Automerge). CRDTs are what Google Docs, Figma, and Notion use for real-time collaboration.

This is an advanced topic worthy of its own deep dive, but the principle is: design your data structures so that all possible merge orders produce the same result.

UI Patterns for Offline State

Optimistic UI

Show changes immediately, before they reach the server:

function TodoList() {
  const todos = useLiveQuery(() => db.todos.toArray());

  async function addTodo(text) {
    const todo = {
      id: crypto.randomUUID(),
      text,
      synced: false,
      createdAt: Date.now(),
    };
    await db.todos.put(todo);
    syncQueue();
  }

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id} data-synced={todo.synced}>
          {todo.text}
          {!todo.synced && <span aria-label="Pending sync">Syncing</span>}
        </li>
      ))}
    </ul>
  );
}

The synced: false flag drives the UI — show a subtle indicator that the item hasn't reached the server yet. Once the sync engine confirms, update the flag.

Offline Indicator

function useOnlineStatus() {
  const [online, setOnline] = useState(navigator.onLine);

  useEffect(() => {
    const handleOnline = () => setOnline(true);
    const handleOffline = () => setOnline(false);

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);

    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);

  return online;
}
navigator.onLine is unreliable

navigator.onLine only tells you if the device has a network connection. It does not tell you if the internet is reachable. A connected WiFi with no internet shows onLine: true. For reliable detection, ping a known endpoint (your own server, not google.com) and handle failures gracefully.

Queue Visualization

For power users, showing the pending sync queue builds trust:

function SyncStatus() {
  const pending = useLiveQuery(() => db.syncQueue.count());

  if (!pending) return null;

  return (
    <div role="status" aria-live="polite">
      {pending} change{pending > 1 ? 's' : ''} waiting to sync
    </div>
  );
}

Testing Offline Behavior

Chrome DevTools

  1. Application > Service Workers > Offline checkbox — simulates no network
  2. Network tab > Throttling > Offline — disables network for the page
  3. Network tab > Throttling > Slow 3G — tests slow connections

Programmatic Testing

async function testOfflineSync() {
  await addTodo({ text: 'Offline todo' });

  const queueBefore = await db.syncQueue.count();
  console.assert(queueBefore === 1, 'Mutation should be queued');

  await syncQueue();

  const queueAfter = await db.syncQueue.count();
  console.assert(queueAfter === 0, 'Queue should be empty after sync');
}

Service Worker Testing Tips

  • Use workbox-window to programmatically check service worker state
  • Test with Cache-Control: no-store on API responses to ensure caching is done by the SW, not the HTTP cache
  • Test the install event by incrementing a version comment in your SW file
  • Test background sync by going offline, making changes, then coming online
Quiz
A user edits a document offline. Meanwhile, another user edits the same document on the server. When the first user comes online and syncs, what is the safest conflict resolution strategy?
What developers doWhat they should do
Treating offline as an error state with an error page
Users don't care why the network is gone. They care that your app works. Showing an error page when you could show cached content is a UX failure.
Design the offline experience as a first-class feature — cache the app shell, store data locally, queue mutations
Using localStorage for offline data storage
localStorage is synchronous (blocks the main thread), limited to 5MB, and stores only strings. IndexedDB is async, supports structured data, and can store hundreds of megabytes. For offline-first, IndexedDB is the only viable option.
Use IndexedDB for structured offline data — it supports transactions, indexes, and stores megabytes of data
Syncing all mutations in parallel
Parallel sync can cause impossible states — an edit arriving before its create, a delete before its update. Sequential processing guarantees operations reach the server in the order the user performed them.
Process mutations sequentially in timestamp order to preserve operation ordering
Key Rules
  1. 1Reads always come from local storage (IndexedDB) — never block on the network for reads
  2. 2Writes go to a local mutation queue first, then sync to the server when online
  3. 3Use Background Sync to replay mutations even after the tab is closed
  4. 4Show sync status in the UI — users trust apps that tell them what is happening
  5. 5Choose a conflict resolution strategy (last-write-wins, three-way merge, CRDTs) based on your data's tolerance for lost edits