CtrlK
BlogDocsLog inGet started
Tessl Logo

testland/workbox-tests

Test Workbox-built service workers - pin behavior of the named recipes (`pageCache`, `staticResourceCache`, `imageCache`, `googleFontsCache`, `offlineFallback`, `warmStrategyCache`) per [developer.chrome.com/docs/workbox/modules/workbox-recipes][wb-recipes]; validate `workbox-precaching` manifest injection (`__WB_MANIFEST` revisioning); assert `workbox-routing` route handler matches; assert `workbox-expiration` and `workbox-cacheable-response` plugin gates; and verify the `workbox-window` registration helper events (`installed`, `waiting`, `controlling`, `activated`). Cache-strategy design (which strategy per route type, TTL + version-bump invalidation, migrating a hand-rolled sw.js to Workbox) lives in references/cache-strategy-design.md. Use when a project ships (or is adopting) a Workbox service worker and its strategy choices or recipe behavior need pinning against refactors or a version upgrade.

72

Quality

90%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Passed

No findings from the security scan

Overview
Quality
Evals
Security
Files

cache-strategy-design.mdreferences/

Choosing a cache strategy

Design-side reference for workbox-tests: pick a cache strategy per route type (cache-first, network-first, stale-while-revalidate, cache-only, network-only) per Workbox conventions, author the strategy code, and generate the matching Playwright assertions to lock it in. Avoids the common "cached forever" pitfall by enforcing TTL + version-bump invalidation.

When to use

  • Designing offline behavior for a new PWA route.
  • Migrating from a hand-rolled SW to Workbox-style strategies.
  • Auditing an existing SW that "caches everything forever" - pick strategies per route type with TTL + invalidation.

Step 1 - Pick strategy per route type

Route typeStrategyWhy
Static immutable (/_next/static/, hashed filenames)CacheFirst with long TTLFilename change = cache key change; safe forever
HTML shell (/, /about)NetworkFirst with timeout fallbackAlways try network for fresh content; fallback to cache offline
API responses (/api/...)StaleWhileRevalidateShow cached now; refresh in background
User-specific data (/api/user/me)NetworkOnlyPrivacy; never cache
Manifest, robots.txtNetworkOnlyAlways reflect deploy state
Images (/img/*)CacheFirst with TTLBandwidth win; expire weekly
3rd-party fontsCacheFirst with long TTLLicense-permitting

Step 2 - Author Workbox-style strategy

// 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))
      )
    )
  );
});

Step 3 - Generate matching Playwright tests

For each registered route, emit one test asserting the strategy behavior. Pattern:

// Tests for sw.js routes - paired with the Step 2 strategy code above
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);
  });
});

Step 4 - Audit existing SW

For each caches.match / event.respondWith block in an existing SW, classify:

Existing patternLikely categoryMigration
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) onlyCacheOnly (dangerous for HTML)Verify intentional
Hand-rolled SWR (parallel fetch + cache.put)StaleWhileRevalidateReplace with Workbox class

Step 5 - Lock invalidation strategy

Bumping SW_VERSION is the most reliable invalidation, but breaks revalidation for users with stale tabs. Pair with:

  • Trigger skipWaiting() only after user opt-in (banner: "New version available - refresh").
  • For HTML shell, prefer NetworkFirst with networkTimeoutSeconds: 3 over CacheFirst (so users see updated UI as soon as network allows).

Anti-patterns

Anti-patternWhy it failsFix
CacheFirst without TTLStale forever; users on stale UI for weeksAlways ExpirationPlugin (Step 2)
Cache POST/PUT/DELETE responsesSide effects replayed; data corruptionStrategies only match GET by default; verify in fetch handler
Cache Set-Cookie responsesCross-user cookie leakCacheableResponsePlugin({ statuses: [200] }) excludes; never cache user-specific
Auto skipWaiting on every deployUsers mid-form lose stateRequire user opt-in (Step 5)
One cache name "app-cache" foreverOld assets stay foreverVersion per release (Step 2 SW_VERSION)

Limitations

  • Workbox v7+ requires bundling (Vite/webpack/Rollup); CDN-served SW patterns are limited.
  • Some browsers (Firefox) have stricter SW cache quota; test the worst-case browser for your audience.
  • This reference is JS/TS-first; no equivalent for Dart/Flutter PWA SWs.

References

  • Workbox docs - authoritative API + plugin reference (consult for current ExpirationPlugin / CacheableResponsePlugin signatures)
  • service-worker-lifecycle-tests - sister skill providing the SW state-machine spec plus the general Playwright SW harness in its references/

SKILL.md

tile.json