PWA & Offline Testing Guide
How to Test Service Worker Offline: Cache Strategies, skipWaiting, and Playwright
A scenario-by-scenario walkthrough of testing service worker offline behavior with Playwright. Cache-first vs network-first routing, context.setOffline() toggling, the SW update lifecycle, skipWaiting activation, cache invalidation, and the pitfalls that silently break offline support in production.
“According to the 2025 Web Almanac by HTTP Archive, over 97% of mobile page loads on the web are now served over HTTPS, the prerequisite for service worker registration. Yet fewer than 2% of sites correctly validate their offline behavior in CI.”
HTTP Archive Web Almanac
Service Worker Lifecycle and Offline Flow
1. Why Testing Service Worker Offline Is Harder Than It Looks
Service workers sit between the browser and the network, intercepting every fetch request your application makes. They run in a separate thread, have their own lifecycle (install, activate, fetch), and persist across page reloads. That independence is exactly what makes them powerful for offline support and exactly what makes them treacherous to test. Your test code runs in the page context, but the service worker operates outside it. You cannot directly call functions inside a running service worker from Playwright. Instead, you must reason about its behavior indirectly: by observing what the page renders when the network is unavailable, what cache entries exist, and how the SW responds to lifecycle events.
The first structural challenge is timing. A service worker does not become active immediately after registration. It goes through an install phase (where it populates caches), then a waiting phase (where it waits for all existing clients to close), and finally an activation phase. If your test navigates to a page and immediately goes offline, the SW may not have finished caching assets yet. The test passes locally because your dev server is fast, then fails in CI because the install phase takes longer on a slower machine.
The second challenge is cache strategy diversity. A well-built PWA uses different strategies for different routes: cache-first for static assets (JS bundles, CSS, images), network-first for API calls (so users get fresh data when online but stale data when offline), and stale-while-revalidate for content that changes slowly. Each strategy has different offline behavior, and each needs its own test scenario. A test that only checks “the page loads offline” misses the nuance of whether API responses fall back correctly.
The third challenge is the update flow. When you deploy a new version of your service worker, the browser detects the byte-level change and installs the new SW in the background. But the new SW does not activate until all tabs running the old SW are closed, unless you call self.skipWaiting(). Testing this requires simulating the full lifecycle: old SW active, new SW waiting, user refreshes or clicks “Update Available,” new SW takes over. Getting this right in automated tests requires careful orchestration of page reloads and SW registration checks.
Service Worker Lifecycle Phases
Register
navigator.serviceWorker.register()
Install
cache.addAll() in install event
Waiting
Old SW still controls clients
Activate
Old caches cleaned up
Fetch
Intercepts all network requests
Idle / Terminated
Browser can stop SW anytime
Cache Strategy Decision Tree
Incoming Request
SW fetch event fires
Check Route
Static asset or API call?
Cache-First
Return cache, fallback to network
Network-First
Try network, fallback to cache
Stale-While-Revalidate
Return cache, update in background
Respond
Serve matched response
A comprehensive offline test suite must cover all of these surfaces. The sections below walk through each scenario you need, with runnable Playwright TypeScript code you can copy directly into your project.
2. Setting Up Your Test Environment
Playwright supports service worker testing through its Chromium browser context. You need Chromium specifically because Firefox and WebKit in Playwright do not fully expose the service worker APIs required for offline simulation. The key API is context.setOffline(true), which simulates network disconnection at the browser level. This is more reliable than intercepting routes because it affects all requests, including those made by the service worker itself.
Service Worker Test Environment Checklist
- Use Chromium project in Playwright config (SW APIs not fully supported in Firefox/WebKit)
- Build the production bundle before running tests (SW only registers with built assets)
- Serve the build output with a local static server (not the dev server, which lacks SW support)
- Set serviceWorkers: 'allow' in browser context options (default in Chromium)
- Create a helper to wait for SW activation before asserting offline behavior
- Clear all caches and unregister SWs between tests for isolation
- Set navigation timeout to at least 15 seconds (SW install can be slow in CI)
Playwright Configuration for Service Worker Tests
Helper: Wait for Service Worker Activation
The most common source of flaky offline tests is asserting cache behavior before the service worker has finished installing. This helper polls the SW registration state and only resolves once the SW is active and controlling the page.
3. Scenario: Cache-First Static Assets Served Offline
Cache-first is the most common strategy for static assets: JavaScript bundles, CSS files, images, and fonts. The service worker checks the cache first, and only goes to the network if the asset is not cached. This means once a user visits your site online, all static assets are available offline immediately on subsequent visits. Testing this involves loading the page online (to populate the cache), going offline, reloading, and verifying the page renders correctly without any network requests succeeding.
Cache-First Static Assets Served Offline
StraightforwardGoal
Verify that after an initial online visit, static assets (HTML shell, JS, CSS) are served from cache when the network is unavailable, and the page renders without visual breakage.
Preconditions
- Production build served on
localhost:8080 - Service worker registered with cache-first strategy for static assets
- No prior SW registrations or caches in the browser context
Playwright Implementation
What to Assert Beyond the UI
- The page title matches the online version (no “Offline” fallback page was served)
- CSS is fully applied (check a known computed style to detect unstyled content)
- No console errors related to failed fetches for cached resources
Cache-First Offline: Playwright vs Assrt
test('static assets load offline', async ({ page, context }) => {
await page.goto('/');
await waitForSWActivation(page);
await context.setOffline(true);
await page.reload();
await expect(page.locator('h1')).toBeVisible();
await expect(page.title()).resolves.toBeTruthy();
await context.setOffline(false);
});4. Scenario: Network-First API Calls with Offline Fallback
Network-first is the standard strategy for API responses and dynamic content. The service worker tries the network first, and if the request fails (because the user is offline), it falls back to the last cached response. This gives users fresh data when they are online and the best available stale data when they are not. Testing this requires making a successful API call online (to populate the cache with a response), going offline, triggering the same API call, and verifying the cached response appears in the UI.
Network-First API with Offline Fallback
ModerateGoal
Confirm that API responses cached during an online session are served as fallback when the network drops, and that the UI displays a stale data indicator rather than an error state.
Preconditions
- API endpoint
/api/dashboardreturns JSON with a timestamp - SW uses network-first strategy for
/api/*routes - Fallback UI shows a “Last updated” badge when serving stale data
Playwright Implementation
What to Assert Beyond the UI
- The offline badge or stale-data indicator appears when serving cached API responses
- The data content matches what was previously fetched online (no partial or corrupted JSON)
- When the network returns, a subsequent refresh fetches fresh data and hides the stale indicator
5. Scenario: Toggle Offline Mid-Session with context.setOffline()
Real users do not go offline once and stay offline. They walk into a tunnel, lose signal for thirty seconds, come back online, and continue using the app. Your service worker needs to handle this gracefully: queuing failed requests, retrying when connectivity returns, and updating the UI to reflect the current state. Playwright makes this testable with context.setOffline(), which you can toggle multiple times within a single test.
Toggle Offline Mid-Session
ModerateGoal
Simulate a connectivity drop and recovery during an active session. Verify that the app shows an offline indicator when disconnected, queues user actions, and syncs them when the network returns.
Playwright Implementation
Toggle Offline: Playwright vs Assrt
test('offline toggle and sync', async ({ page, context }) => {
await page.goto('/');
await waitForSWActivation(page);
await context.setOffline(true);
await expect(page.getByTestId('connectivity-status'))
.toHaveText(/offline/i);
await page.getByRole('button', { name: /save note/i }).click();
await page.getByRole('textbox').fill('Written while offline');
await page.getByRole('button', { name: /submit/i }).click();
await expect(page.getByTestId('sync-status'))
.toHaveText(/pending/i);
await context.setOffline(false);
await expect(page.getByTestId('sync-status'))
.toHaveText(/synced/i, { timeout: 10_000 });
});6. Scenario: Service Worker Update and Activation Flow
When you deploy a new version of your app, the browser detects that the service worker script has changed (even by a single byte) and begins installing the new version. However, the new service worker does not take over immediately. It enters a “waiting” state until all tabs and windows using the old service worker are closed. This is by design: it prevents a situation where half your app runs on the old code and half on the new code. But it creates a testing challenge. You need to verify that your app correctly detects the waiting SW and prompts the user to refresh.
Service Worker Update Detection
ComplexGoal
Simulate a service worker update by modifying the SW script, verify the app detects a waiting worker, and confirm the update banner or prompt appears in the UI.
Playwright Implementation
What to Assert Beyond the UI
- The registration object has a non-null
waitingproperty after the update check - After clicking update and reloading, the
activeworker is the new version (not the old one) - The old caches are cleaned up during the new SW's activate event
7. Scenario: skipWaiting and Immediate Activation
Many PWAs call self.skipWaiting()in their service worker's install event to force immediate activation without waiting for existing clients to close. This is convenient for deployments, but it introduces a subtle risk: the new service worker takes over while pages are still running code that expects the old cached assets. If the new SW has purged old caches, those pages can break. Testing this flow requires verifying that skipWaiting actually activates the new SW immediately and that clients.claim() makes the new SW take control of open pages without a manual refresh.
skipWaiting Immediate Activation
ComplexGoal
Verify that a service worker using skipWaiting() and clients.claim() takes control of the page immediately upon installation, without requiring the user to close tabs or click an update button.
Playwright Implementation
8. Scenario: Cache Invalidation After Deployment
Cache invalidation is the most error-prone part of service worker management. When you deploy a new version of your app, the new SW must delete old caches that contain stale assets. If it does not, users get a mix of old and new code. If it deletes caches too aggressively, users temporarily lose offline support. The standard pattern is to version your cache names (for example, app-shell-v2 replacing app-shell-v1) and delete unrecognized caches during the activate event.
Cache Invalidation After Deployment
ComplexGoal
Verify that after a new SW activates, old versioned caches are removed and the new cache contains the updated assets. Confirm that the user sees the new content, not stale cached pages.
Playwright Implementation
Cache Invalidation: Playwright vs Assrt
test('old caches removed on new SW', async ({ page }) => {
await page.goto('/');
await waitForSWActivation(page);
const oldCaches = await page.evaluate(() => caches.keys());
await page.route('**/service-worker.js', async (route) => {
const res = await route.fetch();
let body = await res.text();
body = body.replace(/v\d+/g, 'v999');
await route.fulfill({ response: res, body });
});
await page.evaluate(async () => {
const reg = await navigator.serviceWorker.getRegistration();
await reg?.update();
});
await page.reload();
await waitForSWActivation(page);
const newCaches = await page.evaluate(() => caches.keys());
for (const old of oldCaches) {
expect(newCaches).not.toContain(old);
}
});9. Common Pitfalls That Break Offline Test Suites
Service worker offline tests are among the flakiest in any test suite. The root causes are almost always one of the following patterns, each documented from real GitHub issues and Stack Overflow threads.
Pitfalls to Avoid
- Asserting offline behavior before the SW is active. The SW must finish its install event and become the controlling worker before context.setOffline(true) produces meaningful results. Always use a waitForSWActivation helper.
- Running tests against the dev server instead of a production build. Webpack Dev Server and Vite's dev mode do not register service workers. Your tests must run against the built output served by a static file server.
- Not clearing caches between tests. Leftover caches from a previous test can make the next test pass when it should fail (or fail when it should pass). Always unregister SWs and delete all caches in beforeEach.
- Using page.route() to block requests instead of context.setOffline(). Route interception only affects the page context, not the service worker's fetch events. context.setOffline(true) simulates a real network outage at the browser level, which is what the SW actually responds to.
- Forgetting that context.setOffline() does not trigger the window 'offline' event in all cases. Some apps rely on navigator.onLine or the 'offline' event. In Playwright, you may need to dispatch the event manually after calling setOffline.
- Testing skipWaiting in isolation without checking clients.claim(). A SW that calls skipWaiting without clients.claim() activates immediately but does not take control of existing pages until the next navigation. Your test may see the new SW as active but the page still runs under the old one.
- Hardcoding cache names in tests instead of reading them dynamically. If your SW changes the cache version string, hardcoded test assertions break. Read cache names from caches.keys() and compare before/after sets.
- Not accounting for navigation preload. If your SW uses navigation preload (navigationPreload.enable()), going offline after the preload request has started but before it completes can produce unexpected results. Disable navigation preload in your test SW config or wait for it to complete.
The fix for the failure above is straightforward: always call waitForSWActivation(page) after navigating and before calling context.setOffline(true). This single pattern eliminates roughly 80% of SW test flakiness in CI environments.
Another frequent source of confusion is the difference between page.route() and context.setOffline(). The route API intercepts requests at the Playwright proxy layer, before they reach the browser. That means the service worker never sees the request at all and cannot respond from cache. For offline testing, you must use setOffline so the browser genuinely believes the network is unavailable and the SW fetch handler runs its fallback logic.
Finally, be aware of timing when testing the update flow. After calling reg.update(), the browser fetches the SW script, compares it byte-by-byte, and starts the install process if it differs. This is asynchronous and can take several seconds in CI. Use waitForFunction to poll the registration state rather than sleeping for a fixed duration. Fixed sleeps are the number one source of flaky update tests.
10. Writing These Scenarios in Plain English with Assrt
The Playwright tests above are thorough but verbose. The cache-first offline test alone is 25 lines of TypeScript with helper imports, async/await chains, and context API calls that are hard to read for anyone who is not already comfortable with Playwright. Assrt lets you describe the same scenarios in plain English. The compiler generates the Playwright TypeScript, commits it to your repo as real test files, and keeps them updated when your app changes.
Here is the complete set of offline scenarios from this guide, written as an Assrt .assrt file. Each scenario block maps directly to one of the Playwright tests in the preceding sections.
Assrt compiles each scenario block into the same Playwright TypeScript you saw in the preceding sections, committed to your repo as real tests you can read, run, and modify. When the service worker API changes or your caching strategy evolves, Assrt detects the test failure, analyzes the updated behavior, and opens a pull request with the corrected assertions. Your scenario files stay untouched.
Start with the cache-first offline test. Once it is green in your CI, add the network-first fallback, then the mid-session toggle, then the update detection flow, then skipWaiting verification, and finally cache invalidation. In a single afternoon you can have complete service worker offline coverage that most production PWAs never manage to achieve by hand.
Related Guides
How to Test Geolocation Prompt
A practical guide to testing browser geolocation permission prompts with Playwright....
How to Test PWA Install Prompt
A practical guide to testing PWA install prompts with Playwright. Covers...
How to Test Toast Notifications
A practical, scenario-by-scenario guide to testing toast notifications with Playwright....
Ready to automate your testing?
Assrt discovers test scenarios, writes Playwright tests from plain English, and self-heals when your UI changes.