frontend · advanced · 7d
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 PWA that works fully offline (IndexedDB queue survives reload), syncs on reconnect with LWW (per-field merge stretch), serves the shell via a versioned service worker, and flushes via Background Sync even if the tab was closed — with sync status UI and metrics.
Milestones
0/5 · 0%- 01Local-first write queue (IndexedDB)
Make every read and write go through an IndexedDB queue and render the UI from local state — so the app works with DevTools set to Offline and a write survives a reload. The queue is an append log of `{id, op: 'create'|'update'|'delete', payload, local_updated_at, status: 'pending'|'synced'}` keyed by note id; reads merge the pending queue over the last-synced snapshot so the UI is always optimistic. The critical invariant is durability: a write made offline is written to IndexedDB synchronously (inside the same tick that updates the UI) and is still queued after a page reload — if it lives only in memory, a reload before reconnect loses it. Prove it: go offline, create/edit a note, reload the page while still offline, and show the note is still there and still pending; then go online and show it syncs.
Definition of done- All reads/writes go through an IndexedDB queue; the UI renders from local state and works with DevTools Offline.
- A write made offline survives a page reload (still queued, not lost) and the pending count is visible in the UI — verified with an offline → write → reload → still-pending test.
Feeds fromSelf-review
Show offline → write → reload → still-pending, then online → synced. A senior reviewer checks the queue is in IndexedDB (not memory) and that reads merge pending over the last-synced snapshot.
- 02Service-worker caching (SWR vs cache-first)
Add a service worker with differentiated caching strategies and a correct update lifecycle. The app shell (HTML) uses stale-while-revalidate — always fast (serve from cache immediately, update in the background) with a one-visit staleness window that is acceptable for a shell. Versioned static assets (JS/CSS with content-hash filenames) use cache-first — they are truly immutable, so serve from cache with no revalidation. A new deploy must update the cached shell on the next visit without a hard refresh: the new worker installs, waits in 'waiting' until all tabs unload (or skipWaiting is called), then activates and claims clients. Document the tradeoff: waiting until tabs close means 'stale until restart' but no mid-session inconsistency; skipWaiting means immediate activation but risks serving the new shell against the old asset set. State the stale-content window for each strategy and why the lifecycle makes update timing non-obvious.
Definition of done- The app shell loads from the service worker offline; shell uses stale-while-revalidate, hashed static assets use cache-first — verified offline with DevTools and cache storage inspection.
- A new deploy updates the cached shell on the next visit without a hard refresh; the lifecycle (install → waiting → activate) and skipWaiting tradeoff are documented with stale-window numbers.
Feeds fromSelf-review
Show the shell loading offline from the SW and a new deploy updating on next visit (cache storage before/after). A senior reviewer checks SWR vs cache-first are differentiated, the lifecycle is correct, and the skipWaiting tradeoff is stated.
- 03Sync with LWW on reconnect
Flush the write queue on reconnect, resolve conflicts with last-writer-wins, and register a Background Sync tag as a fallback for missed flushes. On reconnect (online event + visibilitychange + Background Sync), iterate the pending queue in order and POST each op to the server; the server compares `server_updated_at` vs `local_updated_at` and keeps the later one (LWW). No edit is silently lost — the losing version is at least logged and surfaced in the UI as a conflict. Register a Background Sync tag (`sync-pending-writes`) on every write so the browser fires the sync event even if the tab was closed at reconnect time. Then address the browser-support caveat: Safari's partial Background Sync support means the queue must also flush on page-visible and online events as a fallback. Document where whole-document LWW fails: two users editing different fields of the same note — the later flush clobbers the other's field change because the merge key is document + timestamp, not field + timestamp.
Definition of done- On reconnect the queue flushes in order; conflicts resolve LWW by comparing server vs local updated_at, losing versions are logged/surfaced, and no edit is silently lost — verified with a concurrent-edit conflict fixture.
- A Background Sync tag is registered on write and flushes the queue even if the tab was closed at reconnect time; fallback flush on online/visibilitychange is implemented and the Safari caveat is documented.
Feeds fromSelf-review
Show a conflict fixture (concurrent edit) resolving LWW with the loser logged, and a Background Sync tag registration. A senior reviewer checks the flush is ordered, LWW compares updated_at, and the per-field vs document LWW failure is explained.
- 04Optimistic UI and sync status
Make the offline experience feel instant and legible. Every local write updates the UI immediately (optimistic) before the sync confirms it — no spinner on create/edit/delete. The sync status is always visible: a persistent indicator shows 'All synced' / 'Syncing…' / 'Offline — N pending' / 'Conflict on note X', and each note shows its sync state (synced vs pending). A pending queue that grows while offline is shown with a count, and a conflict resolved via LWW surfaces which version won and why (timestamp comparison). Measure the perceived latency: with optimistic UI, create-to-visible is <16 ms (one frame); without it, it's network RTT + server time. Prove it by toggling optimistic off and showing the delay, and by going offline, queuing 5 edits, and showing all 5 appear instantly with correct status transitions as they sync on reconnect.
Definition of done- Local writes are optimistic (UI updates before sync confirms); sync status indicator shows All synced / Syncing / Offline N pending / Conflict and each note shows its sync state.
- Queuing 5 edits offline shows all 5 instantly with pending badges; on reconnect they transition to synced/conflict with the winner explained by timestamp — verified with an offline-batch test.
Feeds fromSelf-review
Show optimistic vs non-optimistic perceived latency and 5 offline edits with status transitions on reconnect. A senior reviewer checks the queue count is live, conflicts surface the winning timestamp, and the UI is usable offline with no spinner on write.
- 05Observe sync and work an incident
Make sync observable and prove it survives an incident. Emit metrics for the offline subsystem: pending queue depth, flush success/failure rate, sync duration p50/p99, conflict rate, and service-worker cache hit rate. Show them on a small dashboard or console panel. Then work an incident: simulate a flaky network (intermittent failures on the flush endpoint) mid-sync and watch the queue stall — pending depth stops draining, retry backoff kicks in, conflicts accumulate if the server mutated the same notes. Detect it from your dashboard (pending depth flat, failure rate up), mitigate (retry with backoff, surface conflicts to the user, don't duplicate-queue), and write a 5-line post-mortem whose prevention is not 'never go offline'. Document the Background Sync failure modes: the browser fires sync when it believes the network is available but doesn't guarantee delivery time; a failed sync re-queues with browser-controlled exponential backoff; if the browser kills the SW before sync completes, the tag stays registered and retries — but only where Background Sync is supported.
Definition of done- A dashboard/panel shows pending depth, flush success/failure rate, sync p50/p99, and SW cache hit rate; a flaky-network incident is reproduced, detected from the dashboard, and mitigated with backoff without duplicating the queue.
- A post-mortem names root cause and a prevention that isn't 'never go offline' or 'disable Background Sync', and Background Sync failure modes are documented.
Feeds fromSelf-review
Paste the dashboard and post-mortem for the flaky-network incident. A senior reviewer checks pending depth + flush rate localized the stall, backoff is exponential (not tight loop), and Background Sync failure modes are stated.
Starter
fallowlone/skein-projects
projects/offline-pwa-sync
- README.md
- src/sync.ts
- test/sync.test.ts
npx degit fallowlone/skein-projects/projects/offline-pwa-sync offline-pwa-sync Implement the stubs, then run the tests until they pass: bun test
Fork the repo and push your work — the grade workflow runs the suite plus static checks on your own runners.
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 (field + timestamp as merge key).
- Add an undo stack that works across offline/online transitions without corrupting the sync queue — undo must not generate a new pending op that conflicts with itself.
- Add end-to-end encryption for the notes so the server never sees plaintext and conflicts are resolved on encrypted payloads by timestamp alone.