Offline-First & Background Sync
โฑ๏ธ ~4-minute bite ยท solve the sandbox to master
5-Year-Old Metaphor
โ The physical, real-world picture. No jargon.โ๏ธ Google Docs on a plane โ you keep writing offline. When you land and get wifi, your changes sync. Conflicts resolved automatically.
Online-first vs Offline-first
Online-first (fragile)
Default assumption: network always available. Every action requires a server round-trip. Network drops โ app broken. Shows spinner. User waits. Data lost.
Offline-first (resilient)
Default assumption: network is unreliable. Every action hits local storage first. Network is an enhancement. App works always. Sync is opportunistic.
The offline-first stack
Interactive Sandbox
โ Move something, see it react instantly.Pattern
Service Worker: install โ waiting โ active. Install event precaches app shell. Fetch event intercepts requests. Activate event cleans old caches.
Network timeline
Sync strategy
Cache-first for app shell, network-first for API data
Challenge
Step through all 5 offline-first patterns. Watch the online/offline toggle and sync status on each event.
Why Should I Care?
โ The exact interview question + the bug it kills.Interview questions
Q: Service Worker lifecycle โ install/activate/fetch in detail?
| 1 | // sw.js |
| 2 | const CACHE_NAME = 'app-v2'; |
| 3 | const APP_SHELL = ['/index.html', '/app.js', '/styles.css']; |
| 4 | ย |
| 5 | // 1. INSTALL โ precache app shell |
| 6 | self.addEventListener('install', (event) => { |
| 7 | event.waitUntil( |
| 8 | caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)) |
| 9 | ); |
| 10 | self.skipWaiting(); // activate immediately (optional) |
| 11 | }); |
| 12 | ย |
| 13 | // 2. ACTIVATE โ clean old caches |
| 14 | self.addEventListener('activate', (event) => { |
| 15 | event.waitUntil( |
| 16 | caches.keys().then((keys) => |
| 17 | Promise.all(keys |
| 18 | .filter((k) => k !== CACHE_NAME) |
| 19 | .map((k) => caches.delete(k)) |
| 20 | ) |
| 21 | ) |
| 22 | ); |
| 23 | self.clients.claim(); // take control of all tabs immediately |
| 24 | }); |
| 25 | ย |
| 26 | // 3. FETCH โ serve cached resources |
| 27 | self.addEventListener('fetch', (event) => { |
| 28 | event.respondWith( |
| 29 | caches.match(event.request).then((cached) => { |
| 30 | return cached ?? fetch(event.request); // cache-first |
| 31 | }) |
| 32 | ); |
| 33 | }); |
Q: How does the Background Sync API work?
| 1 | // 1. Register a sync tag when going offline |
| 2 | async function submitForm(data) { |
| 3 | await idb.put('sync-queue', { id: Date.now(), data, status: 'pending' }); |
| 4 | ย |
| 5 | const registration = await navigator.serviceWorker.ready; |
| 6 | await registration.sync.register('submit-forms'); // tag |
| 7 | } |
| 8 | ย |
| 9 | // 2. SW fires 'sync' event when online |
| 10 | self.addEventListener('sync', (event) => { |
| 11 | if (event.tag === 'submit-forms') { |
| 12 | event.waitUntil(replayPendingForms()); |
| 13 | } |
| 14 | }); |
| 15 | ย |
| 16 | async function replayPendingForms() { |
| 17 | const pending = await idb.getAll('sync-queue'); |
| 18 | for (const item of pending) { |
| 19 | await fetch('/api/submit', { |
| 20 | method: 'POST', |
| 21 | body: JSON.stringify(item.data), |
| 22 | }); |
| 23 | await idb.delete('sync-queue', item.id); // clear on success |
| 24 | } |
| 25 | } |
Q: What is a CRDT and why is it used for collaborative editing?
A CRDT (Conflict-free Replicated Data Type) is a data structure where concurrent operations can be merged without conflict. CRDTs have a mathematical property: regardless of the order operations are applied, the result is the same. This makes them perfect for collaborative editing โ two users editing offline can merge their changes without data loss, and the merge is deterministic. Y.js (used by Notion, Loom) and Automerge are popular CRDT libraries for JavaScript.
Bug: not handling sync conflicts โ data loss
| 1 | // โ BAD: last-write-wins without user awareness |
| 2 | async function sync(localDoc) { |
| 3 | const serverDoc = await fetch('/api/doc/1').then(r => r.json()); |
| 4 | // If server version is newer, just overwrite local changes: |
| 5 | await db.put('docs', serverDoc); // Alice's offline edits LOST |
| 6 | } |
| 7 | ย |
| 8 | // โ BETTER: detect conflict, prompt user |
| 9 | async function sync(localDoc) { |
| 10 | const serverDoc = await fetch('/api/doc/1').then(r => r.json()); |
| 11 | if (serverDoc.updatedAt > localDoc.syncedAt) { |
| 12 | // Show conflict UI: "Server has newer version โ keep yours or theirs?" |
| 13 | showConflictDialog(localDoc, serverDoc); |
| 14 | } |
| 15 | } |
| 16 | ย |
| 17 | // โ BEST (collaborative): Y.js CRDT merge |
| 18 | const ydoc = new Y.Doc(); |
| 19 | // Both changes are merged deterministically โ no data loss |
| 20 | Y.applyUpdate(ydoc, serverUpdate); |
| 21 | Y.applyUpdate(ydoc, localUpdate); |
The Deep Dive
โ Spec refs, engine internals, the minutiae.Dexie.js โ IndexedDB with a human API
| 1 | import Dexie, { type Table } from 'dexie'; |
| 2 | ย |
| 3 | interface Note { id?: number; title: string; body: string; updatedAt: Date; } |
| 4 | ย |
| 5 | class AppDB extends Dexie { |
| 6 | notes!: Table<Note>; |
| 7 | ย |
| 8 | constructor() { |
| 9 | super('AppDB'); |
| 10 | this.version(1).stores({ |
| 11 | notes: '++id, title, updatedAt', // ++ = auto-increment |
| 12 | }); |
| 13 | } |
| 14 | } |
| 15 | ย |
| 16 | const db = new AppDB(); |
| 17 | ย |
| 18 | // CRUD โ all async, returns Promises |
| 19 | const id = await db.notes.add({ title: 'Offline note', body: '...', updatedAt: new Date() }); |
| 20 | const note = await db.notes.get(id); |
| 21 | await db.notes.update(id, { body: 'Updated offline' }); |
| 22 | const recent = await db.notes.orderBy('updatedAt').last(10).toArray(); |
| 23 | await db.notes.delete(id); |
Workbox โ Service Worker strategies
| 1 | import { registerRoute } from 'workbox-routing'; |
| 2 | import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies'; |
| 3 | import { BackgroundSyncPlugin } from 'workbox-background-sync'; |
| 4 | ย |
| 5 | // App shell: CacheFirst โ never need network for versioned assets |
| 6 | registerRoute(({ request }) => request.destination === 'script', |
| 7 | new CacheFirst({ cacheName: 'scripts-v2' })); |
| 8 | ย |
| 9 | // API data: NetworkFirst โ prefer fresh, cache fallback |
| 10 | registerRoute(({ url }) => url.pathname.startsWith('/api/'), |
| 11 | new NetworkFirst({ |
| 12 | cacheName: 'api-v1', |
| 13 | networkTimeoutSeconds: 3, |
| 14 | plugins: [ |
| 15 | new BackgroundSyncPlugin('api-queue', { |
| 16 | maxRetentionTime: 24 * 60, // retry for 24 hours |
| 17 | }), |
| 18 | ], |
| 19 | })); |
| 20 | ย |
| 21 | // Images: SWR โ fast + background refresh |
| 22 | registerRoute(({ request }) => request.destination === 'image', |
| 23 | new StaleWhileRevalidate({ cacheName: 'images-v1' })); |
Y.js โ CRDT for collaborative editing
| 1 | import * as Y from 'yjs'; |
| 2 | import { WebsocketProvider } from 'y-websocket'; |
| 3 | ย |
| 4 | // Create a shared document |
| 5 | const ydoc = new Y.Doc(); |
| 6 | const ytext = ydoc.getText('document'); // shared text type |
| 7 | ย |
| 8 | // Sync via WebSocket โ Y.js handles merging |
| 9 | const provider = new WebsocketProvider('wss://sync.example.com', 'room-1', ydoc); |
| 10 | ย |
| 11 | // Offline: changes buffered locally in Y.Doc |
| 12 | ytext.insert(0, 'Hello from Alice'); |
| 13 | ย |
| 14 | // On reconnect: Y.js sends only the diff (encoded state vector) |
| 15 | // Server merges all concurrent edits โ guaranteed convergence |
| 16 | ย |
| 17 | // Persistence: encode Y.Doc to Uint8Array for IndexedDB |
| 18 | const update = Y.encodeStateAsUpdate(ydoc); |
| 19 | await idb.put('crdt-state', update); |
| 20 | ย |
| 21 | // Restore from IndexedDB on next load |
| 22 | const savedUpdate = await idb.get('crdt-state'); |
| 23 | if (savedUpdate) Y.applyUpdate(ydoc, savedUpdate); |
PouchDB + CouchDB replication protocol
PouchDB is a CouchDB-compatible in-browser database that replicates to a CouchDB server automatically. It handles conflict detection, revision tracking, and sync resumption out of the box โ the entire offline-first stack in one library.
| 1 | import PouchDB from 'pouchdb'; |
| 2 | ย |
| 3 | const localDB = new PouchDB('myapp'); |
| 4 | const remoteDB = new PouchDB('https://sync.example.com/myapp'); |
| 5 | ย |
| 6 | // Live bidirectional sync โ auto-reconnects on network drop |
| 7 | localDB.sync(remoteDB, { live: true, retry: true }) |
| 8 | .on('change', (info) => console.log('synced:', info)) |
| 9 | .on('error', (err) => console.error('sync error:', err)); |
| 10 | ย |
| 11 | // CRUD to localDB โ syncs to remote when online |
| 12 | await localDB.put({ _id: 'note-1', title: 'Offline note', body: '...' }); |
| 13 | ย |
| 14 | // Conflict resolution: CouchDB tracks all revisions |
| 15 | // Each doc has a _rev field โ concurrent edits create branches |
| 16 | // PouchDB exposes conflicts via { conflicts: true } option |
Interview Questions
โ Real questions from real interviews โ with answers.Local IndexedDB as the source of truth; delta sync on reconnect; CRDT or version vector for conflict resolution.
New SW installs alongside the old one; waits until all tabs using the old SW are closed before activating.
CRDTs merge concurrent edits mathematically; LWW silently discards one user's work.
Background Sync fires even after the page is closed; navigator.onLine only works while the page is open.
Optimistic local writes; vector clock per record; server-side merge with conflict surfacing; incremental sync on reconnect.
Memory Game
โ Quick quiz โ lock the concept in long-term memory.What is Workbox and what problem does it solve?