Web Engineering ·

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

A practical guide to building web applications that work reliably without network connectivity, covering service worker caching strategies, IndexedDB data storage, conflict resolution, and background sync.

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

Most web applications treat offline as an error state. The spinner appears, the fetch fails, and the user gets a blank screen or a generic “No internet connection” message. This works fine when connectivity is reliable. It falls apart for field workers, travelers, users on flaky mobile networks, or anyone who has ever opened a tab on the subway.

The offline-first approach inverts this. Design for no connectivity. Treat the network as an optional enhancement. When you build this way, your application is faster (reads from local cache), more resilient, and genuinely useful in environments where your users actually work.

This guide covers the concrete engineering decisions involved: service worker lifecycle and caching strategies, structured storage with IndexedDB, conflict resolution when changes come back online, and the production gotchas that are not obvious until they bite you.

The Offline-First Mindset

The shift is architectural, not cosmetic. An offline-first application does not show a cached version as a fallback. It works from local data as the primary source, then reconciles with the server when connectivity is available.

This means:

  • Local writes succeed immediately, server writes happen asynchronously
  • The UI reflects local state, not server state
  • Conflicts are an expected condition, not an exceptional one
  • Sync is a background concern, not a prerequisite for interaction

The hardest part is accepting that two users can make conflicting changes to the same record while offline, and your system needs a defined strategy for resolving that.

Service Worker Lifecycle

A service worker is a JavaScript file that runs in a separate thread, intercepts network requests, and manages the browser cache. It has three lifecycle phases: install, activate, and fetch.

// sw.ts
const CACHE_VERSION = 'v2';
const STATIC_CACHE = `static-${CACHE_VERSION}`;
const DYNAMIC_CACHE = `dynamic-${CACHE_VERSION}`;

const STATIC_ASSETS = [
  '/',
  '/index.html',
  '/app.js',
  '/app.css',
];

self.addEventListener('install', (event: ExtendableEvent) => {
  event.waitUntil(
    caches.open(STATIC_CACHE).then((cache) => cache.addAll(STATIC_ASSETS))
  );
  // Take control immediately — do not wait for the old SW to expire
  (self as ServiceWorkerGlobalScope).skipWaiting();
});

self.addEventListener('activate', (event: ExtendableEvent) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(
        keys
          .filter((key) => key !== STATIC_CACHE && key !== DYNAMIC_CACHE)
          .map((key) => caches.delete(key))
      )
    )
  );
  // Claim all clients without waiting for a reload
  (self as ServiceWorkerGlobalScope).clients.claim();
});

The skipWaiting() and clients.claim() calls are important. Without them, a new service worker installs but waits for all existing tabs to close before activating. For most offline-first apps, you want the new version to take control immediately. Be aware that this means a tab could have HTML from the old version served by the new service worker, so keep your cache versioning disciplined.

Caching Strategies

Three strategies cover most use cases. The right choice depends on the resource type and how stale you can tolerate.

Cache-First

Serve from cache, fall back to network. Use for static assets, fonts, icons, or any resource that changes infrequently.

self.addEventListener('fetch', (event: FetchEvent) => {
  if (isStaticAsset(event.request.url)) {
    event.respondWith(
      caches.match(event.request).then(
        (cached) => cached ?? fetch(event.request)
      )
    );
  }
});

function isStaticAsset(url: string): boolean {
  return /\.(js|css|woff2?|png|svg|ico)$/.test(url);
}

Network-First

Try the network, fall back to cache on failure. Use for API responses where freshness matters but offline access is still required.

async function networkFirst(request: Request): Promise<Response> {
  const cache = await caches.open(DYNAMIC_CACHE);
  try {
    const networkResponse = await fetch(request);
    if (networkResponse.ok) {
      cache.put(request, networkResponse.clone());
    }
    return networkResponse;
  } 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

Serve from cache immediately, then update the cache in the background. Use for data where near-real-time freshness is acceptable (dashboards, feeds, settings).

async function staleWhileRevalidate(request: Request): Promise<Response> {
  const cache = await caches.open(DYNAMIC_CACHE);
  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;
}
StrategyFreshnessOffline supportLatencyUse for
Cache-firstLowFullFastestStatic assets, fonts
Network-firstHighGraceful fallbackSlowestAPI responses
Stale-while-revalidateMediumFullFastFeeds, settings, catalogs
Network-onlyHighestNoneNetwork speedAuth, payments
Cache-onlyLowestFullFastestApp shell

Storing Structured Data with IndexedDB

The Cache API stores HTTP responses. For structured application data that changes locally while offline, you need IndexedDB. It is transactional, supports indexes and range queries, and handles structured objects natively.

The raw IndexedDB API is callback-based and verbose. Wrap it:

// db.ts
interface Task {
  id: string;
  title: string;
  completed: boolean;
  updatedAt: number;
  syncStatus: 'synced' | 'pending' | 'conflict';
}

function openDB(): Promise<IDBDatabase> {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open('app-db', 2);

    request.onupgradeneeded = (event) => {
      const db = (event.target as IDBOpenDBRequest).result;

      if (!db.objectStoreNames.contains('tasks')) {
        const store = db.createObjectStore('tasks', { keyPath: 'id' });
        store.createIndex('syncStatus', 'syncStatus', { unique: false });
        store.createIndex('updatedAt', 'updatedAt', { unique: false });
      }
    };

    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

async function putTask(task: Task): Promise<void> {
  const db = await openDB();
  return new Promise((resolve, reject) => {
    const tx = db.transaction('tasks', 'readwrite');
    tx.objectStore('tasks').put(task);
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });
}

async function getPendingTasks(): Promise<Task[]> {
  const db = await openDB();
  return new Promise((resolve, reject) => {
    const tx = db.transaction('tasks', 'readonly');
    const index = tx.objectStore('tasks').index('syncStatus');
    const request = index.getAll('pending');
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

Write locally first, mark the record as pending, and sync later. This is the core pattern.

async function updateTask(id: string, patch: Partial<Task>): Promise<void> {
  const current = await getTask(id);
  await putTask({
    ...current,
    ...patch,
    updatedAt: Date.now(),
    syncStatus: 'pending',
  });
  // Attempt immediate sync if online; Background Sync handles the offline case
  if (navigator.onLine) {
    await syncPendingTasks();
  }
}

Conflict Resolution

When two clients modify the same record while offline, you have a conflict. Three common strategies:

Last-Write-Wins

The simplest approach. The record with the higher updatedAt timestamp wins. Works for most casual data (user preferences, settings). Fails when two users are genuinely collaborating, because one of their changes silently disappears.

async function resolveConflict(local: Task, remote: Task): Promise<Task> {
  return local.updatedAt > remote.updatedAt ? local : remote;
}

Field-Level Merging

Merge non-conflicting fields. Only escalate fields that genuinely conflict.

interface MergeResult<T> {
  merged: T;
  conflicts: Partial<Record<keyof T, { local: unknown; remote: unknown }>>;
}

function mergeTask(
  base: Task,
  local: Task,
  remote: Task
): MergeResult<Task> {
  const conflicts: MergeResult<Task>['conflicts'] = {};
  const merged = { ...base };

  for (const key of Object.keys(base) as (keyof Task)[]) {
    const localChanged = local[key] !== base[key];
    const remoteChanged = remote[key] !== base[key];

    if (localChanged && remoteChanged && local[key] !== remote[key]) {
      conflicts[key] = { local: local[key], remote: remote[key] };
      // Fall back to remote on genuine conflict (server wins)
      (merged as Record<string, unknown>)[key as string] = remote[key];
    } else if (localChanged) {
      (merged as Record<string, unknown>)[key as string] = local[key];
    } else {
      (merged as Record<string, unknown>)[key as string] = remote[key];
    }
  }

  return { merged: merged as Task, conflicts };
}

CRDTs (Conflict-free Replicated Data Types)

CRDTs are data structures designed to merge without conflicts by mathematical guarantee. A grow-only set (G-Set), last-write-wins register (LWW-Register), or OR-Set are common primitives. They add implementation complexity but eliminate the entire class of merge conflict bugs for the data types they cover.

A practical counter CRDT:

interface PNCounter {
  increments: Record<string, number>; // nodeId -> count
  decrements: Record<string, number>;
}

function incrementCounter(counter: PNCounter, nodeId: string): PNCounter {
  return {
    ...counter,
    increments: {
      ...counter.increments,
      [nodeId]: (counter.increments[nodeId] ?? 0) + 1,
    },
  };
}

function mergeCounters(a: PNCounter, b: PNCounter): PNCounter {
  const allNodes = new Set([
    ...Object.keys(a.increments),
    ...Object.keys(b.increments),
    ...Object.keys(a.decrements),
    ...Object.keys(b.decrements),
  ]);

  const increments: Record<string, number> = {};
  const decrements: Record<string, number> = {};

  for (const node of allNodes) {
    increments[node] = Math.max(
      a.increments[node] ?? 0,
      b.increments[node] ?? 0
    );
    decrements[node] = Math.max(
      a.decrements[node] ?? 0,
      b.decrements[node] ?? 0
    );
  }

  return { increments, decrements };
}

function readCounter(counter: PNCounter): number {
  const inc = Object.values(counter.increments).reduce((s, n) => s + n, 0);
  const dec = Object.values(counter.decrements).reduce((s, s2) => s + s2, 0);
  return inc - dec;
}
StrategyComplexityData loss riskSuitable for
Last-write-winsLowHighPreferences, settings
Field-level mergeMediumLowDocuments, profiles
Server winsLowMedium (local changes lost)Safety-critical data
CRDTsHighNone (by design)Collaborative editing, counters
Operational transformVery highNoneReal-time text editing

Background Sync

The Background Sync API allows the service worker to retry failed requests after connectivity is restored, even if the originating tab has closed.

// In your app code
async function queueSync(tag: string): Promise<void> {
  if ('serviceWorker' in navigator && 'SyncManager' in window) {
    const registration = await navigator.serviceWorker.ready;
    await registration.sync.register(tag);
  } else {
    // Fallback: attempt sync immediately
    await syncPendingTasks();
  }
}

// In sw.ts
self.addEventListener('sync', (event: SyncEvent) => {
  if (event.tag === 'sync-pending-tasks') {
    event.waitUntil(syncPendingTasksFromSW());
  }
});

async function syncPendingTasksFromSW(): Promise<void> {
  const pending = await getPendingTasks();
  await Promise.allSettled(
    pending.map(async (task) => {
      const response = await fetch('/api/tasks', {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(task),
      });
      if (response.ok) {
        await putTask({ ...task, syncStatus: 'synced' });
      } else if (response.status === 409) {
        await putTask({ ...task, syncStatus: 'conflict' });
      }
    })
  );
}

Browser support for Background Sync is broad on Chromium-based browsers but absent in Safari. Always implement the immediate-fallback path.

Handling Auth Tokens Offline

Authentication creates a specific tension with offline-first design. JWTs expire. If the user’s token expires while offline, requests will fail when they reconnect, and writes may be rejected.

A practical approach:

interface StoredAuth {
  accessToken: string;
  refreshToken: string;
  expiresAt: number; // Unix timestamp
}

async function getValidToken(): Promise<string | null> {
  const auth = await getAuthFromIndexedDB();
  if (!auth) return null;

  const bufferMs = 5 * 60 * 1000; // Refresh 5 minutes before expiry
  if (Date.now() < auth.expiresAt - bufferMs) {
    return auth.accessToken;
  }

  // If online, try to refresh
  if (navigator.onLine) {
    try {
      const refreshed = await refreshAccessToken(auth.refreshToken);
      await storeAuthInIndexedDB(refreshed);
      return refreshed.accessToken;
    } catch {
      return null; // Force re-login
    }
  }

  // Offline with an expired token: return the expired token anyway
  // The server will reject it on sync, but local operations can continue
  // Use a separate flag to re-auth on next online event
  return auth.accessToken;
}

window.addEventListener('online', async () => {
  const auth = await getAuthFromIndexedDB();
  if (auth && Date.now() > auth.expiresAt) {
    // Prompt re-authentication before sync
    dispatchEvent(new CustomEvent('auth:refresh-required'));
  } else {
    await syncPendingTasks();
  }
});

Keep refresh tokens in IndexedDB, not localStorage. IndexedDB is accessible from service workers; localStorage is not.

Testing Offline Scenarios

Chrome DevTools provides an offline checkbox in the Network panel, but this is insufficient for real testing. The offline toggle does not reflect the real sequence of events: going offline mid-request, coming back online, the sync firing, and the UI reconciling.

Write explicit tests:

// Example using a mock service worker (msw) and vitest
describe('offline task sync', () => {
  it('writes to IndexedDB when offline and syncs on reconnect', async () => {
    // Simulate offline
    vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false);

    await updateTask('task-1', { completed: true });

    const pending = await getPendingTasks();
    expect(pending).toHaveLength(1);
    expect(pending[0].syncStatus).toBe('pending');

    // Simulate coming back online
    vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true);
    window.dispatchEvent(new Event('online'));

    // Wait for sync
    await vi.waitFor(async () => {
      const tasks = await getPendingTasks();
      expect(tasks).toHaveLength(0);
    });
  });

  it('marks record as conflict when server returns 409', async () => {
    server.use(
      http.put('/api/tasks', () => new HttpResponse(null, { status: 409 }))
    );

    await updateTask('task-2', { title: 'conflicting change' });
    await syncPendingTasks();

    const task = await getTask('task-2');
    expect(task?.syncStatus).toBe('conflict');
  });
});

Also test the service worker cache version upgrade path. Specifically: deploy a new cache version and verify old entries are purged, not served.

Production Considerations

Storage limits. IndexedDB storage is shared across origins and subject to browser eviction under disk pressure. On Safari, the limit is ~1GB per origin on desktop, much less on iOS. Implement explicit storage quota checks and prune old data before hitting limits.

Cache invalidation on deploy. If you increment CACHE_VERSION but forget to update the STATIC_ASSETS list, users can end up with a mix of old and new assets. Automate the cache manifest generation as part of your build step.

Partial sync failures. Promise.allSettled lets you handle individual record failures without aborting the entire sync run. Log failures with enough context to retry or surface conflicts.

Service worker scope. The service worker only intercepts requests within its scope (the directory it is served from). Serve it from the root (/sw.js) to cover your entire app.

Multiple tabs. Two tabs can both be running the same service worker. If Tab A writes to IndexedDB and the service worker syncs, Tab B still has stale in-memory state. Use BroadcastChannel or the clients.matchAll() API to notify open tabs when sync completes.

// In sw.ts, after a successful sync
const clients = await (self as ServiceWorkerGlobalScope).clients.matchAll();
clients.forEach((client) =>
  client.postMessage({ type: 'sync-complete', timestamp: Date.now() })
);

Storage eviction on iOS. Safari evicts IndexedDB data for sites not visited in seven days. If your users work offline for extended periods and then reconnect, their local changes may already be gone. Store critical pending writes in a redundant location (encrypted in a server-side draft store on last-known-online moment) for high-stakes applications.

Closing

Offline-first design pays off in proportion to how unreliable your users’ network environments are. For a field service tool used in basements and construction sites, it is non-negotiable. For a SaaS admin panel accessed from office desks, the tradeoffs may not be worth it. The patterns here, service worker caching, IndexedDB for local writes, a defined conflict resolution strategy, and background sync, compose well. Start with the caching layer and a simple last-write-wins sync strategy. Add CRDT-based conflict resolution only when you have observed real data loss in production. The infrastructure supports the full spectrum; build as much of it as your use case demands.

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.