Offline Support for Recipe Web App

2026-07-06 · recipe web app · generated by Claude

Summary

Add offline support so a user can open the app with no network and still browse, search, and cook from previously viewed (or explicitly saved) recipes. The core is a service worker that precaches the app shell and applies per-route runtime caching strategies, plus IndexedDB for structured recipe data and user edits made offline. Work is split into six phases, each shippable on its own: app-shell caching first (biggest win, lowest risk), then read-only recipe caching, then an explicit "save for offline" feature, then offline writes with background sync, and finally an update/refresh UX and automated tests.

Key decisions

DecisionChoiceWhy
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-pwa depending on the bundler).
  • Register in app entry: navigator.serviceWorker.register('/sw.js') behind a feature check, after load.
  • Navigation fallback to the cached shell for SPA routes; offline.html for anything unhandled.
  • Kill switch: keep a trivial sw.js that 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 name api-lists, max ~50 entries / 24h.
  • GET /api/recipes/:id: NetworkFirst with networkTimeoutSeconds: 3, cache api-recipe.
  • Recipe images: CacheFirst, cache images, 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 (keyPath id, indexes on title, 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-images cache 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} to outbox → attempt network.
  • On failure, register Background Sync tag outbox-flush; fallback replay on online event 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 baseUpdatedAt and 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 waiting SW, offer "Refresh", post SKIP_WAITING message, reload on controllerchange.
  • 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

RiskSeverityMitigation
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