Overview
Offline behavior fragments into eight named recipes per Jake Archibald's offline cookbook off-cookbook (opens in new window) - and most PWAs use three or four of them across different routes. A test suite that asserts "the site works offline" misses the per-route recipe contract: a route on Cache only must serve from cache even when network is fine; a route on Network falling back to cache must prefer network when both are present.
This skill emits the per-route test suite. The output is a Playwright spec file plus a coverage YAML that maps each URL pattern to its recipe and its assertion. The suite is distinct from the strategy authoring covered by workbox-tests (references/cache-strategy-design.md); that side authors the strategy, this builder generates the verification.
Composes with:
When to use
Workflow
Step 1 - Inventory routes and pick a recipe per route
For each URL pattern the SW intercepts, decide which of the eight named recipes per off-cookbook (opens in new window) applies. The cookbook recipes verbatim:
| Recipe (per off-cookbook (opens in new window)) | When to use (quoted) |
|---|---|
| Cache only | "Use for static assets you've cached during installation that your site depends on." |
| Network only | "Best for requests without offline equivalents, like analytics or non-GET operations." |
| Cache, falling back to network | "Ideal for building offline-first apps where cached content serves as the default." |
| Cache and network race | "Useful when disk access is slow and network speed might win the competition for small assets." |
| Network falling back to cache | "Good for frequently updating resources, though slow connections create user frustration." |
| Cache then network | "Perfect for content that changes often; displays cached data immediately, updates when fresh data arrives." |
| Generic fallback | "Provides a default response when both cache and network requests fail for secondary content." |
| Service worker-side templating | "Combines cached templates with JSON data to render pages the service worker controls." |
Workbox layers a named composite recipe on top per wb-recipes (opens in new window):
Capture decisions in a YAML manifest:
# tests/offline-recipe-matrix.yaml
routes:
- pattern: /_next/static/**
recipe: Cache only
reason: Hashed filenames, immutable
- pattern: /
recipe: Network falling back to cache
reason: HTML shell - fresh while online, stale offline
- pattern: /api/feed
recipe: Cache then network
reason: List view; show fast, update behind
- pattern: /api/user/me
recipe: Network only
reason: PII; never cache
- pattern: /img/**
recipe: Cache, falling back to network
reason: Bandwidth save; image lifetime ≥ cache TTL
- pattern: /*
recipe: Generic fallback
reason: Catch-all 404; offline.htmlStep 2 - Emit the per-recipe Playwright test
For each recipe row in Step 1's matrix, emit the matching test - the assertion shape must distinguish "served from cache" vs "served from network." The two poles (cache-always vs network-always) are inline; the rest live in the reference.
Cache only
Serves from cache even with network reachable AND with network blocked:
test('Cache only: /_next/static/x.css serves from cache regardless of network', async ({ page, context }) => {
await page.goto('https://localhost:3000/');
await page.waitForLoadState('networkidle');
// Block network for the static path
await context.route('**/_next/static/**', route => route.abort('failed'));
const res = await page.evaluate(() =>
fetch('/_next/static/x.css').then(r => r.status).catch(() => 0)
);
expect(res).toBe(200);
});Network only
test('Network only: /api/user/me fails when offline (never cached)', async ({ page, context }) => {
await page.goto('https://localhost:3000/');
await context.setOffline(true);
const res = await page.evaluate(() =>
fetch('/api/user/me').then(r => r.status).catch(() => 0)
);
expect(res).toBe(0); // network error; not served from cache
});The remaining six recipe templates - Cache falling back to network, Cache and network race, Network falling back to cache, Cache then network, Generic fallback, and SW-side templating - are in references/recipes.md, each with its assertion-shape note.
Step 3 - Test the underlying storage choice
Per off-data (opens in new window), offline data needs the right storage:
| Storage | Per off-data (opens in new window) | Test posture |
|---|---|---|
| Cache Storage API | "Designed for network resources accessed via URL (HTML, CSS, JavaScript, images, videos, audio)" | Assert caches.has(name) + caches.open(name).keys() returns expected URLs |
| IndexedDB | "Use IndexedDB to store structured data" - "data that needs to be searchable or combinable in a NoSQL-like manner" | Assert IDB schema + key existence via indexedDB.open() |
| Storage Manager | navigator.storage.estimate() returns { quota, usage }; navigator.storage.persist() requests durable storage | Assert persisted() returns true post-request |
Test the Storage Manager persist() request:
test('Storage Manager persist() succeeds for installed PWA', async ({ page }) => {
await page.goto('https://localhost:3000/');
const result = await page.evaluate(async () => {
const persisted = await navigator.storage.persist();
const isPersisted = await navigator.storage.persisted();
return { persisted, isPersisted };
});
expect(result.isPersisted).toBe(true);
});Per off-data (opens in new window): "Chrome permits origins to use up to 60% of total disk space." Tests that assume small caches mask quota-pressure bugs; assert the application stays well below the estimate:
test('cache usage stays under 50MB', async ({ page }) => {
await page.goto('https://localhost:3000/');
await page.waitForLoadState('networkidle');
const est = await page.evaluate(async () => navigator.storage.estimate());
expect(est.usage!).toBeLessThan(50 * 1024 * 1024);
});Step 4 - Test the offline-fallback page itself
Per wb-recipes (opens in new window), offlineFallback() defaults to offline.html. Assert the file is precached + the response shape:
test('offline.html is precached and well-formed', async ({ page, context }) => {
await page.goto('https://localhost:3000/');
await page.waitForLoadState('networkidle');
let [sw] = context.serviceWorkers();
if (!sw) sw = await context.waitForEvent('serviceworker');
const offlinePagePresent = await sw.evaluate(async () => {
for (const name of await caches.keys()) {
const c = await caches.open(name);
const found = await c.match('/offline.html') || await c.match('/_offline');
if (found) return found.status;
}
return null;
});
expect(offlinePagePresent).toBe(200);
});If the project overrides the default pageFallback option, swap the assertion target.
Step 5 - Test the offline → online recovery
A subtle class of bugs: a route correctly serves from cache offline, but doesn't refresh when network returns. Test the transition:
test('cache then network: recovers fresh data when online returns', async ({ page, context }) => {
await page.goto('https://localhost:3000/feed');
await context.setOffline(true);
await page.reload();
// Stale cache served
await context.setOffline(false);
// Trigger refresh
await page.evaluate(() => fetch('/api/feed').then(r => r.json()));
await page.waitForTimeout(500);
// Assert no "offline" banner lingering
await expect(page.locator('[data-testid="offline-banner"]')).not.toBeVisible();
});Step 6 - Emit the coverage matrix and CI gate
Write tests/offline-coverage.yaml mapping each route from Step 1 to its test name and the source cookbook recipe:
matrix:
- route: /_next/static/**
recipe: Cache only
spec: offline-fallback.spec.ts > 'Cache only: /_next/static/*'
source: off-cookbook "Cache only"
- route: /
recipe: Network falling back to cache
spec: offline-fallback.spec.ts > 'Network falling back to cache'
source: off-cookbook "Network falling back to cache"
- route: /api/feed
recipe: Cache then network
spec: offline-fallback.spec.ts > 'Cache then network: /api/feed'
source: off-cookbook "Cache then network"
- route: /api/user/me
recipe: Network only
spec: offline-fallback.spec.ts > 'Network only: /api/user/me'
source: off-cookbook "Network only"
- route: /img/**
recipe: Cache, falling back to network
spec: offline-fallback.spec.ts > 'Cache, falling back to network: /img/*'
source: off-cookbook "Cache, falling back to network"
- route: /*
recipe: Generic fallback
spec: offline-fallback.spec.ts > 'Generic fallback'
source: off-cookbook "Generic fallback"; wb-recipes offlineFallback()
storage:
- check: cache-usage-under-budget
source: off-data Storage Manager estimate()
- check: persisted-storage-granted
source: off-data Storage Manager persist()CI gates on every row having a passing spec.
Worked example: a 4-route news PWA
Applying Step 1's matrix to a news PWA - static -> Cache only, / -> Network falling back to cache, /api/articles -> Cache then network, /api/user/me -> Network only, /* -> Generic fallback - emits a 5-cell Playwright spec that runs in ~15 seconds on Chromium and catches the four most common offline regressions:
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| One "site works offline" smoke test | Per-route recipe regressions invisible | One spec per recipe row per off-cookbook (opens in new window) (Step 2) |
| Test offline by killing the dev server | SW caches still serve from network until setOffline(true) is called | Always context.setOffline(true) (every cell) |
| Skip the offline → online recovery test | Stale data lingers after network returns; users see "offline" banner online | Step 5 |
Pin caches.match('/offline.html') to a hard URL | Workbox pageFallback option overrides; tests false-fail on custom configs | Match by suffix (Step 4) |
| Test only Chromium | Firefox cache behavior + quota differ | Run cross-browser; Firefox storage.persist() UX differs |
Hand-build storage assertions instead of navigator.storage.estimate() | Misses quota-pressure | Step 3 uses the API |
| Assume cached HTML stays fresh forever | TTL plugin (or its absence) gates this; per wb-recipes (opens in new window) imageCache() defaults to 30 days | Assert TTL via imageCache-style entry expiration in workbox-tests |
| Mix recipes without documenting per route | New routes inherit wrong default | The matrix YAML (Step 1) is the source of truth |
Limitations
References
Companion detail for offline-fallback-tests. The two poles (Cache only, Network only) are inline in Step 2; these are the remaining six recipe templates from the offline cookbook. Each recipe's "when to use" is quoted once in the SKILL.md Step 1 table - here only the assertion shape is noted.
Cache, falling back to network
Serves from cache when present, network when not:
test('Cache, falling back to network: /img/x.png serves from cache when present, network when not', async ({ page, context }) => {
// Warm
await page.goto('https://localhost:3000/img/x.png');
await page.waitForLoadState('networkidle');
// Now offline - cache hits
await context.setOffline(true);
const offline = await page.evaluate(() =>
fetch('/img/x.png').then(r => r.status).catch(() => 0)
);
expect(offline).toBe(200);
});Cache and network race
The first responder wins. Assert the call returns under a small-disk-read budget:
test('Cache and network race: response under 100ms when cached', async ({ page }) => {
await page.goto('https://localhost:3000/api/race-cache');
await page.waitForLoadState('networkidle');
const ms = await page.evaluate(async () => {
const t = performance.now();
await fetch('/api/race-cache');
return performance.now() - t;
});
expect(ms).toBeLessThan(100);
});Network falling back to cache
Prefers network when online, falls back to cache when offline:
test('Network falling back to cache: / serves fresh when online, cached when offline', async ({ page, context }) => {
await page.goto('https://localhost:3000/');
await page.waitForLoadState('networkidle');
await context.setOffline(true);
const resp = await page.goto('https://localhost:3000/');
expect(resp?.status()).toBe(200);
await context.setOffline(false);
// When online, network is preferred - track that a request fires
let hits = 0;
page.on('request', req => { if (req.url().endsWith('/')) hits++; });
await page.reload();
expect(hits).toBeGreaterThanOrEqual(1);
});Cache then network
Two responses for one fetch: instant cache, then revalidated network. Assert two paint phases:
test('Cache then network: /api/feed renders cached list first, refreshes on network', async ({ page }) => {
await page.goto('https://localhost:3000/feed');
// Initial render uses cached list
await expect(page.locator('[data-testid="feed-list"]')).toBeVisible();
const initialCount = await page.locator('[data-testid="feed-item"]').count();
// Refresh fetches from network with newer items
await page.evaluate(() => fetch('/api/feed?force-refresh=1'));
await page.waitForTimeout(500);
const updated = await page.locator('[data-testid="feed-item"]').count();
expect(updated).toBeGreaterThanOrEqual(initialCount);
});Generic fallback
Unknown route while offline serves offline.html (Workbox offlineFallback() default):
test('Generic fallback: unknown route + offline serves offline.html', async ({ page, context }) => {
await page.goto('https://localhost:3000/');
await page.waitForLoadState('networkidle');
await context.setOffline(true);
const resp = await page.goto('https://localhost:3000/never-existed');
expect(resp?.status()).toBe(200);
await expect(page.locator('text=/offline|unavailable/i')).toBeVisible();
});Service Worker side templating
Assert the SW synthesizes the response from a cached shell + JSON data:
test('SW-side templating: /reports/123 renders cached shell + JSON data', async ({ page }) => {
// The SW's fetch handler must compose template + data
await page.goto('https://localhost:3000/reports/123');
await expect(page.locator('h1')).toContainText('Report #123');
// Distinct origin proves SW synthesis (not pure network)
const src = await page.evaluate(() => navigator.serviceWorker.controller?.scriptURL);
expect(src).toBeTruthy();
});Source references: