Building a true offline-first PWA requires rethinking every layer of the stack. These pillars form the structural backbone:
IndexedDB as the primary data store. The cloud is a backup, not the origin. Reads never block on network.
An in-browser proxy that intercepts network requests and serves cached responses intelligently.
Queue mutations locally. Flush them to the server automatically when connectivity is restored.
A strategy — CRDTs, last-write-wins, or user arbitration — for merging divergent states.
A two-way sync protocol that knows what each client has and delivers exactly the delta needed.
Deliver timely updates even when the app isn't open, keeping offline data eventually consistent.
Service workers are the engine of offline-first PWAs. They sit between your app and the network — a programmable proxy that you control. In PWAs 2.0, their role expands dramatically beyond simple caching.
The key innovation in PWAs 2.0 is layered caching strategies. Different resources need different freshness guarantees:
// Workbox-powered routing with layered strategies import { registerRoute, Route } from 'workbox-routing'; import { CacheFirst, StaleWhileRevalidate, NetworkFirst } from 'workbox-strategies'; // App shell: cache-first — never changes registerRoute( ({ request }) => request.destination === 'document', new CacheFirst({ cacheName: 'shell-v2' }) ); // API data: stale-while-revalidate — fast + fresh registerRoute( ({ url }) => url.pathname.startsWith('/api/feed'), new StaleWhileRevalidate({ cacheName: 'api-feed', plugins: [new ExpirationPlugin({ maxAgeSeconds: 3600 })] }) ); // User data: network-first — consistency critical registerRoute( ({ url }) => url.pathname.startsWith('/api/user'), new NetworkFirst({ networkTimeoutSeconds: 3, cacheName: 'user-data' }) );
Notice the deliberate mismatch: the app shell uses cache-first (the HTML never needs to be fresh), feed data uses stale-while-revalidate (show something immediately, update in the background), and user-specific data uses network-first with a fallback (correctness matters more than speed for personal data).
The hardest problem in offline-first development isn't going offline — it's coming back online. When a user has been working offline, their local state has diverged from the server. Merging these divergent histories correctly is the core engineering challenge.
Conflict-free Replicated Data Types (CRDTs) are mathematical structures that can be merged deterministically regardless of operation order. Instead of storing "the user's name is Alice", you store a sequence of operations: "user set name to Alice at T1", "user set name to Bob at T2". Any replica can replay these operations and arrive at the same result.
interface Operation { id: string; // UUID type: string; // 'SET' | 'DELETE' | 'APPEND' path: string; // dot-notation field path value: unknown; // payload timestamp: number; // HLC (hybrid logical clock) clientId: string; // for tie-breaking } class SyncEngine { private pending: Operation[] = []; // Queue an operation locally, apply optimistically async applyLocal(op: Operation) { await db.apply(op); // write to IndexedDB this.pending.push(op); // enqueue for server scheduleSync(); // attempt flush } // Flush pending ops to server when online async flush() { if (!navigator.onLine) return; const batch = [...this.pending]; const serverOps = await api.sync(batch); await this.mergeServerOps(serverOps); // CRDT merge this.pending = this.pending.filter( op => !batch.find(b => b.id === op.id) ); } }
The key detail is the hybrid logical clock (HLC). Wall-clock time is unreliable across devices — users can have incorrect system clocks. HLCs combine a physical timestamp with a monotonic counter, giving you causally ordered events that survive clock skew.
For most teams, IndexedDB's raw API is too verbose to use directly. Libraries like Dexie.js, RxDB, and Electric SQL wrap it in ergonomic abstractions while preserving its transactional guarantees.
import Dexie, { Table } from 'dexie'; export class AppDatabase extends Dexie { notes!: Table<Note>; operations!: Table<Operation>; constructor() { super('myapp'); this.version(3).stores({ // Index on synced flag for efficient pending-ops queries notes: 'id, updatedAt, [userId+updatedAt]', operations: 'id, synced, timestamp, [synced+timestamp]' }); } } const db = new AppDatabase(); // Live query — React hook that re-renders on data change export function useNotes(userId: string) { return useLiveQuery( () => db.notes .where('userId').equals(userId) .sortBy('updatedAt'), [userId] ); }
The useLiveQuery hook is the architectural linchpin: your UI subscribes to local data directly. When a sync operation writes new data to IndexedDB, every subscribed component re-renders automatically — no Redux actions, no manual cache invalidation.
Browsers allocate storage quota dynamically. Chrome grants up to 60% of available disk space; Safari is more conservative and will evict caches under storage pressure. Always use the Storage API to request persistent storage for data your users cannot afford to lose:
// Request persistent storage on first meaningful interaction async function requestPersistence() { if (navigator.storage?.persist) { const granted = await navigator.storage.persist(); if (!granted) { // Fallback: warn user, prioritize critical data pruneLowPriorityCache(); } } } // Check quota before large writes const { usage, quota } = await navigator.storage.estimate(); const usagePercent = (usage / quota) * 100; if (usagePercent > 80) evictOldData();
There's no one-size-fits-all caching strategy. The right choice depends on how frequently data changes and the cost of serving stale data.
| Strategy | Best For | Tradeoff |
|---|---|---|
| Cache First | App shell, fonts, static assets | Stale until version bump. Fast every time. |
| Network First | User profile, payments, auth | Slow offline. Accurate when online. |
| Stale-While-Revalidate | News feed, product catalog | Always fast. One-render stale. Best UX. |
| Network Only | Analytics, tracking pixels | Drops silently offline. Intentional. |
| Cache Only | Pre-cached game assets | Never updates. Deterministic. |
The most impactful strategy for most apps is Stale-While-Revalidate for API responses. Users see data instantly from cache; in the background, the service worker fetches an update and quietly refreshes the UI. This makes your app feel both fast and current — without waiting for the network.
The Background Sync API solves a subtle but critical UX problem: what happens when the user submits a form, the network fails, and they close the tab? Without background sync, that data is lost. With it, the browser registers a sync event and retries the request automatically — even after the tab is closed — as soon as connectivity is restored.
// In your app: register a sync tag when offline async function saveComment(comment) { await db.pendingComments.add(comment); // persist locally const reg = await navigator.serviceWorker.ready; await reg.sync.register('sync-comments'); } // In service-worker.js: handle the sync event self.addEventListener('sync', (event) => { if (event.tag === 'sync-comments') { event.waitUntil( db.pendingComments .toArray() .then(comments => Promise.all(comments.map(async c => { await api.postComment(c); await db.pendingComments.delete(c.id); })) ) ); } });
The browser handles exponential backoff automatically. Your service worker only needs to define what "success" looks like. If the promise rejects, the browser will retry the sync — up to implementation-specific limits.
Safari lacks Background Sync support. For cross-browser resilience, combine with a manual online-event listener as fallback.
The tooling ecosystem around offline-first PWAs has matured dramatically. Teams no longer need to build sync engines from scratch.
Postgres to local SQLite sync with automatic CRDTs. Your backend is your sync engine.
Offline-first sync framework with optimistic mutations, conflict resolution, and push/pull protocol.
Reactive local-first database with built-in CRDT support and multiple persistence adapters.
Zero-config service worker generation with Workbox integration. Precaching in one line.
The most ergonomic IndexedDB wrapper. Live queries, type safety, and migration support.
Fine-grained reactive state with first-class offline sync and automatic persistence.