PWA Testing Strategy: Offline, Install, Service Worker
AI generated
PASS
expect()
Testing · PWA · Service Worker · Offline
PWA Testing Strategy: Offline, Install, Service Worker
How to reliably test Progressive Web Apps

Progressive Web Apps bring service workers, offline fallbacks and install prompts that classic E2E tests do not cover. Anyone using Cypress or Playwright without a dedicated strategy for network simulation, cache invalidation and manifest validation misses exactly the bugs that confront customers with stale prices offline or right after a deploy. This article shows how PWA testing works reliably in practice.

16 min read Service Worker · Offline First · Cache Invalidation Cypress 13+ · Playwright 1.4x · Lighthouse CI

1. Why PWA testing differs from classic E2E testing

Classic E2E testing assumes a simple model: the browser sends a request, the server responds, the DOM updates accordingly. A Progressive Web App inserts an extra layer in between, the service worker, which sits as a programmable proxy between the browser and the network. It can answer requests directly from Cache Storage, without a server request ever taking place at all. That exact fact breaks the assumption many classic test suites make, namely that every network response comes directly from the backend under test. A test that stubs an API with cy.intercept() may still see a cached response from the service worker, because the worker intercepts the fetch event before the stub can even take effect.

Three new testing surfaces come into play with PWAs that do not exist in classic, purely server-rendered applications: the service worker lifecycle with its install, activate and fetch phases, the application's offline behavior with no network connection at all, and the install flow through the web app manifest. Each of these surfaces can break independently of the actual application code. A second important difference concerns test isolation: a service worker registered during one test run stays active in the browser profile and can affect subsequent tests with stale caches, unless caches.keys() is explicitly cleared and the service worker registration is unregistered between runs. Without this cleanup, the classic, hard-to-reproduce PWA test flakes appear.

2. Understanding and deliberately testing the service worker lifecycle

The service worker lifecycle goes through three fixed phases: install, activate and fetch. In the install event, the service worker loads the defined files into a versioned cache. In the activate event, it cleans up old cache versions and, with self.clients.claim(), immediately takes control of already open tabs instead of waiting for a reload. Only after that does the fetch handler kick in, deciding on the actual caching strategy per request. A test that ignores this sequence and asserts against cached content right after cy.visit() often ends up testing against a service worker that is not even active yet.

To deliberately check the lifecycle, a reliable test explicitly waits for navigator.serviceWorker.ready or navigator.serviceWorker.controller before running offline-relevant assertions. Playwright additionally offers context.serviceWorkers(), which returns a list of all active service worker instances in the context in Chromium-based browsers and can be inspected directly. For deploy tests, self.skipWaiting() in the install event is crucial: without this call, a new service worker gets stuck in the waiting state until every tab running the old version is closed, which causes timeouts in E2E suites when the test does not expect exactly this behavior.


// service-worker.js - install, activate and fetch lifecycle
const CACHE_VERSION = 'shop-cache-v3';
const PRECACHE_ASSETS = [
  '/offline.html',
  '/css/app.css',
  '/js/app.js',
  '/images/logo.svg'
];

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_VERSION).then((cache) => cache.addAll(PRECACHE_ASSETS))
  );
  // Activate the new service worker immediately after install
  self.skipWaiting();
});

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(
        keys
          .filter((key) => key !== CACHE_VERSION)
          .map((key) => caches.delete(key))
      )
    ).then(() => self.clients.claim())
  );
});

self.addEventListener('fetch', (event) => {
  if (event.request.mode === 'navigate') {
    event.respondWith(
      fetch(event.request).catch(() => caches.match('/offline.html'))
    );
    return;
  }
  event.respondWith(
    caches.match(event.request).then((cached) => cached || fetch(event.request))
  );
});

3. Simulating offline conditions in Cypress

Cypress has no native equivalent of context.setOffline(). The built-in cy.intercept() command can fail individual requests deliberately with { forceNetworkError: true }, but that only affects requests Cypress can intercept in the browser, not the service worker's own behavior and not the operating system's actual network connection. A service worker that has registered a fetch handler often does not even see an error simulated via forceNetworkError, because it itself is the first instance to respond to the fetch event.

For genuine offline emulation in Chrome-based browsers, the only path left is the Chrome DevTools Protocol, which Cypress exposes internally via Cypress.automation('remote:debugger:protocol', …). The Network.emulateNetworkConditions command with offline: true cuts the connection at the browser level, not just for individual requests. This technique is unofficial, works reliably only in Chrome and Electron, not in Firefox or WebKit, and must be explicitly reset after every test, otherwise subsequent tests wrongly run offline. This limitation is one of the main reasons many teams additionally or exclusively turn to Playwright for PWA-heavy suites.


// cypress/e2e/pwa/offline-fallback.cy.js
describe('PWA offline fallback', () => {
  it('shows the offline page once the service worker is active', () => {
    cy.visit('/');

    // Wait until the service worker has taken control of this page
    cy.window()
      .its('navigator.serviceWorker.controller')
      .should('exist');

    // cy.intercept only fakes failures for requests it can see in the
    // browser's fetch/XHR layer, it does not put the OS network offline.
    // For true offline emulation in Chrome-family browsers, drop to CDP:
    cy.window().then(() =>
      Cypress.automation('remote:debugger:protocol', {
        command: 'Network.emulateNetworkConditions',
        params: { offline: true, latency: 0, downloadThroughput: 0, uploadThroughput: 0 }
      })
    );

    cy.visit('/checkout', { failOnStatusCode: false });
    cy.get('[data-testid="offline-banner"]')
      .should('be.visible')
      .and('contain.text', 'Offline');

    // Restore normal network conditions so later tests are unaffected
    cy.window().then(() =>
      Cypress.automation('remote:debugger:protocol', {
        command: 'Network.emulateNetworkConditions',
        params: { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1 }
      })
    );
  });
});

4. Simulating offline conditions in Playwright

Playwright solves the same problem with a single, cross-browser API: context.setOffline(true) switches the entire BrowserContext into an offline state, in which every network request fails, regardless of whether it originates from application code, from the service worker, or from a third-party script. The method works identically in Chromium, Firefox and WebKit, because Playwright implements it through each browser's own automation protocol layer instead of a browser-specific workaround. That makes offline tests in Playwright considerably more stable and portable than the CDP-based solution in Cypress.

The decisive test flow for PWAs: load the page once online so the service worker can install and precache, wait for navigator.serviceWorker.controller, then only afterward set context.setOffline(true) and revisit the page. That makes it possible to precisely distinguish whether a previously visited route is served from cache and whether a never-visited route correctly redirects to the offline fallback page. Resetting with setOffline(false) after every offline test case is mandatory, since the state otherwise persists across test.describe blocks and causes subsequent tests to fail for no obvious reason.


// tests/pwa/offline-fallback.spec.ts
import { test, expect } from '@playwright/test';

test('shows a cached product page and offline banner without network', async ({ page, context }) => {
  // Visit once online so the service worker can install and precache assets
  await page.goto('/products/test-sku-001');
  await page.waitForFunction(() => navigator.serviceWorker.controller !== null);

  // Flip the whole browser context offline, no CDP flags required
  await context.setOffline(true);

  await page.goto('/products/test-sku-001');
  await expect(page.getByTestId('offline-banner')).toBeVisible();
  await expect(page.getByTestId('product-price')).toHaveAttribute('data-stale', 'true');

  // Navigating to an uncached route should fall back to offline.html
  await page.goto('/products/never-visited-sku');
  await expect(page.getByRole('heading', { name: 'You are offline' })).toBeVisible();

  await context.setOffline(false);
});

5. Testing the install prompt and manifest validation

The beforeinstallprompt event that Chrome fires when a page meets the installability criteria is hard to trigger reliably in automated browsers. Among other things, Chrome checks engagement heuristics such as a minimum time on site, which are practically never met in a fresh test context with no interaction history. The native prompt itself is also a browser UI element outside the DOM that no test framework can drive directly. The pragmatic approach: do not test the native browser dialog, test your own install banner component rendered in the DOM that reacts to the event.

To do that, the event is triggered synthetically in the test, for example with window.dispatchEvent(new Event('beforeinstallprompt')), with prompt() and userChoice added as mock functions so your own UI logic can be tested independently of the real browser behavior. For the manifest itself, a unit test is not enough: Lighthouse's PWA audit automatically validates required fields such as name, icons at 192px and 512px minimum, start_url and display. A missing maskable icon or a wrong theme_color value does not cause a hard failure, but it does lead to a lower installability score and potentially an inconsistent icon on the user's home screen.


{
  "name": "Mironsoft Shop",
  "short_name": "MS Shop",
  "start_url": "/?utm_source=pwa",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#365314",
  "orientation": "portrait-primary",
  "icons": [
    { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
  ]
}

6. Testing cache strategies and invalidation after deploy

Three cache strategies cover most PWA use cases: cache-first for static assets such as images, fonts and CSS that rarely change and whose URL typically also changes via a hash in the filename whenever they do. Network-first for content that needs to be current, such as price and stock data, with a cache fallback reserved strictly for the offline case. Stale-while-revalidate as a compromise: the user immediately gets the cached response while a fresh version is fetched in the background and stored for the next call. Choosing the wrong strategy per endpoint is the most common cause of PWA bugs that stay invisible in testing, because they only show up after real deploys.

The critical test case is cache invalidation after a deploy: a new service worker with an incremented CACHE_VERSION must reliably delete old caches in the activate event, otherwise the application keeps serving a mix of old and new code after the deploy. A good test setup deploys a changed version of the application against the same browser instance, calls registration.update(), waits for the statechange to activated, and then checks via caches.keys() that only the new cache version exists. Without this test, a forgotten skipWaiting() or a miscalculated CACHE_VERSION often goes unnoticed for weeks.

7. E-commerce-specific pitfalls: stale prices and cache poisoning

The most dangerous PWA bug in an e-commerce context is a cache-first strategy on price- or stock-related endpoints. If the product detail page or the price endpoint is accidentally served cache-first, a user in offline mode or on an unstable network sees a price that is hours or days old, with nothing in the interface marking it as stale. In the worst case, the user completes a checkout at the wrong price, leading to cancellations, goodwill costs or lost trust. An E2E test covering exactly this case loads a product page online, changes the price server-side, goes offline, and checks that the interface either explicitly marks the old price with data-stale="true" or disables the checkout button.

Cache poisoning is the second danger: if a faulty or personalized response, such as a server error page or a cart containing someone else's items, ends up accidentally stored under a generic cache key, every subsequent user is served the same wrong response until the cache expires or is invalidated. The fetch handler should therefore only cache responses with status 200, and should never write requests with an Authorization header or personalized endpoints such as /customer/account into a shared cache. A regression test that deliberately provokes a 500 response and then checks that it does not end up in the cache reliably catches this class of bugs before it becomes visible in production.

8. Automating Lighthouse PWA audits in CI

Manual Lighthouse checks in the Chrome DevTools panel only surface PWA regressions once a developer happens to look. Lighthouse CI (@lhci/cli) automates exactly this audit on every pull request and can fail the build when defined thresholds are not met. Lighthouse's PWA category checks, among other things, whether a service worker is registered, whether the manifest is valid, whether an offline page responds with status code 200, and whether the page is served over HTTPS. These checks run headless against a real Chrome process, delivering more reliable results than pure unit tests against the service worker file.

In practice, lhci autorun runs against a built and locally served version of the application, collects several runs to reduce measurement noise, and compares the results against a lighthouserc.json with assertions such as categories:pwa at least 0.9. If the threshold fails, the CI job aborts with a clear error message, instead of the regression only surfacing in the next manual review. This automation complements the functional Cypress and Playwright tests from the previous sections rather than replacing them: Lighthouse checks PWA compliance structurally, the E2E tests check actual user behavior in offline and install scenarios.


# .github/workflows/lighthouse-ci.yml
name: Lighthouse CI

on:
  pull_request:
    branches: [main]

jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npm run build
      - run: npm install -g @lhci/cli@0.13.x
      - run: lhci autorun
        env:
          LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}

9. PWA testing approaches compared head to head

Choosing the right tool for a given PWA testing task has a direct impact on the suite's stability and maintenance effort. Cypress and Playwright differ noticeably when it comes to offline simulation, service worker introspection and multi-browser support, while many functional tests can be written almost identically in either framework.

Testing Task Cypress Playwright Recommendation
Genuine offline emulation Only via unofficial CDP workarounds context.setOffline() natively Playwright for offline-heavy suites
Service worker introspection No direct API access context.serviceWorkers() Playwright
Multi-browser support Only the Chrome family is stable Chromium, Firefox, WebKit Playwright for cross-browser coverage
PWA score / manifest audit Via external Lighthouse CLI Via external Lighthouse CLI Lighthouse CI independent of the E2E tool
Setup for standard E2E tests Very low, large community Low, native TypeScript support Both equally good outside PWA specifics

In practice, a combination works well: Playwright for the offline and service-worker-heavy test cases, where genuine multi-browser coverage and stable context.setOffline() emulation matter, and Lighthouse CI as a structural addition for manifest and PWA score regressions. Teams already fully committed to Cypress do not necessarily need to migrate PWA tests, but they should recognize the CDP-based offline emulation for what it is: a working but Chrome-specific workaround that hits limits in mixed browser setups.

Mironsoft

PWA testing, E2E automation and CI/CD for Magento and Hyva shops

Want PWA tests that catch offline bugs before deploy?

We build Cypress and Playwright suites for service worker lifecycle, offline fallbacks and cache invalidation, and integrate Lighthouse PWA audits directly into your CI/CD pipeline.

Offline test suites

Cypress and Playwright tests for service worker, cache strategies and offline fallback pages

Manifest & install flow

Validation of the web app manifest, icons and your own install banner logic

Lighthouse CI

Automated PWA score audits as a quality gate in pull requests

10. Summary

PWA testing differs from classic E2E testing through three additional surfaces: the service worker lifecycle, genuine offline behavior, and the install flow through the web app manifest. Cypress reliably covers standard E2E scenarios but runs into unofficial CDP workarounds for genuine offline emulation, which only work in Chrome-based browsers. Playwright solves the same task with the native, cross-browser context.setOffline() API and offers direct insight into active service worker instances with context.serviceWorkers().

For e-commerce, the most important rule is this: price and stock data should never sit behind a cache-first strategy, or stale values will appear current offline or on an unstable network. Cache invalidation after every deploy must be tested explicitly, as must protection against cache poisoning from error responses. Lighthouse CI complements the functional E2E tests with a structural, automated check of manifest, HTTPS and PWA score on every pull request.

PWA Testing Strategy - The Essentials at a Glance

Service worker lifecycle

Deliberately test install, activate and fetch, wait for navigator.serviceWorker.ready instead of asserting immediately.

Offline simulation

context.setOffline() in Playwright is native and cross-browser, in Cypress only via a CDP workaround for Chrome.

Cache strategies

Cache-first only for static assets, network-first for price and stock, stale-while-revalidate as a compromise.

CI safeguards

Lighthouse CI as a quality gate for manifest, HTTPS and PWA score on every pull request.

11. FAQ: PWA Testing Strategy

1What sets PWA testing apart from classic E2E testing?
Additionally covers the service worker lifecycle, genuine offline behavior and the install flow through the web app manifest. These surfaces do not exist in classic server applications.
2Can Cypress simulate genuine offline behavior?
Only to a limited extent via forceNetworkError. Genuine offline emulation only via the Chrome DevTools Protocol and Cypress.automation(), reliable only in Chrome-based browsers.
3How do you simulate offline conditions in Playwright?
context.setOffline(true) switches the entire BrowserContext offline, identically in Chromium, Firefox and WebKit. Reset with setOffline(false) after the test.
4Why is the native install prompt hard to trigger?
Engagement heuristics are barely met in fresh test contexts, and the dialog also sits outside the DOM. Test your own install banner component with a synthetic event instead.
5What is the biggest PWA pitfall in e-commerce?
Cache-first on price- or stock-related endpoints shows stale prices offline without marking them, which can lead to incorrect checkouts.
6What is cache poisoning in service workers?
Faulty or personalized responses stored under a generic cache key get served to every subsequent user. Only cache status 200 responses, never personalized endpoints.
7Which cache strategy fits which endpoint?
Cache-first for static assets, network-first for price and stock, stale-while-revalidate as a compromise for content shown fast but updated in the background.
8How do you test cache invalidation after a deploy?
Deploy a changed version, call registration.update(), wait for the statechange to activated, and check via caches.keys() that only the new cache version exists.
9What exactly does Lighthouse CI check for PWAs?
Service worker registration, manifest validity, an offline page with status code 200, and HTTPS delivery. Assertions can fail the CI build if a threshold is not met.
10Should you switch from Cypress to Playwright for PWA tests?
Not necessarily for the whole suite. Playwright has the edge in offline emulation and multi-browser support, a combination of both frameworks is a workable middle ground.