Skip to main content

πŸ”Œ Offline-First Development Strategies

Offline-first is a design philosophy, not just a feature: you treat the network as an enhancement, not a requirement. The app reads and writes local data first and syncs opportunistically. This lesson covers the storage, sync, and conflict-resolution patterns that make that dependable β€” and how to test them.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Contrast the traditional and offline-first mental models
  • Choose the right client storage (IndexedDB, Cache API, Web Storage) for each kind of data
  • Implement optimistic UI and a durable queue-based sync with exponential backoff
  • Apply the common conflict-resolution strategies and their trade-offs
  • Detect and communicate network status and test offline behavior

Estimated Time: 45–55 minutes  β€’  Difficulty: Intermediate–Advanced

Hands-on: Build an offline-first todo flow with optimistic UI and a sync queue that drains on reconnect.

In This Lesson

The Offline-First Mindset

Most apps are built network-first: they assume a connection and treat its absence as an error state. Offline-first inverts that assumption. The local device is the source of truth for the current session, the UI works whether or not there's a signal, and the network is used to sync in the background. Connectivity becomes a spectrum β€” great, poor, intermittent, none β€” rather than a binary.

flowchart TD A["Network-first app"] --> B{"Online?"} B -->|Yes| C["Works"] B -->|No| D["Error screen"] E["Offline-first app"] --> F["Works from local data"] F --> G{"Online?"} G -->|Yes| H["Sync & enhance"] G -->|No| I["Keep working, queue changes"]
πŸ’‘ The elevator analogy: A network-first app is an elevator that traps you when the power cuts. An offline-first app switches to backup power β€” it still moves you safely to the next floor and keeps limited service running until main power returns. The failure is planned for, not fatal.

πŸ“– Core principles

Availability: the app is usable regardless of network state.

Local-first performance: read from local storage before the network.

Resilience: transitions between online and offline are seamless.

Eventual consistency: local and remote converge when connectivity returns.

Choosing Client Storage

Offline-first lives or dies on client storage. Three APIs cover almost every need; match the tool to the data.

APICapacityData typesBest for
IndexedDBLarge (quota-based)Structured objects, blobsApp data, queues, queryable records
Cache APILarge (quota-based)Request/Response pairsAssets and HTTP responses (from the SW)
localStorage / sessionStorage~5 MBStrings only (synchronous)Small flags, preferences, tokens

⚠️ Avoid localStorage for real data

Its API is synchronous, so it blocks the main thread, and it's capped near 5 MB and strings-only. It's fine for a theme flag; it is the wrong home for a document store. WebSQL is fully deprecated β€” never start with it.

IndexedDB β€” the workhorse

IndexedDB is a transactional, indexed object database in the browser. Its native API is famously verbose (event-based). A realistic pattern wraps it in promises; in production most teams reach for a thin library like idb. Here's the raw shape so you understand what those libraries do:

function openDb() {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open('app-db', 1);
    req.onupgradeneeded = () => {
      const db = req.result;
      const store = db.createObjectStore('todos', { keyPath: 'id' });
      store.createIndex('pending', 'pending', { unique: false });
    };
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

async function putTodo(todo) {
  const db = await openDb();
  return new Promise((resolve, reject) => {
    const tx = db.transaction('todos', 'readwrite');
    tx.objectStore('todos').put(todo);
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });
}

βœ… The same code with the idb library

import { openDB } from 'idb';

const db = await openDB('app-db', 1, {
  upgrade(db) {
    const store = db.createObjectStore('todos', { keyPath: 'id' });
    store.createIndex('pending', 'pending');
  }
});

await db.put('todos', todo);          // write
const all = await db.getAll('todos'); // read

Same database, a fraction of the ceremony. Learn the raw API once, then use a wrapper.

Optimistic UI Updates

Waiting for a server round-trip before showing a change feels sluggish β€” and impossible offline. With optimistic UI you update the interface immediately, assuming success, then reconcile when (or if) the server responds. If it fails, you roll back.

sequenceDiagram participant U as User participant UI participant DB as Local DB participant S as Server U->>UI: Add todo UI->>DB: Save (pending: true) UI->>U: Show it instantly UI->>S: POST /todos alt Success S->>UI: 201 + server id UI->>DB: Mark synced, swap id else Failure S->>UI: Error / offline UI->>DB: Keep pending, queue retry end
async function addTodo(text) {
  const todo = {
    id: `tmp-${crypto.randomUUID()}`, // temporary local id
    text,
    completed: false,
    pending: true,                    // not yet confirmed by the server
    updatedAt: Date.now()
  };

  await putTodo(todo);   // 1. persist locally
  renderTodo(todo);      // 2. show it right away

  try {
    const res = await fetch('/api/todos', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(todo)
    });
    if (!res.ok) throw new Error(res.statusText);
    const saved = await res.json();
    await putTodo({ ...todo, id: saved.id, pending: false });
    reconcileId(todo.id, saved.id); // 3a. swap temp id for real id
  } catch {
    await enqueue({ type: 'create', payload: todo }); // 3b. queue for later
  }
}

πŸ’‘ Mark, don't hide

Keep a pending flag on unsynced records and reflect it subtly in the UI (a faint clock icon, reduced opacity). Users trust an app that's honest about what has and hasn't been saved yet.

Queue-Based Synchronization

When a write can't reach the server, don't drop it β€” append it to a durable sync queue in IndexedDB and drain the queue when connectivity returns (via the online event or a service-worker sync event). Failed items retry with exponential backoff and eventually land in a dead-letter state you can surface to the user.

flowchart TD A["User action"] --> B["Write local DB"] B --> C["Append to sync queue"] C --> D{"Online?"} D -->|Yes| E["Process queue"] D -->|No| F["Wait for reconnect"] F --> E E --> G{"Success?"} G -->|Yes| H["Remove from queue"] G -->|No| I{"Max retries?"} I -->|No| J["Backoff, retry"] J --> E I -->|Yes| K["Dead-letter, alert user"]
async function processQueue() {
  const db = await openDb();
  const items = await getAll(db, 'syncQueue'); // ordered by timestamp

  for (const item of items) {
    try {
      const res = await fetch(item.url, {
        method: item.method,
        headers: { 'Content-Type': 'application/json' },
        body: item.body
      });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      await remove(db, 'syncQueue', item.id); // success β†’ drop it
    } catch (err) {
      item.retries += 1;
      if (item.retries >= 5) {
        item.status = 'failed';               // dead-letter
      } else {
        item.nextRetry = Date.now() + 2 ** item.retries * 1000; // backoff
      }
      await put(db, 'syncQueue', item);
      break; // stop on first failure; try again on next trigger
    }
  }
}

// Drain whenever the browser reports we're back online.
window.addEventListener('online', () => {
  processQueue().catch(console.error);
});

⚠️ Make operations idempotent

A queued request may be sent more than once (a retry after a response that never arrived). Give each operation a stable client-generated id and have the server treat a repeat as a no-op, so a double-send can't create duplicates.

Conflict Resolution

When the same record is edited locally and remotely, their versions diverge. You need a policy. There is no universally right answer β€” it depends on the data and how much you trust each side.

StrategyRuleGood for
Server winsRemote is authoritative; discard localRead-mostly data, prices, inventory
Client winsLocal overwrites remotePersonal drafts, single-device data
Last write winsNewest updatedAt keepsSimple fields, low conflict rate
MergeCombine both (e.g. CRDTs / OT)Collaborative editing (Notion, Figma)
ManualAsk the user to pickHigh-value, ambiguous conflicts
// Last-write-wins reconciliation on sync.
async function reconcile(localItem) {
  const res = await fetch(`/api/items/${localItem.id}`);
  const serverItem = await res.json();

  if (serverItem.updatedAt > localItem.updatedAt) {
    await putTodo(serverItem);        // server is newer β†’ adopt it
    return serverItem;
  }
  return uploadItem(localItem);        // local is newer β†’ push it up
}

πŸ’‘ True collaboration needs merges, not overwrites

Last-write-wins silently loses one person's edits. For multi-user editing, apps like Notion and Figma use operational transforms (OT) or conflict-free replicated data types (CRDTs) so concurrent changes merge without data loss. Libraries such as Yjs and Automerge implement CRDTs for you.

Network Status & UX

Communicate connectivity clearly but calmly. Use the online/offline events for the coarse state and the Network Information API for quality hints.

function updateStatus() {
  document.body.classList.toggle('is-offline', !navigator.onLine);
}
window.addEventListener('online', () => { updateStatus(); processQueue(); });
window.addEventListener('offline', updateStatus);
updateStatus();

// Optional: adapt to connection quality where supported.
const conn = navigator.connection;
if (conn) {
  conn.addEventListener('change', () => {
    if (conn.saveData || conn.effectiveType === '2g') {
      loadLowResAssets();  // be frugal on slow / save-data connections
    }
  });
}

⚠️ navigator.onLine lies

navigator.onLine === true only means the device has a network interface, not that your server is reachable (captive-portal WiFi, DNS failure, dead backend). Treat it as a hint. The reliable signal that a request will succeed is that the request actually succeeded β€” build your sync around fetch outcomes, not just the flag.

βœ… UX guidelines

  • Show an unobtrusive banner or badge for offline state β€” never a blocking modal.
  • Indicate per-item sync status (pending / synced / failed).
  • Offer a manual "Sync now" for users who want control.
  • Communicate data age when showing cached content that might be stale.

Hands-on Exercise

πŸ‹οΈ Offline-first todo flow

Objective: Add a todo, show it instantly, and have it sync when the network returns.

Instructions:

  1. Create an IndexedDB store todos (keyPath id) and a syncQueue store.
  2. On "Add", write the todo with pending: true, render it immediately (optimistic UI), and attempt a POST.
  3. If the POST fails (or you're offline), append a create operation to the sync queue.
  4. On the online event, drain the queue with exponential backoff; on success, flip pending to false and swap the temp id.
  5. Toggle DevTools β†’ Network β†’ Offline, add two todos, go back online, and confirm both sync.
πŸ’‘ Hint

Use crypto.randomUUID() for temporary ids so retries stay idempotent. Reflect pending visually with a CSS class. If nothing syncs on reconnect, confirm your online listener is attached before going offline, and that processQueue() reads items in timestamp order.

βœ… Core of the solution
async function addTodo(text) {
  const todo = { id: `tmp-${crypto.randomUUID()}`, text,
                 pending: true, updatedAt: Date.now() };
  await db.put('todos', todo);
  renderTodo(todo);

  if (!navigator.onLine) {
    return db.put('syncQueue', { id: todo.id, op: 'create', body: todo });
  }
  try {
    const res = await fetch('/api/todos', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(todo)
    });
    const saved = await res.json();
    await db.delete('todos', todo.id);
    await db.put('todos', { ...todo, id: saved.id, pending: false });
    reconcileId(todo.id, saved.id);
  } catch {
    await db.put('syncQueue', { id: todo.id, op: 'create', body: todo });
  }
}

window.addEventListener('online', () => processQueue());

🎯 Quick Quiz

Question 1: Which storage API is the best fit for a queryable store of structured application records?

Question 2: What does optimistic UI do?

Question 3: Why is navigator.onLine === true not a guarantee your request will succeed?

Testing & Pitfalls

How to test offline behavior

  • DevTools β†’ Network β†’ Offline / throttling to simulate no or poor connectivity.
  • Real devices in airplane mode β€” emulators hide timing quirks.
  • Drop the connection mid-operation to exercise your queue and rollback paths.
  • Automated tests: mock fetch to reject and assert data was queued and later drained.
test('queues writes when offline', async () => {
  global.fetch = vi.fn().mockRejectedValue(new Error('offline'));
  const enqueue = vi.spyOn(queue, 'add');

  await addTodo('Buy milk');

  expect(enqueue).toHaveBeenCalledOnce();
  expect(fetch).toHaveBeenCalledTimes(1); // attempted, then queued on failure
});

⚠️ Common pitfalls

  • Storage quotas: writes can fail when the origin is over quota β€” check navigator.storage.estimate() and handle QuotaExceededError.
  • Stale caches: timestamp cached data and show its age; use stale-while-revalidate for content that drifts.
  • Lost writes: non-idempotent retries create duplicates β€” use stable client ids.
  • Silent sync failures: surface dead-lettered items instead of dropping them.

Summary & Quiz

πŸŽ‰ Key Takeaways

  • Offline-first treats the network as an enhancement: read/write locally, sync opportunistically.
  • Use IndexedDB for structured data, the Cache API for responses, and Web Storage only for tiny flags.
  • Optimistic UI keeps the app responsive; a durable sync queue with backoff keeps writes safe.
  • Pick a conflict-resolution policy per data type; true collaboration needs merges (CRDTs/OT), not overwrites.
  • navigator.onLine is a hint, not a guarantee β€” build sync around real fetch outcomes and idempotent operations.

πŸ“š Further Reading

πŸš€ What's Next?

You've completed the PWA arc β€” architecture, service workers, and offline-first design. Next you'll bring the whole module together in the weekend project, combining a framework, state management, and these offline capabilities into one app.

πŸŽ‰ Nice work!

You can now build apps that shrug off a dead network. On to the weekend project.