Author service worker cache strategies (cache-first, network-first, stale-while-revalidate, cache-only, network-only) per Workbox conventions, plus generate the matching Playwright assertions to lock the strategy in. Avoids the common "cached forever" pitfall by enforcing TTL + version-bump invalidation. Use when a new route needs defined offline behavior, when a hand-rolled `sw.js` is being moved to Workbox strategies, or when users report seeing stale content after a deploy.
76
96%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Authors strategy code (Workbox-compatible) + the matching
service-worker-tests assertions in one workflow. Pairs with the
service-worker-tests skill (testing) and pwa-install-flow-tests
skill (install-time prerequisites).
| Route type | Strategy | Why |
|---|---|---|
Static immutable (/_next/static/, hashed filenames) | CacheFirst with long TTL | Filename change = cache key change; safe forever |
HTML shell (/, /about) | NetworkFirst with timeout fallback | Always try network for fresh content; fallback to cache offline |
API responses (/api/...) | StaleWhileRevalidate | Show cached now; refresh in background |
User-specific data (/api/user/me) | NetworkOnly | Privacy; never cache |
| Manifest, robots.txt | NetworkOnly | Always reflect deploy state |
Images (/img/*) | CacheFirst with TTL | Bandwidth win; expire weekly |
| 3rd-party fonts | CacheFirst with long TTL | License-permitting |
// sw.js
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import {
CacheFirst,
NetworkFirst,
StaleWhileRevalidate,
NetworkOnly,
} from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
const SW_VERSION = 'v3';
precacheAndRoute(self.__WB_MANIFEST);
// Static immutable
registerRoute(
({ url }) => url.pathname.startsWith('/_next/static/'),
new CacheFirst({
cacheName: `static-${SW_VERSION}`,
plugins: [
new ExpirationPlugin({ maxAgeSeconds: 60 * 60 * 24 * 365 }),
],
})
);
// HTML shell
registerRoute(
({ request }) => request.destination === 'document',
new NetworkFirst({
cacheName: `html-${SW_VERSION}`,
networkTimeoutSeconds: 3,
plugins: [
new CacheableResponsePlugin({ statuses: [200] }),
],
})
);
// API
registerRoute(
({ url }) => url.pathname.startsWith('/api/') && !url.pathname.startsWith('/api/user/'),
new StaleWhileRevalidate({
cacheName: `api-${SW_VERSION}`,
plugins: [
new CacheableResponsePlugin({ statuses: [200] }),
new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 60 * 5 }),
],
})
);
// User data - never cache
registerRoute(
({ url }) => url.pathname.startsWith('/api/user/'),
new NetworkOnly()
);
// Cleanup old SW versions on activate
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((k) => !k.endsWith(`-${SW_VERSION}`))
.map((k) => caches.delete(k))
)
)
);
});For each registered route, emit one test asserting the strategy behavior. Pattern:
// Tests for sw.js routes - paired with sw-cache-strategy-author Step 2
import { test, expect } from '@playwright/test';
test.describe('SW cache strategies', () => {
test('static assets: cache-first (offline still serves)', async ({ page, context }) => {
await page.goto('https://localhost:3000');
await page.waitForLoadState('networkidle');
await context.setOffline(true);
const resp = await page.evaluate(() =>
fetch('/_next/static/abc123.css').then(r => r.status)
);
expect(resp).toBe(200);
});
test('HTML shell: network-first (fresh while online)', async ({ page }) => {
let networkHits = 0;
page.on('request', req => {
if (req.url().endsWith('/about') && req.resourceType() === 'document') {
networkHits++;
}
});
await page.goto('https://localhost:3000/about');
await page.goto('https://localhost:3000/');
await page.goto('https://localhost:3000/about');
expect(networkHits).toBeGreaterThanOrEqual(2);
});
test('user API: never cached', async ({ page, context }) => {
await page.goto('https://localhost:3000');
await page.evaluate(() => fetch('/api/user/me'));
await page.waitForTimeout(100);
let [sw] = context.serviceWorkers();
const userCache = await sw.evaluate(async () => {
const cache = await caches.open('api-v3');
const reqs = await cache.keys();
return reqs.map(r => r.url);
});
expect(userCache).not.toContain(expect.stringContaining('/api/user/'));
});
test('SW v3 deletes v2 caches on activate', async ({ context, page }) => {
await page.goto('https://localhost:3000');
let [sw] = context.serviceWorkers();
const cacheNames = await sw.evaluate(() => caches.keys());
expect(cacheNames.every((n: string) => n.endsWith('-v3'))).toBe(true);
});
});For each caches.match / event.respondWith block in an existing
SW, classify:
| Existing pattern | Likely category | Migration |
|---|---|---|
caches.match(req).then(r => r ?? fetch(req)) | CacheFirst (no TTL) | Add ExpirationPlugin |
fetch(req).catch(() => caches.match(req)) | NetworkFirst (no timeout) | Add networkTimeoutSeconds |
caches.match(req) only | CacheOnly (dangerous for HTML) | Verify intentional |
| Hand-rolled SWR (parallel fetch + cache.put) | StaleWhileRevalidate | Replace with Workbox class |
Bumping SW_VERSION is the most reliable invalidation, but breaks
revalidation for users with stale tabs. Pair with:
skipWaiting() only after user opt-in (banner: "New
version available - refresh").networkTimeoutSeconds: 3
over CacheFirst (so users see updated UI as soon as network allows).| Anti-pattern | Why it fails | Fix |
|---|---|---|
| CacheFirst without TTL | Stale forever; users on stale UI for weeks | Always ExpirationPlugin (Step 2) |
| Cache POST/PUT/DELETE responses | Side effects replayed; data corruption | Strategies only match GET by default; verify in fetch handler |
Cache Set-Cookie responses | Cross-user cookie leak | CacheableResponsePlugin({ statuses: [200] }) excludes; never cache user-specific |
| Auto skipWaiting on every deploy | Users mid-form lose state | Require user opt-in (Step 5) |
| One cache name "app-cache" forever | Old assets stay forever | Version per release (Step 2 SW_VERSION) |
service-worker-tests -
sister skill providing Playwright SW lifecycle test patterns