Offline Support for Recipe Web App
Summary
Key decisions
| Decision | Choice | Why |
|---|---|---|
| SW tooling | Workbox (via workbox-build/bundler plugin) rather than hand-rolled SW |
Precache manifest with content hashes, battle-tested strategies, expiration plugins; hand-rolled SWs are the #1 source of stale-app bugs |
| App shell strategy | Precache (cache-first, versioned by build hash) | HTML/JS/CSS/icons are known at build time; instant loads and guaranteed offline start |
| Recipe API data | stale-while-revalidate for lists/search; network-first (3s timeout, cache fallback) for a single recipe detail |
Lists tolerate slight staleness and should feel instant; a recipe you are about to cook should be fresh when the network allows |
| Recipe images | cache-first with expiration (max ~150 entries / 30 days, LRU via Workbox ExpirationPlugin) |
Images are immutable and heavy; cap prevents unbounded storage growth on phones |
| Structured data store | IndexedDB (via the small idb wrapper), not Cache API JSON blobs |
Enables offline search/filter by ingredient or title, partial updates, and an outbox for offline writes |
| Offline writes (favorites, notes, ratings) | Outbox pattern + Background Sync API, fallback to replay-on-reconnect (online event) where Sync is unsupported |
Sync API is Chromium-only; the outbox works everywhere, Sync is progressive enhancement |
| SW update policy | New SW waits; app shows a "New version — Refresh" toast that calls skipWaiting() |
Silent skipWaiting mid-session can mix old pages with new assets; explicit refresh is predictable |
| Scope | Offline = read everything cached + queue small writes. No offline recipe creation in v1 | Recipe creation involves image upload and conflict-prone editing; defer until the outbox is proven |
Phases
Phase 1 — Service worker + app-shell precache S
Register a Workbox-generated SW that precaches the app shell (HTML, JS/CSS bundles, fonts, icons, an offline.html fallback). Confirm the app boots with the network disabled. Prerequisites: HTTPS everywhere, web app manifest present.
- Add Workbox to the build (e.g.
workbox-webpack-plugin/vite-plugin-pwadepending on the bundler). - Register in app entry:
navigator.serviceWorker.register('/sw.js')behind a feature check, afterload. - Navigation fallback to the cached shell for SPA routes;
offline.htmlfor anything unhandled. - Kill switch: keep a trivial
sw.jsthat self-unregisters, deployable if a bad SW ships.
// sw.js (Workbox, injectManifest mode)
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching';
import { registerRoute, NavigationRoute } from 'workbox-routing';
precacheAndRoute(self.__WB_MANIFEST);
registerRoute(new NavigationRoute(createHandlerBoundToURL('/index.html')));
Done when: DevTools offline mode → app loads to its home screen; Lighthouse PWA "works offline" check passes.
Phase 2 — Runtime caching for recipe reads M
Cache API responses and images so any recipe the user has viewed is readable offline.
GET /api/recipes?…(lists, search):StaleWhileRevalidate, cache nameapi-lists, max ~50 entries / 24h.GET /api/recipes/:id:NetworkFirstwithnetworkTimeoutSeconds: 3, cacheapi-recipe.- Recipe images:
CacheFirst, cacheimages,ExpirationPlugin({ maxEntries: 150, maxAgeSeconds: 30*86400, purgeOnQuotaError: true }). - Never cache authenticated user-profile endpoints or non-GET requests here.
registerRoute(
({url}) => url.pathname.startsWith('/api/recipes/'),
new NetworkFirst({ cacheName: 'api-recipe', networkTimeoutSeconds: 3 })
);
Done when: view a recipe online, go offline, reopen it — text and images render.
Phase 3 — IndexedDB recipe store + offline UX states M
Mirror recipe JSON into IndexedDB so the app can search/filter offline and render proper UI states instead of spinners.
- Schema:
recipes(keyPathid, indexes ontitle,ingredients,updatedAt),meta(last sync time),outbox(Phase 5). - Data layer reads: IndexedDB-first render, network revalidate, write-through on fetch success (single
getRecipe(id)function so components don't know about caching). - UI: global offline banner driven by
navigator.onLine+ fetch failures; "cached · updated 2h ago" timestamp on recipe pages; distinguish "offline and not cached" from "not found".
Done when: offline search over previously synced recipes returns results; uncached recipe shows a friendly offline message.
Phase 4 — Explicit "Save for offline" M
Let the user pin recipes (or a whole collection like "This week's meal plan") for guaranteed offline availability, not just what they happened to browse.
- Download button on recipe page → fetch full recipe JSON + all images, store JSON in IndexedDB and images in a dedicated
saved-imagescache exempt from LRU expiration. - Show per-recipe saved state and total storage used (
navigator.storage.estimate()). - Request persistent storage:
navigator.storage.persist()after first save, so the browser won't evict under pressure. - "Manage offline recipes" list with per-item delete.
Done when: saved recipe survives cache eviction test (clear runtime caches, keep saved cache) and opens fully offline.
Phase 5 — Offline writes: outbox + background sync L
Queue small mutations (favorite, rating, cooking notes, shopping-list ticks) made offline and replay them when connectivity returns.
- Write path: optimistic local update in IndexedDB → append
{id, method, url, body, createdAt}tooutbox→ attempt network. - On failure, register Background Sync tag
outbox-flush; fallback replay ononlineevent and on app start. - Idempotency: client-generated operation UUIDs; server ignores duplicates (requires small API change — coordinate).
- Conflicts: last-write-wins for favorites/ratings; notes carry
baseUpdatedAtand surface a "keep mine / keep server" prompt on mismatch. - UI: pending-sync indicator ("2 changes waiting"), clears on flush.
Done when: favorite a recipe in airplane mode, kill the tab, reconnect — change reaches the server exactly once.
Phase 6 — Update UX, telemetry, tests S
Make the SW lifecycle safe to operate long-term.
- Update toast: listen for
waitingSW, offer "Refresh", postSKIP_WAITINGmessage, reload oncontrollerchange. - Cache versioning: bump cache-name prefix on breaking schema changes; delete stale caches in
activate. - Telemetry: SW install/activate errors, cache hit rate, outbox flush failures.
- Tests: Playwright with
context.setOffline(true)covering — cold offline start, offline recipe read, save-for-offline, outbox replay, SW update flow. Run in CI.
Done when: CI offline suite green; deploying a new version shows the refresh toast on an open session.
Risks
| Risk | Severity | Mitigation |
|---|---|---|
| Stale app "zombie" — users stuck on an old cached version | high | Workbox hashed precache + waiting-SW refresh toast (Phase 6); kill-switch SW ready from day one (Phase 1) |
| Caching private/authenticated responses and serving them to the wrong session | high | Only cache explicitly allow-listed GET routes; clear IndexedDB + user caches on logout; never cache Set-Cookie-bearing responses |
| Storage quota exhaustion / eviction on low-end phones (images) | med | ExpirationPlugin caps + purgeOnQuotaError; storage.persist() for explicitly saved recipes; storage-used UI |
| Duplicate or lost writes when the outbox replays | med | Idempotency keys per operation; server-side dedupe; replay only removes an entry after 2xx |
| Background Sync unsupported (Safari/Firefox) | med | Outbox is the source of truth; replay on online + app start works everywhere; Sync is enhancement only |
| Safari/iOS quirks: ~50MB-ish cache pressure, 7-day storage eviction for rarely-used web apps | med | Keep saved payloads lean (compress JSON, responsive image sizes); encourage Add-to-Home-Screen (exempts from 7-day cap); test on real iOS device |
| SW breaks existing analytics/streaming/range requests (e.g. video) | low | Route allow-list only — unmatched requests pass through untouched |
Open questions
- Which bundler/framework is the app on? Determines Workbox integration (
vite-plugin-pwavsworkbox-webpack-pluginvs standaloneworkbox-build). - Are recipes user-private, or public content? Changes how aggressive shared caching can be and what must be wiped on logout.
- Can the API add idempotency-key support for Phase 5 writes, and is there appetite for the small server change?
- Rough recipe payload sizes and image weights — do we need server-side resized image variants before Phase 4 pinning is viable on mobile data?
- Is offline recipe creation/editing (full authoring) wanted later? If yes, plan a v2 with proper conflict resolution now so the schema doesn't fight us.
- PWA install prompt / Add-to-Home-Screen: in scope for this effort or separate?