Web Engineering ·

Building Offline-First Web Applications: Service Workers, IndexedDB, and Sync Strategies for Production

A deep-dive into offline-first web application architecture covering service worker lifecycle, IndexedDB patterns, conflict resolution strategies, and production pitfalls that most guides skip.

Building Offline-First Web Applications: Service Workers, IndexedDB, and Sync Strategies for Production

Most web applications treat offline as an error state. The network fails, you show a spinner, maybe a toast that says “check your connection,” and the user is stuck. Offline-first inverts this: the local store is the source of truth, and the network is an optional sync channel.

That inversion touches every layer of your stack. This article covers how to build it correctly, including the parts most tutorials skip: cache invalidation under deploy, storage quota pressure, conflict resolution when two clients edit the same record offline, and debugging a service worker that has already registered and cached the old version of your app.


Service Worker Lifecycle and Caching Strategies

A service worker is a JavaScript file that runs in a separate thread, outside the page, and intercepts network requests via a fetch event listener. Before you can use it, you need to understand the lifecycle because getting it wrong is the most common source of “why is my user still seeing the old version” bugs.

Registration is straightforward:

// src/sw-register.ts
export async function registerServiceWorker(): Promise<ServiceWorkerRegistration | null> {
  if (!("serviceWorker" in navigator)) return null;

  try {
    const registration = await navigator.serviceWorker.register("/sw.js", {
      scope: "/",
      type: "classic",
    });

    registration.addEventListener("updatefound", () => {
      const newWorker = registration.installing;
      if (!newWorker) return;

      newWorker.addEventListener("statechange", () => {
        if (
          newWorker.state === "installed" &&
          navigator.serviceWorker.controller
        ) {
          // A new SW is waiting. Prompt the user to reload or auto-skip waiting.
          notifyUpdateAvailable(registration);
        }
      });
    });

    return registration;
  } catch (err) {
    console.error("SW registration failed:", err);
    return null;
  }
}

function notifyUpdateAvailable(reg: ServiceWorkerRegistration): void {
  // Option A: show a "reload to update" banner
  // Option B: call reg.waiting?.postMessage({ type: "SKIP_WAITING" }) immediately
  document.dispatchEvent(new CustomEvent("sw:update-available", { detail: reg }));
}

The lifecycle has three phases: installing, waiting, and active. A new worker installs but stays in “waiting” until all tabs using the old worker are closed. If you want to force an update, post SKIP_WAITING from the page:

// In the service worker itself (sw.ts)
self.addEventListener("message", (event: ExtendableMessageEvent) => {
  if (event.data?.type === "SKIP_WAITING") {
    self.skipWaiting();
  }
});

Calling skipWaiting() activates the new worker immediately, but any open tabs still have the old JavaScript loaded. You need to tell clients to reload:

self.addEventListener("activate", (event: ExtendableEvent) => {
  event.waitUntil(
    (async () => {
      await self.clients.claim();
      const clients = await self.clients.matchAll({ type: "window" });
      clients.forEach((client) => client.navigate(client.url));
    })()
  );
});

Cache-First vs Network-First vs Stale-While-Revalidate

Pick the wrong strategy and you’ll either serve stale HTML indefinitely or break offline behavior entirely.

Cache-first is correct for versioned assets: JS bundles, CSS files, images referenced by content hash. If the URL contains a hash, the content never changes, so always serve from cache:

const STATIC_CACHE = "static-v2";
const STATIC_ASSETS = ["/app.a1b2c3d4.js", "/styles.e5f6g7h8.css"];

self.addEventListener("install", (event: ExtendableEvent) => {
  event.waitUntil(
    caches.open(STATIC_CACHE).then((cache) => cache.addAll(STATIC_ASSETS))
  );
});

self.addEventListener("fetch", (event: FetchEvent) => {
  if (isVersionedAsset(event.request.url)) {
    event.respondWith(caches.match(event.request).then((r) => r ?? fetch(event.request)));
    return;
  }
  // ...
});

function isVersionedAsset(url: string): boolean {
  return /\.[a-f0-9]{8,}\.(js|css|woff2)$/.test(url);
}

Network-first is correct for API calls where freshness matters but you want a fallback:

async function networkFirst(request: Request): Promise<Response> {
  const cache = await caches.open("api-cache-v1");
  try {
    const response = await fetch(request);
    if (response.ok) {
      cache.put(request, response.clone()); // async, no await needed
    }
    return response;
  } catch {
    const cached = await cache.match(request);
    if (cached) return cached;
    return new Response(JSON.stringify({ error: "offline", cached: false }), {
      status: 503,
      headers: { "Content-Type": "application/json" },
    });
  }
}

Stale-while-revalidate is the right call for app shell HTML and navigation requests where you want instant load but still want updates to propagate:

async function staleWhileRevalidate(request: Request): Promise<Response> {
  const cache = await caches.open("shell-cache-v1");
  const cached = await cache.match(request);

  const networkFetch = fetch(request).then((response) => {
    if (response.ok) cache.put(request, response.clone());
    return response;
  });

  return cached ?? networkFetch;
}

IndexedDB for Local Storage

localStorage is synchronous and limited to 5-10 MB. For offline-first, you need IndexedDB: async, structured data, and quota-limited only by disk space (with browser permission prompts at high usage). Working with the raw IndexedDB API is painful, so wrap it.

A minimal typed wrapper using the idb library:

import { openDB, DBSchema, IDBPDatabase } from "idb";

interface AppDB extends DBSchema {
  tasks: {
    key: string;
    value: {
      id: string;
      title: string;
      completed: boolean;
      updatedAt: number; // Unix ms
      syncedAt: number | null;
      vectorClock: Record<string, number>;
    };
    indexes: { "by-syncedAt": number | null };
  };
  syncQueue: {
    key: number;
    value: {
      id?: number;
      operation: "create" | "update" | "delete";
      entityType: string;
      entityId: string;
      payload: unknown;
      createdAt: number;
      retryCount: number;
    };
  };
}

let db: IDBPDatabase<AppDB>;

export async function getDB(): Promise<IDBPDatabase<AppDB>> {
  if (db) return db;
  db = await openDB<AppDB>("app-db", 1, {
    upgrade(database) {
      const taskStore = database.createObjectStore("tasks", { keyPath: "id" });
      taskStore.createIndex("by-syncedAt", "syncedAt");

      database.createObjectStore("syncQueue", {
        keyPath: "id",
        autoIncrement: true,
      });
    },
  });
  return db;
}

Every mutation writes to both the local store and the sync queue within a single transaction:

export async function createTask(
  task: Omit<AppDB["tasks"]["value"], "syncedAt" | "vectorClock">
): Promise<void> {
  const database = await getDB();
  const tx = database.transaction(["tasks", "syncQueue"], "readwrite");

  const clientId = getClientId(); // stable ID stored in localStorage
  const record = {
    ...task,
    syncedAt: null,
    vectorClock: { [clientId]: 1 },
  };

  await tx.objectStore("tasks").put(record);
  await tx.objectStore("syncQueue").add({
    operation: "create",
    entityType: "tasks",
    entityId: task.id,
    payload: record,
    createdAt: Date.now(),
    retryCount: 0,
  });

  await tx.done;
}

Conflict Resolution Strategies

This is where offline-first gets genuinely hard. Two clients edit the same record while offline. They both come back online. What wins?

Last-Write-Wins

Simplest. Compare updatedAt timestamps, the later one wins. The problem is clock skew: device clocks drift, and a user with a device set 10 minutes in the future will always win conflicts. Use this only when data loss is acceptable or conflicts are rare.

function mergeTaskLWW(
  local: AppDB["tasks"]["value"],
  remote: AppDB["tasks"]["value"]
): AppDB["tasks"]["value"] {
  return local.updatedAt >= remote.updatedAt ? local : remote;
}

Vector Clocks for Causal Ordering

A vector clock is a map from client ID to logical timestamp. On every write, increment your own entry. When merging, you can determine whether one version causally precedes another, or whether they are concurrent (genuine conflict requiring user resolution).

type VectorClock = Record<string, number>;

function happensBefore(a: VectorClock, b: VectorClock): boolean {
  const allKeys = new Set([...Object.keys(a), ...Object.keys(b)]);
  let strictlyLess = false;

  for (const key of allKeys) {
    const aVal = a[key] ?? 0;
    const bVal = b[key] ?? 0;
    if (aVal > bVal) return false;
    if (aVal < bVal) strictlyLess = true;
  }

  return strictlyLess;
}

function isConcurrent(a: VectorClock, b: VectorClock): boolean {
  return !happensBefore(a, b) && !happensBefore(b, a);
}

function mergeClocks(a: VectorClock, b: VectorClock): VectorClock {
  const result: VectorClock = {};
  const allKeys = new Set([...Object.keys(a), ...Object.keys(b)]);
  for (const key of allKeys) {
    result[key] = Math.max(a[key] ?? 0, b[key] ?? 0);
  }
  return result;
}

If isConcurrent returns true, you have a real conflict. You can present both versions to the user, auto-merge field-by-field (safe for non-overlapping edits), or escalate to a CRDT.

CRDTs for Automatic Merge

A Conflict-free Replicated Data Type is a data structure with a mathematically defined merge operation that is commutative, associative, and idempotent. The merge always produces the same result regardless of order, so you never need to resolve conflicts manually.

For simple use cases, a grow-only set (G-Set) or last-write-wins register (LWW-Register) is sufficient. For collaborative text, you want a sequence CRDT like Yjs or Automerge.

// LWW-Register: each field carries its own timestamp
type LWWField<T> = { value: T; timestamp: number; clientId: string };
type LWWRecord<T> = { [K in keyof T]: LWWField<T[K]> };

function mergeLWWRecord<T>(
  local: LWWRecord<T>,
  remote: LWWRecord<T>
): LWWRecord<T> {
  const result = {} as LWWRecord<T>;
  for (const key of Object.keys(local) as (keyof T)[]) {
    const l = local[key];
    const r = remote[key];
    if (l.timestamp >= r.timestamp) {
      result[key] = l;
    } else {
      result[key] = r;
    }
  }
  return result;
}

For real collaborative documents, reach for Yjs directly. It implements a YATA (Yet Another Transformation Approach) CRDT that handles concurrent character insertions correctly, which Operational Transform cannot do without a central server to serialize operations.

The practical choice: use LWW for isolated records where field-level granularity is enough, use Yjs when users can edit the same text concurrently.


Background Sync API

The Background Sync API lets a service worker retry queued operations even after the user closes the tab. Register a sync tag when the operation fails:

// In the page
async function saveWithSync(task: Task): Promise<void> {
  await createTask(task); // write to IndexedDB first

  if ("serviceWorker" in navigator && "SyncManager" in window) {
    const reg = await navigator.serviceWorker.ready;
    await reg.sync.register("sync-tasks");
  } else {
    // Fallback: try immediately, queue for next page load
    await flushSyncQueue();
  }
}
// In sw.ts
self.addEventListener("sync", (event: SyncEvent) => {
  if (event.tag === "sync-tasks") {
    event.waitUntil(flushSyncQueue());
  }
});

async function flushSyncQueue(): Promise<void> {
  const database = await getDB();
  const queue = await database.getAll("syncQueue");

  for (const item of queue) {
    try {
      const response = await fetch("/api/sync", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(item),
      });

      if (response.ok) {
        await database.delete("syncQueue", item.id!);
        await database
          .transaction("tasks", "readwrite")
          .objectStore("tasks")
          .put({
            ...(await database.get("tasks", item.entityId))!,
            syncedAt: Date.now(),
          });
      } else if (response.status >= 400 && response.status < 500) {
        // Client error: remove from queue, log, don't retry
        console.error("Sync rejected:", response.status, item);
        await database.delete("syncQueue", item.id!);
      }
      // 5xx: leave in queue, browser will retry
    } catch {
      // Network error: browser will retry on next sync event
      break;
    }
  }
}

Background Sync has limited browser support (Chrome/Edge, not Safari as of early 2026). For Safari, a reasonable fallback is a visibilitychange listener that flushes the queue when the tab becomes visible, plus a periodic attempt on online events.


Sync Strategy Tradeoffs

DimensionLast-Write-WinsVector ClocksCRDT (Yjs/Automerge)
Implementation complexityLowMediumHigh
Data loss riskHigh (clock skew)Low (explicit conflicts)None (merge is safe)
Conflict resolutionAutomatic, lossyManual or field-mergeAutomatic, lossless
Server requirementTimestamp syncClock sync optionalNone (peer-to-peer possible)
Bundle size impactNegligibleNegligible30-80 KB (Yjs)
Best forNon-critical state, settingsStructured records, form dataCollaborative documents, shared state

Production Pitfalls

Cache Invalidation Under Deploy

The most common offline-first failure mode: you deploy a new version, the service worker serves the old HTML shell, and the new API response shape breaks the old client code. The fix is versioning your cache names and purging old caches on activate:

const CURRENT_CACHES = new Set(["static-v2", "api-cache-v1", "shell-cache-v1"]);

self.addEventListener("activate", (event: ExtendableEvent) => {
  event.waitUntil(
    (async () => {
      const cacheNames = await caches.keys();
      await Promise.all(
        cacheNames
          .filter((name) => !CURRENT_CACHES.has(name))
          .map((name) => caches.delete(name))
      );
      await self.clients.claim();
    })()
  );
});

Bump the cache version string on every deploy that changes cached response shapes.

Storage Quota

Browsers allocate storage quota per origin. The limit is typically 60% of available disk space, but it varies. IndexedDB and Cache Storage share the same quota pool.

Call the Storage API before writing large datasets:

async function checkStorageQuota(): Promise<{
  available: boolean;
  percentUsed: number;
}> {
  if (!navigator.storage?.estimate) {
    return { available: true, percentUsed: 0 };
  }

  const { usage = 0, quota = 0 } = await navigator.storage.estimate();
  const percentUsed = quota > 0 ? (usage / quota) * 100 : 0;

  return {
    available: percentUsed < 80, // warn at 80%, not 100%
    percentUsed,
  };
}

Also call navigator.storage.persist() to request durable storage. Without it, the browser may evict your IndexedDB data under storage pressure, silently.

Debugging Service Workers

The service worker is a separate process. console.log from inside it appears in DevTools under Application > Service Workers, not in the main console. Common debugging issues:

The worker is stuck in waiting. Open chrome://inspect/#service-workers to see all registered workers. In DevTools Application tab, click “skipWaiting” to force activation in development.

Requests are not being intercepted. Check the scope. A service worker registered with scope /app/ will not intercept requests to /api/. The scope must cover the URL you want to intercept.

Cache is not being updated. The browser caches the service worker file itself. During development, check “Update on reload” in DevTools Application tab. In production, serve your sw.js with Cache-Control: no-cache, no-store so the browser always fetches the latest worker file.

IndexedDB is not persisting between reloads. Check whether you are opening the database before the previous connection has closed. Multiple openDB calls with different version numbers will throw a VersionError. Guard with a module-level singleton as shown above.


Production Considerations

Versioning the sync protocol. Include a schema version in every sync queue item so the server can handle payloads from old clients that are syncing after a long offline period. A client offline for two weeks may still be running an old app version and sending the old payload shape.

Observability. Track sync queue depth as a metric. A queue that never drains indicates a persistent server-side rejection. Track the time between createdAt and syncedAt per entity type: this is your offline-to-sync latency, and it tells you whether background sync is actually firing.

Idempotency. The server must handle duplicate sync payloads. A network timeout means the client will retry. Use a stable operation ID (the id field from the sync queue record, or a UUID in the payload) as an idempotency key. On the server, an ON CONFLICT (idempotency_key) DO NOTHING is the simplest safeguard.

Testing offline behavior. Use Chrome DevTools Network tab to simulate offline mode, but also test with the service worker serving cached responses while the network is available. The two failure modes are different: “no network” vs. “network returns errors.” Service workers are active in both, but your networkFirst fallback logic only triggers on network errors, not on 4xx/5xx responses.

Safari quirks. Safari supports service workers and IndexedDB but does not support Background Sync. Safari also resets IndexedDB storage for origins that have not been visited in 7 days when ITP is active. Call navigator.storage.persist() and test the 7-day eviction behavior explicitly if you need reliable offline storage for Safari users.


Offline-first architecture is not a feature you bolt on. It requires a different mental model from the start: local state is primary, the network is a sync channel, and conflicts are normal rather than exceptional. The service worker lifecycle handles cache serving. IndexedDB with a sync queue handles durable local mutations. Vector clocks or CRDTs handle the merge problem. The rest is operational: versioned cache names, quota monitoring, idempotent server endpoints, and observable sync latency.

The pieces are well-understood. The difficulty is holding all of them together under a production deploy cycle.

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.