QA How-To
How to Test Service Workers With Playwright (2026)
Learn to test service workers with Playwright using registration, cache, offline, update, request, and cleanup checks in a runnable TypeScript project.
22 min read | 2,786 words
TL;DR
To test service workers with Playwright, use Chromium on localhost, observe the worker through BrowserContext, and synchronize on registration, controller, cache, and response state. Test the lifecycle in layers: registration, cache population, offline fallback, worker requests, updates, and cleanup.
Key Takeaways
- Run service worker tests in Chromium with a secure localhost origin and a persistent browser context only when the scenario requires retained storage.
- Wait for BrowserContext serviceworker events and page-side controller state instead of adding arbitrary sleeps.
- Prove offline behavior by warming the exact cache first, switching the context offline, and asserting both cached and uncached outcomes.
- Observe worker-owned requests at BrowserContext scope because a Page listener does not represent every service worker request.
- Use a versioned cache and skipWaiting plus clients.claim only when that lifecycle policy matches the production application.
- Delete registrations and Cache Storage entries between tests to prevent order-dependent passes.
To test service workers with Playwright, exercise the browser lifecycle rather than checking only that a file exists. A useful suite proves that the worker registers, controls the page, fills the intended cache, serves known resources offline, rejects or falls back for unknown resources, updates safely, and leaves no state that contaminates the next test.
This tutorial builds a small TypeScript project around a local application shell. You will use supported Playwright APIs such as browserContext.waitForEvent('serviceworker'), context.setOffline(), context.on('request'), and page-side Service Worker and Cache Storage APIs. The examples target Chromium because Playwright's service worker inspection and routing support is Chromium-focused. For broader runner fundamentals, keep the Playwright test runner tutorial nearby.
TL;DR
| Risk | Synchronization point | Strong assertion |
|---|---|---|
| Registration fails | context.waitForEvent('serviceworker') |
Worker URL ends in /sw.js |
| First page is uncontrolled | navigator.serviceWorker.ready plus controller state |
Reloaded page has a controller |
| Precache is incomplete | caches.open() and cache.keys() |
Expected URLs are present |
| Offline shell breaks | context.setOffline(true) |
Cached heading remains visible |
| Runtime request bypasses worker | Context request event and worker response | Request has a service worker owner |
| New worker never activates | registration.update() and controller change |
Version marker changes |
| Tests leak browser state | unregister and delete caches | Zero matching registrations and caches |
Use event promises before the action that triggers them. A promise created afterward can miss a fast registration, request, or controller change. Avoid waitForTimeout() for lifecycle coordination because machine speed is unrelated to worker state.
What You Will Build
You will create a minimal app and a focused Playwright suite that can:
- detect registration and confirm that the page is controlled;
- inspect the application shell cache without relying on DevTools;
- warm a runtime cache and prove cached behavior while truly offline;
- distinguish a worker-owned request from a normal page request;
- force an update check and verify the active worker version;
- reset registrations and caches so every test starts deliberately.
The finished suite treats the service worker as a browser component with asynchronous install, activate, fetch, and update phases. That distinction matters because a successful HTTP response for /sw.js does not prove installation, activation, control, or fetch interception.
Prerequisites
Use Node.js 22.x LTS, npm 10.x or newer, and @playwright/test 1.55.x for this project. The examples use TypeScript 5.9.x and the http-server 14.1.1 package. Pinning versions makes the tutorial repeatable; if your repository already uses newer compatible versions, retain its lockfile and verify the APIs against the installed Playwright release.
Create an empty directory, then install the exact development dependencies:
mkdir playwright-service-worker-lab
cd playwright-service-worker-lab
npm init -y
npm install --save-dev @playwright/test@1.55.0 typescript@5.9.2 http-server@14.1.1
npx playwright install chromium
Service workers require a secure context. Browsers treat http://localhost as trustworthy for local development, so the tutorial server is valid without a local TLS certificate. Do not substitute a plain HTTP LAN IP such as http://192.168.1.20; registration can be rejected because that origin is not secure.
Verify the prerequisite: run the following commands and confirm that they print Node 22.x and Playwright 1.55.0.
node --version
npx playwright --version
If browser installation fails in CI, follow the same dependency-installation principles in GitHub Actions for Playwright.
Step 1: Configure a Chromium-Only Test Project
Add scripts to package.json so the local web server and test command have stable names:
{
"scripts": {
"serve": "http-server public -p 4173 -c-1",
"test": "playwright test",
"test:sw": "playwright test tests/service-worker.spec.ts --project=chromium"
},
"devDependencies": {
"@playwright/test": "1.55.0",
"http-server": "14.1.1",
"typescript": "5.9.2"
}
}
Create playwright.config.ts:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: false,
use: {
baseURL: 'http://localhost:4173',
trace: 'retain-on-failure',
},
webServer: {
command: 'npm run serve',
url: 'http://localhost:4173',
reuseExistingServer: !process.env.CI,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
fullyParallel: false is intentional for the learning project. Registrations and Cache Storage belong to an origin, so parallel cases using the same profile can obscure lifecycle reasoning. Playwright normally gives each test an isolated context, and a later step adds explicit cleanup for cases that deliberately reuse state.
Do not set serviceWorkers: 'block'. That option is useful when a worker interferes with network mocks, but it prevents the subject of this suite from running. The default is allow. If your unrelated route stubs behave unexpectedly, review Playwright route fulfill examples and decide whether that test should block workers or test them.
Verify Step 1: ask Playwright to list the tests. No tests exist yet, but configuration loading must succeed without a TypeScript error.
npx playwright test --list
Expected output includes Total: 0 tests in 0 files. A web server is not required for list mode.
Step 2: Build a Versioned Service Worker Fixture
Create public/index.html with an application shell, a runtime-data button, and visible status fields:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Worker Lab</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<h1>Service Worker Lab</h1>
<p id="worker-status">registering</p>
<button id="load-data" type="button">Load profile</button>
<output id="profile">not loaded</output>
<script type="module" src="/app.js"></script>
</body>
</html>
Create public/styles.css:
body { font-family: system-ui, sans-serif; max-width: 42rem; margin: 3rem auto; }
button { display: block; margin: 1rem 0; }
Create public/profile.json:
{ "name": "Ada", "role": "SDET" }
Create public/app.js. It exposes meaningful DOM state instead of making the test reach into implementation variables:
const status = document.querySelector('#worker-status');
const profile = document.querySelector('#profile');
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').then(async () => {
const registration = await navigator.serviceWorker.ready;
status.textContent = registration.active ? 'active' : 'waiting';
}).catch((error) => {
status.textContent = `error: ${error.message}`;
});
}
document.querySelector('#load-data').addEventListener('click', async () => {
try {
const response = await fetch('/profile.json');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
profile.textContent = `${data.name}, ${data.role}`;
} catch {
profile.textContent = 'profile unavailable';
}
});
Finally, create public/sw.js:
const VERSION = 'v1';
const SHELL_CACHE = `worker-lab-shell-${VERSION}`;
const DATA_CACHE = `worker-lab-data-${VERSION}`;
const SHELL = ['/', '/index.html', '/styles.css', '/app.js'];
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(SHELL_CACHE).then((cache) => cache.addAll(SHELL)));
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
const names = await caches.keys();
await Promise.all(names
.filter((name) => name.startsWith('worker-lab-') && ![SHELL_CACHE, DATA_CACHE].includes(name))
.map((name) => caches.delete(name)));
await self.clients.claim();
})());
});
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
if (event.request.method !== 'GET' || url.origin !== self.location.origin) return;
if (url.pathname === '/profile.json') {
event.respondWith((async () => {
const cache = await caches.open(DATA_CACHE);
try {
const response = await fetch(event.request);
if (response.ok) await cache.put(event.request, response.clone());
return response;
} catch {
return (await cache.match(event.request)) || new Response(
JSON.stringify({ error: 'offline' }),
{ status: 503, headers: { 'Content-Type': 'application/json' } },
);
}
})());
return;
}
event.respondWith(caches.match(event.request).then((cached) => cached || fetch(event.request)));
});
waitUntil() extends install or activate until its promise settles. respondWith() assigns the response for a fetch event. response.clone() is necessary because response bodies are streams and cannot be consumed twice.
Verify Step 2: start the server and request both the page and worker script. Stop it with Ctrl+C afterward.
npm run serve
# In a second terminal:
curl --fail http://localhost:4173/
curl --fail http://localhost:4173/sw.js
The first response contains Service Worker Lab; the second contains const VERSION = 'v1'.
Step 3: Test Service Workers With Playwright Registration Checks
Create tests/service-worker.spec.ts and begin with a helper that waits for readiness in the page. The helper reloads if the first navigation has no controller. A newly installed worker usually controls future navigations, while clients.claim() may control the current client as activation completes. Supporting both paths makes the assertion explicit.
import { expect, test, type Page } from '@playwright/test';
async function waitForWorkerControl(page: Page): Promise<void> {
await page.evaluate(async () => {
await navigator.serviceWorker.ready;
});
if (!(await page.evaluate(() => Boolean(navigator.serviceWorker.controller)))) {
await page.reload();
}
await expect.poll(
() => page.evaluate(() => Boolean(navigator.serviceWorker.controller)),
{ message: 'expected the page to be controlled by a service worker' },
).toBe(true);
}
test('registers and controls the application', async ({ context, page }) => {
const workerPromise = context.waitForEvent('serviceworker');
await page.goto('/');
const worker = await workerPromise;
expect(new URL(worker.url()).pathname).toBe('/sw.js');
await waitForWorkerControl(page);
await expect(page.locator('#worker-status')).toHaveText('active');
const state = await page.evaluate(async () => {
const registration = await navigator.serviceWorker.getRegistration();
return registration?.active?.state;
});
expect(state).toBe('activated');
});
The BrowserContext event gives you Playwright's Worker object. Page evaluation then verifies web-platform state that the worker object does not expose, including the registration's active state and the current controller. These are complementary assertions, not duplicates.
Register the event promise before goto(). If you navigate first, registration may finish before the listener exists. expect.poll() repeatedly reads an observable condition and reports a targeted message instead of sleeping for an arbitrary duration.
Verify Step 3: run only the registration case.
npx playwright test tests/service-worker.spec.ts --project=chromium --grep "registers and controls"
Expected output reports 1 passed. If it hangs, inspect the retained trace rather than increasing timeouts. The Playwright debugging guide for senior QA explains which evidence to collect.
Step 4: Inspect the Precache and Its Responses
Add a second test to the same file. This checks the cache through the page's origin rather than opening the application panel manually:
test('precaches the complete application shell', async ({ page }) => {
await page.goto('/');
await waitForWorkerControl(page);
const cachedPaths = await page.evaluate(async () => {
const cache = await caches.open('worker-lab-shell-v1');
const requests = await cache.keys();
return requests.map((request) => new URL(request.url).pathname).sort();
});
expect(cachedPaths).toEqual(['/', '/app.js', '/index.html', '/styles.css']);
const cachedDocument = await page.evaluate(async () => {
const cache = await caches.open('worker-lab-shell-v1');
const response = await cache.match('/index.html');
return response ? { ok: response.ok, text: await response.text() } : null;
});
expect(cachedDocument?.ok).toBe(true);
expect(cachedDocument?.text).toContain('Service Worker Lab');
});
Checking cache names alone is weak because an empty cache can exist. Checking only request URLs can also miss a cached server error or corrupted fixture. This test verifies the complete expected set and reads one representative body. In a production progressive web app, consider validating content types and revisioned asset names as well.
Avoid asserting every framework-generated chunk when filenames contain hashes. Instead, obtain the expected manifest from the build or assert stable categories, such as one document, one stylesheet, and the revision referenced by the HTML. The exact four-item assertion is appropriate here because this fixture deliberately has stable names.
Verify Step 4: select the cache test by title.
npx playwright test tests/service-worker.spec.ts --project=chromium --grep "precaches the complete"
Expected output is 1 passed. A missing /app.js points to an install failure, a wrong URL, or cache.addAll() rejecting the whole operation because one response failed.
Step 5: Test Cached and Uncached Behavior Offline
Offline testing needs two phases. First fetch data online so the runtime strategy stores it. Then switch the entire BrowserContext offline and repeat the user action. Add these tests:
test('serves a warmed runtime response while offline', async ({ context, page }) => {
await page.goto('/');
await waitForWorkerControl(page);
await page.getByRole('button', { name: 'Load profile' }).click();
await expect(page.locator('#profile')).toHaveText('Ada, SDET');
await context.setOffline(true);
await page.getByRole('button', { name: 'Load profile' }).click();
await expect(page.locator('#profile')).toHaveText('Ada, SDET');
await page.reload();
await expect(page.getByRole('heading', { name: 'Service Worker Lab' })).toBeVisible();
});
test('returns the designed fallback for uncached data', async ({ context, page }) => {
await page.goto('/');
await waitForWorkerControl(page);
await context.setOffline(true);
const result = await page.evaluate(async () => {
const response = await fetch(`/profile.json?cold=${crypto.randomUUID()}`);
return { status: response.status, body: await response.json() };
});
expect(result).toEqual({ status: 503, body: { error: 'offline' } });
});
context.setOffline(true) changes network conditions for pages in that context. It is more faithful than aborting one page route because it also affects worker-originated network attempts. Playwright creates a fresh context for the next test, so the offline setting does not leak through the standard fixtures. If a test needs to continue online, call await context.setOffline(false) in a finally block.
The random query makes the cold request distinct from the warmed /profile.json. Cache matching includes the query string by default. The fallback assertion proves the application's explicit 503 contract; it does not merely accept any rejected promise. You can adapt the same principle to a navigation fallback, image placeholder, or queued mutation.
Verify Step 5: run both offline cases.
npx playwright test tests/service-worker.spec.ts --project=chromium --grep "offline|uncached"
Expected output reports 2 passed. If the cold request unexpectedly returns Ada, verify that the request URL really contains a unique query and that your production cache strategy has not set ignoreSearch: true.
Step 6: Observe Requests Owned by the Service Worker
A service worker can issue a network request after handling a page fetch. Listen on BrowserContext, not only Page, because the context sees requests from every page and service worker it owns. Add this test:
test('attributes the profile request to the service worker', async ({ context, page }) => {
await page.goto('/');
await waitForWorkerControl(page);
const requestPromise = context.waitForEvent('request', (request) =>
new URL(request.url()).pathname === '/profile.json' &&
request.serviceWorker() !== null,
);
await page.getByRole('button', { name: 'Load profile' }).click();
const request = await requestPromise;
expect(request.method()).toBe('GET');
expect(request.serviceWorker()?.url()).toContain('/sw.js');
await expect(page.locator('#profile')).toHaveText('Ada, SDET');
});
The page initiates fetch('/profile.json'), the worker intercepts it, and the network-first strategy performs its own fetch. request.serviceWorker() identifies requests whose owner is a service worker. A normal frame-owned request returns null. This lets you prove ownership without inventing headers solely for testing.
Be precise about routing limitations. When service workers are allowed, a page route may not see requests intercepted by the worker. BrowserContext routing can handle service worker requests in Chromium, but a broad mock can accidentally replace the behavior you meant to test. Use real local resources for lifecycle tests. Put isolated API-contract mocks in separate tests, and consider serviceWorkers: 'block' only when the worker itself is out of scope. The trade-offs are covered in mocking third-party APIs in tests.
Verify Step 6: run the ownership assertion.
npx playwright test tests/service-worker.spec.ts --project=chromium --grep "attributes the profile"
Expected output reports 1 passed. If the promise times out but the UI updates, the resource may already have been served from a cache-only strategy, so no worker-owned network request occurred. Clear the data cache or use a unique request for that ownership scenario.
Step 7: Verify Updates and Clean Up State
Real update testing needs two worker versions. Copy public/sw.js to public/sw-v2.js, then change its first line to const VERSION = 'v2';. Keep the cache and fetch logic identical. Add a test-only endpoint choice in public/app.js by replacing the registration line with:
const workerScript = new URLSearchParams(location.search).get('worker') || '/sw.js';
navigator.serviceWorker.register(workerScript).then(async () => {
Now append an update test and a cleanup helper to the spec:
async function clearWorkerState(page: Page): Promise<void> {
await page.goto('/');
await page.evaluate(async () => {
const registrations = await navigator.serviceWorker.getRegistrations();
await Promise.all(registrations.map((registration) => registration.unregister()));
const names = await caches.keys();
await Promise.all(names
.filter((name) => name.startsWith('worker-lab-'))
.map((name) => caches.delete(name)));
});
}
test('activates a new worker and removes old versioned caches', async ({ page }) => {
await page.goto('/?worker=/sw-v2.js');
await waitForWorkerControl(page);
const details = await page.evaluate(async () => {
const registration = await navigator.serviceWorker.getRegistration();
await registration?.update();
const names = await caches.keys();
return {
script: registration?.active?.scriptURL,
names: names.filter((name) => name.startsWith('worker-lab-')).sort(),
};
});
expect(details.script).toContain('/sw-v2.js');
expect(details.names).toEqual(['worker-lab-data-v2', 'worker-lab-shell-v2']);
});
test('can remove all worker-owned state', async ({ page }) => {
await clearWorkerState(page);
const remaining = await page.evaluate(async () => ({
registrations: (await navigator.serviceWorker.getRegistrations()).length,
caches: (await caches.keys()).filter((name) => name.startsWith('worker-lab-')).length,
}));
expect(remaining).toEqual({ registrations: 0, caches: 0 });
});
A production application normally keeps one stable registration URL and changes the bytes returned at that URL. The separate /sw-v2.js fixture makes the active script identity deterministic for this tutorial. For a production-faithful test, have the server switch the contents served at /sw.js, call registration.update(), wait for updatefound, and observe installing.statechange until activation or waiting. Do not enable skipWaiting() blindly: activating a new worker while old tabs use an incompatible shell can mix versions.
unregister() does not delete Cache Storage. That is why cleanup performs both operations. Conversely, deleting caches does not unregister an active worker, which can recreate them on the next request. Scope cleanup by your application's cache prefix so the test does not erase unrelated origin data.
Verify Step 7: run the complete suite from a clean command.
npm run test:sw
Expected output reports 8 passed. The update test has its own isolated context, so it starts without the v1 registration created by earlier cases. The final cleanup case proves the helper rather than assuming browser-context disposal is sufficient for every custom fixture.
How to Test Service Workers With Playwright Without Flaky Timing
Treat lifecycle transitions as state machines. Registration yields a ServiceWorkerRegistration; installation can create an installing worker; a successful install usually becomes waiting when an older active worker still controls clients; activation produces active; and a client gains a controller according to the worker's lifecycle policy. One timeout cannot represent all those transitions.
Use the narrowest observable signal:
- wait for
context.waitForEvent('serviceworker')when you need Playwright's worker object; - await
navigator.serviceWorker.readywhen you need an active registration; - inspect
navigator.serviceWorker.controllerwhen the current document must be controlled; - listen for
controllerchangewhen an activation policy transfers control; - inspect
registration.installing,waiting, andactivewhen testing staged updates; - query Cache Storage only after installation is complete.
Create listeners before the action. This applies to request, response, serviceworker, updatefound, and controllerchange events. Wrap app-level waits in expect.poll() when the state is easy to read repeatedly. Keep test timeouts as safety limits, not synchronization mechanisms.
When a failure appears only in CI, retain a trace, browser console messages, and failed request details. A trace establishes whether the page navigated and which resources loaded, while page evaluation can record registration and cache state. Avoid logging entire cached response bodies because they make reports noisy and may expose data.
Best Practices
- Keep lifecycle tests on local deterministic assets. A third-party outage should not masquerade as an install defect.
- Assert user behavior plus one relevant implementation boundary. Visible offline content proves value; cache inspection explains why it works.
- Give caches a stable application prefix and explicit version. Cleanup and migrations then target only owned data.
- Test a cold cache and a warm cache. They exercise different branches and reveal false confidence from prior state.
- Confirm the negative case. An uncached offline URL should produce the designed fallback rather than a vague browser error.
- Test update compatibility with open clients if your worker does not call
skipWaiting(). A waiting worker may be correct, not stuck. - Separate worker lifecycle tests from broad network-mocking tests. Each suite then has one source of truth for responses.
- Run the service worker project in Chromium and keep cross-browser page behavior in a separate project. This makes coverage claims honest.
- Never make cache names or a forced query parameter part of production UI solely to satisfy a test. Prefer build metadata or a small test server control.
Troubleshooting
Problem: navigator.serviceWorker is undefined -> Confirm the page uses HTTPS or http://localhost, not file://, a plain LAN address, or a disabled browser policy. Also check that the Chromium project did not set serviceWorkers: 'block'.
Problem: context.waitForEvent('serviceworker') times out -> Create the event promise before navigation and ensure the test starts with a fresh context. If a worker is already registered and running, registration may not create a new event; query context.serviceWorkers() or clear the registration before testing first install.
Problem: the worker is active but navigator.serviceWorker.controller is null -> The first document may have loaded before activation. Wait for ready, then wait for controllerchange or reload. Use clients.claim() only if immediate control is part of the intended product policy.
Problem: route mocks do not intercept a request -> The active worker may satisfy it or own its network fetch. Observe at BrowserContext scope, use a worker-aware context route where supported, or block workers in a separate test whose purpose is page-level API mocking. Playwright route abort examples show focused failure simulation.
Problem: offline tests pass alone but fail in the suite -> Verify whether a shared persistent context or user data directory retains caches. Warm the required URL inside the test, generate unique cold URLs, and clear registrations plus prefixed caches in teardown for any reused profile.
Problem: a new worker stays in waiting -> Existing clients may still be controlled by the previous version. Close those clients or explicitly message the waiting worker to activate if that matches the app's update UX. Do not add skipWaiting() merely to make the test green because that changes production semantics.
Interview Questions and Answers
A strong interview explanation distinguishes browser state from HTTP availability. Expect to discuss why BrowserContext observation matters, how cold and warm offline paths differ, why update policies are product decisions, and how you remove origin state. The structured interview answers below provide concise model responses for those topics.
Where To Go Next
Extend the fixture with navigation fallback, background sync feature detection, and an update prompt for waiting workers. Keep each behavior in its own test so a failed cache migration is distinguishable from an offline UX defect.
Next, study Playwright web server configuration examples to make the local fixture deterministic. Use Playwright route continue with override examples when you need a controlled header or URL variation outside the worker lifecycle suite. For real-time caching patterns, compare worker fetch handling with Playwright WebSocket testing, where connection ownership and offline behavior require a different strategy.
You can also practice explaining the lifecycle under interview pressure in the QA practice workspace, or apply the project evidence to your profile through the resume upload dashboard. Describe the result precisely: you tested installation, control, precache integrity, warm and cold offline branches, request ownership, version activation, and state cleanup.
Conclusion
To test service workers with Playwright reliably, synchronize on browser events and web-platform state, not elapsed time. Start with a trustworthy localhost origin, prove the registration and controller, inspect exact cache outcomes, take the context offline, observe worker-owned requests, test the chosen update policy, and remove both registrations and caches.
The runnable project gives you a small reference system whose behavior is visible at every boundary. Adapt its assertions to your production worker's actual strategy instead of copying cache names or activation policy blindly. That produces tests that detect broken offline experiences and unsafe upgrades without turning implementation trivia into brittle coverage.
Interview Questions and Answers
How would you design a Playwright test for service worker registration?
I create the BrowserContext serviceworker event promise before navigation, then assert the worker script URL. In the page, I await `navigator.serviceWorker.ready`, verify the active state, and prove that the current document has a controller. This separates script discovery, activation, and client control.
What makes an offline service worker test trustworthy?
It establishes whether the cache is cold or warm instead of inheriting unknown state. I warm one exact resource online, switch the BrowserContext offline, and verify that resource still works. I also request a unique uncached URL and assert the application's explicit fallback response.
Why should service worker requests be observed on BrowserContext?
A service worker is owned by the browser context rather than one page. Context request events include requests from pages and workers, and `request.serviceWorker()` identifies worker ownership in Chromium. A page-only listener can miss the network operation performed by the worker.
Would you always use skipWaiting in a service worker test fixture?
No. I use it only when immediate activation is the application's intended policy. It simplifies a controlled fixture, but in production it can let a new worker serve assets to an old page. Update tests should represent the real waiting or activation UX.
How do you prevent service worker tests from becoming flaky?
I wait on serviceworker, ready, controllerchange, request, or visible application state rather than sleeping. Event promises are created before their triggering actions. I also isolate browser contexts, control local resources, and clear registrations plus application-prefixed caches when a profile is reused.
How would you validate a service worker cache migration?
I install the old version, create representative old cache data, then deliver the new worker and observe its intended activation state. After activation, I assert that current cache names and required entries exist, obsolete versioned caches are removed, and an open or newly loaded client still functions.
What is the difference between an active worker and a controlling worker?
An active worker has completed activation for its registration. A controller is the worker currently handling a specific document, so a first-loaded page can temporarily have no controller even though the registration is active. I assert both when the feature requires immediate fetch interception.
Frequently Asked Questions
Can Playwright test service workers?
Yes. In Chromium, Playwright can observe service workers at BrowserContext scope, inspect worker-owned requests, change the context to offline mode, and evaluate standard Service Worker and Cache Storage APIs in the page. Use page-side APIs for registration lifecycle details that the Playwright Worker object does not expose.
Why does a service worker test need Chromium?
Playwright's documented service worker inspection and network-routing support is Chromium-focused. Keep worker-specific coverage in a Chromium project and place browser-independent UI behavior in separate cross-browser tests so the reported scope stays accurate.
How do I test offline mode with Playwright?
Warm the resources required by the scenario, call `context.setOffline(true)`, and trigger the same user action again. Assert the cached success path and a separate uncached fallback path; otherwise old cache state can create a false pass.
Why does page.route not catch a service worker request?
An active worker may intercept the page request before page routing sees it, or the worker may own a separate network fetch. Observe or route at BrowserContext scope where supported, or block service workers only in a different test where the worker is outside the test's purpose.
How do I wait until a service worker controls the page?
Await `navigator.serviceWorker.ready`, then check `navigator.serviceWorker.controller`. If the first document is still uncontrolled, wait for `controllerchange` or reload based on the application's activation policy; do not replace the transition with a fixed delay.
Does unregistering a service worker clear its caches?
No. `registration.unregister()` removes the registration but Cache Storage remains. Delete only caches owned by the application, usually selected by a stable prefix, and verify that both registrations and matching caches are gone.
How should I test a service worker update?
Serve changed worker bytes, request an update with `registration.update()`, and observe installing, waiting, active, or controller state according to the product policy. Also verify cache migration and behavior with existing clients because `skipWaiting()` and `clients.claim()` can introduce mixed-version risk.
Related Guides
- How to Test Browser Permissions With Playwright TypeScript (2026)
- How to Test GraphQL Subscriptions with Playwright (2026)
- How to Test responsive layouts in Playwright (2026)
- How to Test WebAuthn Passkeys With Playwright TypeScript (2026)
- How to Add CI to a test framework (2026)
- How to Add logging to a test framework (2026)