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.

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.

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.

// 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

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.

// 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

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. When the device reconnects, the service worker fires the sync handler which sends all pending changes to the server.

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 (most accurate but hardest). For most applications, last-write-wins with a visible “last modified” timestamp is sufficient.

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

When to Build Offline-First

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 — don’t build offline-first for applications that genuinely require real-time connectivity (live trading, multiplayer games, video conferencing).

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

Related Reading:

Resources:

In conclusion, offline-first development treats connectivity as an enhancement, not a requirement. Start with service worker caching for static assets, 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.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top