Pavan Rangani

HomeBlogBuilding Offline-First Web Apps with Service Workers and IndexedDB

Building Offline-First Web Apps with Service Workers and IndexedDB

By Pavan Rangani · February 12, 2026 · Web Development

Building Offline-First Web Apps with Service Workers and IndexedDB

Building Offline-First Web Apps with Service Workers and IndexedDB

Users expect apps to work everywhere — on airplanes, in subway tunnels, and in areas with spotty coverage. Offline-first web development treats the network as an enhancement rather than a requirement. Therefore, your application works without connectivity and syncs when the network returns. This guide shows you how to build offline-first apps that actually work in production, not just in a demo where the Wi-Fi never drops.

Why Offline-First? The Real-World Case

Consider a field service app used by technicians who work in basements, construction sites, and rural areas. They need to look up equipment manuals, fill out inspection forms, and capture photos — all while offline. An online-only app is useless for them. Moreover, even in connected environments, offline-first apps feel faster because they serve cached data instantly while fetching updates in the background.

The same pattern benefits consumer apps: a note-taking app that saves locally first (so you never lose work), a news reader that pre-caches articles for your commute, or an e-commerce app that lets you browse your cart on the subway. In each case, the user perceives the app as instant because the first paint comes from local storage rather than a round trip to a server.

Service Workers: The Network Proxy

A service worker sits between your app and the network, intercepting every request and deciding whether to serve from cache, fetch from network, or both. It runs in a separate thread and persists even after the user closes the tab. Importantly, it has a lifecycle — install, activate, and fetch — and understanding that lifecycle is the difference between a cache that updates cleanly and one that serves stale assets forever.

// sw.js — Service Worker with cache-first strategy
const CACHE_NAME = 'app-v3';
const STATIC_ASSETS = [
  '/',
  '/index.html',
  '/app.js',
  '/styles.css',
  '/manifest.json'
];

// Install: cache static assets
self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => cache.addAll(STATIC_ASSETS))
      .then(() => self.skipWaiting())  // Activate immediately
  );
});

// Activate: clean up old caches
self.addEventListener('activate', event => {
  event.waitUntil(
    caches.keys().then(keys =>
      Promise.all(
        keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))
      )
    ).then(() => self.clients.claim())  // Take control of all pages
  );
});

// Fetch: Network-first for API, Cache-first for assets
self.addEventListener('fetch', event => {
  const url = new URL(event.request.url);

  if (url.pathname.startsWith('/api/')) {
    // Network-first: try network, fall back to cache
    event.respondWith(
      fetch(event.request)
        .then(response => {
          // Cache successful API responses for offline use
          const clone = response.clone();
          caches.open(CACHE_NAME).then(cache =>
            cache.put(event.request, clone)
          );
          return response;
        })
        .catch(() => caches.match(event.request))  // Offline: use cached response
    );
  } else {
    // Cache-first: serve from cache, update in background
    event.respondWith(
      caches.match(event.request)
        .then(cached => {
          const fetchPromise = fetch(event.request).then(response => {
            caches.open(CACHE_NAME).then(cache =>
              cache.put(event.request, response.clone())
            );
            return response;
          });
          return cached || fetchPromise;
        })
    );
  }
});
Service worker code development
Service workers intercept network requests and serve cached responses when offline

Choosing the Right Caching Strategy

No single caching rule fits every request, so the example above deliberately mixes two strategies. Cache-first suits immutable, versioned assets — your hashed JavaScript bundle and CSS — because once cached they never change and serving them locally is instant. Network-first suits API data that should be fresh when possible but tolerable when stale. In addition, two further strategies are worth knowing: stale-while-revalidate returns the cached copy immediately and refreshes the cache in the background (ideal for avatars and feeds), while network-only is correct for anything you must never serve stale, such as a payment confirmation.

A common mistake is caching everything with one strategy. Consequently, teams either ship stale HTML that ignores deployments or hammer the network for assets that never change. The disciplined approach is to route by request type, exactly as the fetch handler does above. Edge cases matter too: skip caching for non-GET requests, respect Cache-Control: no-store headers, and never cache opaque cross-origin responses blindly, since they can silently consume large amounts of quota.

IndexedDB: Your Offline Database

IndexedDB is a full database in the browser that persists across sessions. Unlike localStorage (which only stores strings and has a 5MB limit), IndexedDB stores structured data, supports indexes and queries, and can hold hundreds of megabytes. Additionally, it’s transactional — writes either fully succeed or fully roll back. The raw API is famously verbose, so most production teams wrap it in a thin helper or adopt a small library such as idb or Dexie.

// Simplified IndexedDB wrapper for offline data
class OfflineStore {
  constructor(dbName, version = 1) {
    this.dbPromise = new Promise((resolve, reject) => {
      const request = indexedDB.open(dbName, version);
      request.onupgradeneeded = (event) => {
        const db = event.target.result;
        if (!db.objectStoreNames.contains('records')) {
          const store = db.createObjectStore('records', { keyPath: 'id' });
          store.createIndex('synced', 'synced', { unique: false });
          store.createIndex('updated', 'updatedAt', { unique: false });
        }
      };
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }

  async save(record) {
    const db = await this.dbPromise;
    return new Promise((resolve, reject) => {
      const tx = db.transaction('records', 'readwrite');
      tx.objectStore('records').put({
        ...record,
        updatedAt: Date.now(),
        synced: false  // Mark as needing sync
      });
      tx.oncomplete = () => resolve();
      tx.onerror = () => reject(tx.error);
    });
  }

  async getUnsynced() {
    const db = await this.dbPromise;
    return new Promise((resolve, reject) => {
      const tx = db.transaction('records', 'readonly');
      const index = tx.objectStore('records').index('synced');
      const request = index.getAll(false);  // All unsynced records
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }
}

// Usage
const store = new OfflineStore('my-app');
await store.save({ id: 'task-1', title: 'Fix bug', status: 'done' });
// Data is saved locally — works offline immediately

Notice the synced index. Because every record carries a boolean flag, the sync layer can cheaply query exactly the rows that still need to reach the server. This small piece of schema design is what turns a local cache into a reliable outbox.

Sync When Back Online

The Background Sync API lets your service worker defer network operations until connectivity returns. When the user submits a form offline, the data saves to IndexedDB and a sync event is registered. Subsequently, when the device reconnects, the service worker fires the sync handler which sends all pending changes to the server — even if the user has already closed the tab. This durability is precisely why Background Sync beats a naive “retry on window focus” approach.

// In the page: queue work, then ask for a sync
async function saveAndSync(record) {
  await store.save(record);              // 1. persist locally first
  const reg = await navigator.serviceWorker.ready;
  if ('sync' in reg) {
    await reg.sync.register('sync-records');  // 2. defer to background
  }
}

// In sw.js: drain the outbox when connectivity returns
self.addEventListener('sync', event => {
  if (event.tag === 'sync-records') {
    event.waitUntil(flushOutbox());
  }
});

async function flushOutbox() {
  const pending = await getUnsyncedRecords();
  for (const record of pending) {
    const res = await fetch('/api/records', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(record)
    });
    // Only mark synced on a confirmed 2xx; otherwise the browser
    // will retry the whole sync event later with backoff.
    if (res.ok) await markSynced(record.id);
    else throw new Error('Sync failed, will retry');
  }
}

Conflict resolution is the hard part. When two devices modify the same record offline, you need a strategy: last-write-wins (simplest), merge fields (more complex), or operational transforms and CRDTs (most accurate but hardest). For most applications, last-write-wins with a visible “last modified” timestamp is sufficient. However, for collaborative documents where concurrent edits are the norm, a CRDT library is worth the added complexity because it merges changes without losing anyone’s work.

Sync and offline data management
Background Sync defers network operations until connectivity returns

Testing, Storage Limits, and Common Pitfalls

Offline behavior is invisible until you deliberately test it, so make “offline mode” a first-class test case. In Chrome DevTools, the Application panel lets you toggle the network offline, inspect the active service worker, and clear caches between runs. Furthermore, browsers impose storage quotas based on available disk, and they may evict your data under pressure unless you request persistent storage via navigator.storage.persist(). Always handle the quota-exceeded error gracefully rather than letting a write silently fail.

Two pitfalls trip up nearly every team. First, a stale service worker: because a new worker waits by default until all tabs close, users can run old code for days unless you implement an update prompt. Second, debugging confusion from skipWaiting() — activating a new worker immediately is convenient, but it can leave a page running new assets against old cached APIs, so version your cache names and test upgrades explicitly.

When Offline-First Web Development Pays Off

Offline-first is worth the complexity for field service applications, note-taking and productivity tools, e-commerce product browsing, data collection in low-connectivity environments, and any app where data loss is unacceptable. However, it adds significant complexity — the caching strategies, the local database, the sync queue, and the conflict handling are all code you must own and test. Therefore, do not build offline-first for applications that genuinely require real-time connectivity, such as live trading, multiplayer games, or video conferencing, where stale data is worse than no data. In those cases, a clear “you are offline” state is the honest design choice.

Progressive web app development
Offline-first apps serve cached data instantly while syncing in the background

Related Reading:

Resources:

In conclusion, offline-first web development treats connectivity as an enhancement, not a requirement. Start with service worker caching for static assets, choose a deliberate strategy per request type, add IndexedDB for data persistence, and implement background sync for seamless reconnection. Your users will thank you the next time they’re in an elevator.

← Back to all articles