Offline-first PWA architecture that survives real networks
Patterns for building PWAs that work on planes, subways and rural 3G — cache layering, sync queues, conflict resolution and the UI cues users expect.
Offline-first isn’t a Lighthouse checkbox; it’s an architecture. Done right, users on a spotty train ride experience your app as if the network never existed. Done wrong, they see silent data loss. Here’s the pattern we use for real products.
The three layers
- UI — always reads from local storage first, network second.
- Local store — IndexedDB (via
idb-keyvalor Dexie), keyed by the same IDs the server uses. - Sync worker — Background Sync API queues writes when offline and replays on reconnect.
Reads
Never await fetch() in the render path. Read from IndexedDB, render immediately, then update in the background:
const cached = await db.get('projects');
render(cached);
fetch('/api/projects').then((r) => r.json()).then((fresh) => {
db.set('projects', fresh);
render(fresh);
});
Writes
Assume the network will fail. Write to IndexedDB, enqueue the mutation, register a sync:
await db.add('outbox', { op: 'update', id, payload });
const reg = await navigator.serviceWorker.ready;
await reg.sync.register('flush-outbox');
The service worker replays the queue when connectivity returns. Handle 409 conflicts explicitly — last-write-wins is fine for personal data, wrong for shared docs.
Conflict resolution
- Last-write-wins — fine for personal preferences.
- Server-authoritative merge — default for CRUD apps; return the reconciled record.
- CRDT — required for collaborative editing. Yjs or Automerge.
UI cues that build trust
- Toolbar dot that changes colour when offline.
- "Saved locally — will sync" toast on each queued write.
- Sync progress banner on reconnect.
- Explicit "retry" for hard failures instead of silent drop.
Anti-patterns
Don’t rely on navigator.onLine alone — it lies. Test connectivity with a HEAD request to your own origin. Don’t use LocalStorage for anything larger than a few KB — it’s synchronous and blocks the main thread.
Ship your install prompt in 60 seconds
InstantPWA is one line of code. Free forever for one widget, no card required.
Get started free