Quickstart

Test a service worker offline in 10 minutes, without flaky timers

A recipe for Playwright tests that actually prove your service worker serves cached responses when the network dies. Seven steps, runnable TypeScript, and the four gotchas that burn most teams on the first try.

Before You Start

  • Node 20 or newer and a Playwright project (any bundler is fine)
  • An app served over HTTPS in production or localhost in dev (SWs refuse to register on other origins)
  • A service worker file at the root of the scope you want to cover, usually /sw.js
  • A way to run the dev server headlessly in CI (next dev, vite, whatever)
  • Chromium in your Playwright install (Firefox and WebKit handle SW lifecycle differently; start with Chromium)

1. Write a service worker that actually caches something

You cannot test offline behavior if the SW does not cache anything offline-worthy. Start with the smallest SW that does cache-first for static assets and network-first with a cache fallback for the HTML shell. Save this at public/sw.js (Next.js, Vite, CRA all serve the public/ folder at the root, which matters for scope in step 2).

public/sw.js

2. Register it from the page and pin the scope

SW scope is decided by the path of the SW file, not by the page that registers it. A worker at /static/sw.js can only control pages under /static/. Put the worker at the root of whatever origin-prefix you want it to cover, and register it explicitly so the intent is visible in tests.

src/register-sw.ts

Call registerServiceWorker() on page load. In Next.js app router that means a small client component inside the root layout. In plain HTML, a <script type="module"> tag near the end of the body works.

3. Prove the worker installed before you cut the network

This is where most offline tests fail silently. Playwright can go offline faster than the browser can register the worker, and then your "offline" fetch hits the net because the SW never got a chance to attach a fetch listener. Wait for navigator.serviceWorker.ready inside the page, not from the test runner.

tests/offline.spec.ts

4. Cut the network and assert the cached response

Playwright has two ways to fake offline: context.setOffline(true) and page.route() with an abort handler. Use setOffline. Route interception runs in the node side and does not fire for requests the SW handles internally, which means you will get false positives.

tests/offline.spec.ts
Running the offline suite

5. Test cache invalidation without reloading the world

A cache that never evicts is a memory leak in slow motion. The activate handler in step 1 deletes old caches by name, but you have to verify that actually happens when a new SW version ships. Simulate an update by messaging the worker and then checking the cache keys.

tests/cache-invalidation.spec.ts

Wire this into CI once, forget about it

Assrt runs offline-mode checks like these on every PR, with screenshots and a diff when the SW drops a precache URL.

Get Started

6. Cover the stale-while-revalidate path

Stale-while-revalidate (SWR) is the most common caching pattern after cache-first, and it has a sneaky failure mode: the background revalidation silently errors out and the cache goes stale forever. Test both halves: the immediate cached response and the updated response on the next request.

public/sw.js (SWR handler)
tests/swr.spec.ts

7. Clean up so the next test does not inherit your cache

Playwright reuses browser contexts aggressively in parallel mode. A SW registered in one test will still be there in the next one, serving half-stale responses that make failures look random. Unregister and clear caches in an afterEach hook.

tests/offline.spec.ts (teardown)

Four gotchas that will burn an hour each

These are the failure modes I have hit in production test suites. None of them are in the MDN docs in any single place, so write them down somewhere your team will find them.

setOffline does not affect SW-handled fetches until the first network call. If your SW returns cached HTML with an inline <img> and that image was not precached, the image request goes to the network first, fails, and you see a broken image even though the page rendered. Precache every subresource you reference from offline pages, or add an image fallback branch in the fetch handler.

navigator.serviceWorker.ready hangs forever in about:blank contexts. If your test calls page.goto() on a URL that 404s, Chromium keeps the page on about:blank and the ready promise never resolves. Always assert the response status from page.goto before waiting on the SW.

skipWaiting plus clients.claim can race the first test assertion. A fresh SW goes install, waiting, activated. With skipWaiting the waiting step is skipped, but the page you loaded still has no controller until activate finishes and clients.claim() resolves. A quick assertion on navigator.serviceWorker.controller can return null even though the SW is active. Wait for both ready AND controller, as shown in step 3.

Safari evicts caches under storage pressure with no warning. WebKit's intelligent storage protection will clear Cache Storage for any site the user has not visited in 7 days, and it is more aggressive in private browsing. Do not trust your Chrome offline tests to cover Safari, run the same suite against the webkit Playwright project at least once in CI, and add a fallback path that re-hydrates missing caches on the next online visit.

Where to go next

Once the seven steps above are green, add a test that simulates a flaky network (use context.route to inject a random 500 response on 20 percent of calls) and confirm the SW absorbs the failures. After that, wire the whole file into your CI pipeline as its own Playwright project, separate from your main E2E suite, so a broken cache does not block feature tests.

Assrt runs tests like these automatically in CI, generated from plain English scenarios. See assrt.ai.