frontend · advanced · 6d
Offline PWA sync
Offline-first notes PWA: a local write queue (IndexedDB) that syncs on reconnect with last-writer-wins conflict resolution, a service worker for asset caching, and background sync for missed flushes.
Deliverable
A notes app that works fully offline, queues edits in IndexedDB, and automatically syncs them to the server on reconnect without data loss.
Milestones
0/3 · 0%- 01Local-first write queue
Intercept all reads/writes through an IndexedDB queue and render the UI from local state — the app must work with DevTools set to Offline.
Definition of done- All reads/writes go through an IndexedDB queue and the UI renders from local state; the app works with DevTools set to Offline.
- A write made offline is durable across a reload (still queued, not lost).
- 02Service-worker caching
Add a service worker with a stale-while-revalidate strategy for the shell and cache-first for assets.
Definition of done- The app shell loads from the service worker offline; the shell uses stale-while-revalidate, static assets use cache-first.
- A new deploy updates the cached shell on the next visit without a hard refresh.
- 03Sync with LWW on reconnect
Flush the write queue on reconnect, resolve conflicts with last-writer-wins (compare server_updated_at vs local_updated_at), and register a Background Sync tag as a fallback.
Definition of done- On reconnect the queue flushes and conflicts resolve last-writer-wins by comparing server vs local updated_at; no edit is silently lost.
- A Background Sync tag flushes the queue even if the tab was closed at reconnect time.
Rubric
| Junior | Mid | Senior | |
|---|---|---|---|
| Service-worker cache strategy | A service worker is registered and intercepts fetch, but caching is uniform — the same strategy for the shell, API responses, and static assets. | The shell uses stale-while-revalidate (always fast, updated in the background), static assets use cache-first (immutable hashed filenames), and a new deploy triggers cache replacement on the next visit without a hard refresh. | You can state the stale-content window for each strategy and explain why the service-worker lifecycle (install → waiting → activate) makes the update timing non-obvious: a new worker waits for all tabs to close before claiming, and skipWaiting changes that trade-off from 'stale until restart' to 'potential mid-session inconsistency'. |
| Write-queue durability & background sync | Edits are stored in memory while offline; a reload before reconnect loses them. | The write queue lives in IndexedDB so it survives page reloads; on reconnect the queue flushes to the server, and no edit is silently lost. | A Background Sync tag registers on write so the flush fires even if the tab was closed at reconnect time; you can state the browser-support caveat (Safari's partial support) and the fallback behavior when Background Sync is unavailable. |
| Write-conflict resolution on reconnect | On reconnect the local write silently overwrites the server, or the server wins without inspecting timestamps — data from one side vanishes without notice. | Conflicts are resolved by comparing server_updated_at vs local_updated_at (last-writer-wins); no edit is silently discarded — the losing version is at least logged. | You can state where whole-document LWW fails (two users edit different fields of the same note; the later flush clobbers the other's change) and articulate the per-field merge that the senior-stretch implements — changes to different fields never overwrite each other because the merge key is field + timestamp, not document + timestamp. |
Reference walkthrough (spoiler)
Cache-strategy selection: stale-while-revalidate suits the app shell because users always get a fast response and the stale window (one visit) is acceptable; cache-first suits versioned static assets (JS/CSS with content-hash filenames) because they are truly immutable. Using cache-first for the shell risks serving an outdated version indefinitely if the update logic fails.
Service-worker update timing: a new service worker waits in 'installing' until all open tabs unload, then moves to 'active'. skipWaiting forces immediate activation but risks mid-session inconsistency if the old and new caches differ. The right call depends on whether a version mismatch causes a hard failure or just a stale UI.
LWW at document vs field granularity: last-writer-wins on updated_at resolves concurrent flushes cleanly when each note is owned by one user. It silently drops work when two users edit different parts of the same document, because the later flush replaces the whole document. Per-field merging — keying conflict resolution on field identity, not document identity — prevents this without a full CRDT.
Background Sync failure modes: the browser fires the sync event when it believes the network is available, but it does not guarantee delivery within a specific time window. A sync that fails re-queues for retry with an exponential backoff controlled by the browser. If the browser kills the service worker before the sync completes, the tag remains registered and will be retried — but only if the browser supports Background Sync; Safari's partial implementation means the queue must also flush on page-visible transitions as a fallback.
Make it senior
- Replace last-writer-wins with per-field merging: changes to different fields of the same note never clobber each other.
- Add an undo stack that works across offline/online transitions without corrupting the sync queue.