🌐 Detecting your location…

How to Build an Offline-First PWA in 2026: Service Workers and IndexedDB Guide

⏱️7 min read  ·  1,397 words

Offline-first means the application works from local data by default and treats the network as an enhancement. That is a different design from “works online, degrades when offline”, and it produces an app that feels instant even on a good connection. This guide builds one properly: service worker caching, IndexedDB for structured data, and background sync for writes made while offline.

The Architecture

  1. Service worker intercepts network requests and serves from cache
  2. Cache Storage holds the application shell — HTML, CSS, JavaScript, fonts
  3. IndexedDB holds structured data — records the user reads and writes
  4. Background Sync replays writes made offline once the connection returns

The key mental shift: the UI reads from IndexedDB, never directly from the network. A separate process keeps IndexedDB current. That single decision is what makes the app work identically online and off.

Step 1: The Manifest

{
  "name": "Field Notes",
  "short_name": "Notes",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#1a1a2e",
  "icons": [
    { "src": "/icons/192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icons/512.png", "sizes": "512x512", "type": "image/png" },
    { "src": "/icons/512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
  ]
}
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#1a1a2e">

The maskable icon matters on Android, where a non-maskable icon gets cropped into a shape that often cuts off part of your logo.

Step 2: Registering the Service Worker

if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const reg = await navigator.serviceWorker.register('/sw.js');

      // Notify the user when a new version is waiting.
      reg.addEventListener('updatefound', () => {
        const newWorker = reg.installing;
        newWorker.addEventListener('statechange', () => {
          if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
            showUpdateBanner(() => {
              newWorker.postMessage({ type: 'SKIP_WAITING' });
            });
          }
        });
      });
    } catch (err) {
      console.error('Service worker registration failed:', err);
    }
  });
}

// Reload once the new worker takes control.
let refreshing = false;
navigator.serviceWorker?.addEventListener('controllerchange', () => {
  if (refreshing) return;
  refreshing = true;
  window.location.reload();
});

Never force-activate a new worker without telling the user. Swapping the code underneath a running application mid-session causes confusing failures — offer a reload instead.

Step 3: The Service Worker and Its Caching Strategies

Different resources want different strategies, and using one strategy for everything is the usual mistake.

Strategy Use for
Cache first Hashed static assets — they never change
Network first API data where freshness matters
Stale while revalidate Content that can be slightly old — avatars, listings
Network only Anything with side effects
// sw.js
const VERSION = 'v3';
const SHELL_CACHE = `shell-${VERSION}`;
const DATA_CACHE  = `data-${VERSION}`;

const SHELL_ASSETS = [
  '/',
  '/index.html',
  '/offline.html',
  '/styles.css',
  '/app.js',
];

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(SHELL_CACHE).then(c => c.addAll(SHELL_ASSETS))
  );
});

self.addEventListener('activate', (event) => {
  event.waitUntil((async () => {
    const keys = await caches.keys();
    await Promise.all(
      keys.filter(k => !k.endsWith(VERSION)).map(k => caches.delete(k))
    );
    await self.clients.claim();
  })());
});

self.addEventListener('message', (event) => {
  if (event.data?.type === 'SKIP_WAITING') self.skipWaiting();
});

self.addEventListener('fetch', (event) => {
  const { request } = event;

  // Never cache anything that changes server state.
  if (request.method !== 'GET') return;

  const url = new URL(request.url);

  // Navigations: network first, fall back to the offline page.
  if (request.mode === 'navigate') {
    event.respondWith(
      fetch(request).catch(() => caches.match('/offline.html'))
    );
    return;
  }

  // API: network first, fall back to the cached copy.
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(request));
    return;
  }

  // Static assets: cache first.
  event.respondWith(cacheFirst(request));
});

async function networkFirst(request) {
  const cache = await caches.open(DATA_CACHE);
  try {
    const response = await fetch(request);
    if (response.ok) cache.put(request, response.clone());
    return response;
  } catch {
    const cached = await cache.match(request);
    if (cached) return cached;
    return new Response(
      JSON.stringify({ error: 'offline' }),
      { status: 503, headers: { 'Content-Type': 'application/json' } }
    );
  }
}

async function cacheFirst(request) {
  const cached = await caches.match(request);
  if (cached) return cached;

  const response = await fetch(request);
  if (response.ok) {
    const cache = await caches.open(SHELL_CACHE);
    cache.put(request, response.clone());
  }
  return response;
}

response.clone() is required because a response body can only be read once. Caching the original and returning it to the page consumes it twice, and the page receives an empty body.

Step 4: IndexedDB for Structured Data

Cache Storage holds whole HTTP responses. For records you need to query, sort, and update individually, use IndexedDB. The raw API is verbose, so a thin wrapper is worth it.

import { openDB } from 'idb';

const db = await openDB('field-notes', 2, {
  upgrade(db, oldVersion) {
    if (oldVersion < 1) {
      const notes = db.createObjectStore('notes', { keyPath: 'id' });
      notes.createIndex('by-updated', 'updatedAt');
      notes.createIndex('by-synced', 'synced');
    }
    if (oldVersion < 2) {
      db.createObjectStore('outbox', { keyPath: 'id', autoIncrement: true });
    }
  },
});

export async function saveNote(note) {
  const record = { ...note, updatedAt: Date.now(), synced: false };
  await db.put('notes', record);
  await db.add('outbox', { type: 'saveNote', payload: record });
  await requestSync();
  return record;
}

export async function listNotes() {
  return db.getAllFromIndex('notes', 'by-updated');
}

Note the ordering: write to local storage first, then queue the sync. The UI updates instantly and does not care whether the network is available. That is the whole point of offline-first.

Step 5: Background Sync

Background Sync lets the browser replay queued writes once connectivity returns, even if the page has been closed.

// In the page
async function requestSync() {
  const reg = await navigator.serviceWorker.ready;
  if ('sync' in reg) {
    await reg.sync.register('sync-outbox');
  } else {
    await flushOutbox();   // fall back to an immediate attempt
  }
}
// In sw.js
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-outbox') {
    event.waitUntil(flushOutbox());
  }
});

async function flushOutbox() {
  const db = await openDB('field-notes', 2);
  const items = await db.getAll('outbox');

  for (const item of items) {
    try {
      const res = await fetch('/api/notes', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(item.payload),
      });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);

      await db.delete('outbox', item.id);
      await db.put('notes', { ...item.payload, synced: true });
    } catch {
      // Leave it queued; the sync event fires again later.
      return;
    }
  }
}

Background Sync is not available in every browser. Always keep a fallback that flushes the outbox when the page loads and when an online event fires.

Handling Conflicts

Offline writes create conflicts, and pretending otherwise produces silent data loss. Decide a policy explicitly.

Last write wins is simplest — send a timestamp and let the server take the newer one. Adequate for personal notes, wrong for anything collaborative.

Server wins on conflict is safe and requires telling the user their offline change was rejected. Never discard it silently.

Merge is best where the data structure supports it, which usually means a CRDT for genuinely collaborative documents.

const res = await fetch('/api/notes/' + note.id, {
  method: 'PUT',
  headers: {
    'Content-Type': 'application/json',
    'If-Unmodified-Since': new Date(note.serverUpdatedAt).toUTCString(),
  },
  body: JSON.stringify(note),
});

if (res.status === 412) {
  await queueConflictForReview(note);   // surface it, never drop it
}

Storage Limits

Browsers evict storage under pressure. For an app whose local data is the source of truth until sync, request persistence.

if (navigator.storage?.persist) {
  const persisted = await navigator.storage.persist();
  console.log('Persistent storage:', persisted);
}

const { usage, quota } = await navigator.storage.estimate();
console.log(`Using ${(usage / 1024 / 1024).toFixed(1)}MB of ${(quota / 1024 / 1024).toFixed(0)}MB`);

Testing Offline Properly

DevTools’ offline checkbox is a starting point, not a test. Also check: reloading while offline, a slow and flaky connection rather than a cleanly absent one, what happens when the service worker updates mid-session, and whether a queued write survives a full browser restart.

# Verify the manifest, service worker, and installability
npx lighthouse https://your-app.com --only-categories=pwa --view

Common Mistakes

Caching POST requests. Cache Storage only handles GET, and caching side-effecting requests would be wrong anyway.

Forgetting response.clone(). The page receives an empty body.

Never invalidating old caches. Users get stale assets indefinitely. Version cache names and delete old ones on activate.

Calling skipWaiting() unconditionally. Code changes underneath a running session.

Reading from the network in the UI. Read from IndexedDB and sync separately, or the app is not offline-first.

Dropping failed offline writes. Silent data loss is the worst possible outcome — always surface conflicts.

Conclusion

Offline-first rests on one architectural decision: the UI reads and writes local storage, and synchronisation happens separately in the background. Cache the application shell with a versioned service worker, keep structured data in IndexedDB, queue writes in an outbox and replay them with Background Sync, and choose a conflict policy deliberately rather than discovering one by accident. Test with a flaky connection rather than a clean offline toggle, because intermittent connectivity is what users actually experience.

MD Rafikul Islam

Written by

MD Rafikul Islam is a software developer and the editor of TechPulse. He writes about developer tooling, hardware, and the practical decisions that come up in day-to-day engineering work — which laptop to buy, which framework to commit to, why a build broke at 2am. He tests the tools he writes about and says plainly when something is not worth the money. Corrections and corrections requests are welcome at rony.yf25@gmail.com.

✍️ Leave a Comment

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

🌐 Read in:🇬🇧 English🇩🇪 Deutsch🇧🇷 Português🇸🇦 العربية🇮🇳 हिन्दी🇧🇩 বাংলা