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.

97%

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

0Offline scenarios covered
0Cache strategies tested
0%Fewer lines with Assrt
0sAvg scenario run time

Service Worker Lifecycle and Offline Flow

BrowserYour AppService WorkerCache StorageNetworkNavigate to pagenavigator.serviceWorker.register()Fetch assets to precacheReturn assetscache.addAll(urls)Fetch request (offline)Match cached responseReturn cached responseServe from cache

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

playwright.config.ts

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.

test/helpers/sw-helpers.ts
Building and Serving for SW Tests

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.

1

Cache-First Static Assets Served Offline

Straightforward

Goal

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

cache-first-offline.spec.ts

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);
});
11% fewer lines

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.

2

Network-First API with Offline Fallback

Moderate

Goal

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/dashboard returns 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

network-first-offline.spec.ts

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

Try Assrt for free

Open-source AI testing framework. No signup required.

Get Started

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.

3

Toggle Offline Mid-Session

Moderate

Goal

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.spec.ts

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 });
});
29% fewer lines

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.

4

Service Worker Update Detection

Complex

Goal

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

sw-update-detection.spec.ts

What to Assert Beyond the UI

  • The registration object has a non-null waiting property after the update check
  • After clicking update and reloading, the active worker 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.

5

skipWaiting Immediate Activation

Complex

Goal

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

skip-waiting.spec.ts
skipWaiting Test Output

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.

6

Cache Invalidation After Deployment

Complex

Goal

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.spec.ts

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);
  }
});
47% fewer lines

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.
Common Failure: Asserting Before SW Is Active

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.

service-worker-offline.assrt

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

Ready to automate your testing?

Assrt discovers test scenarios, writes Playwright tests from plain English, and self-heals when your UI changes.

$npm install @assrt/sdk