Testing PWA Offline Functionality: Service Workers, Caching, and Background Sync
The promise of Progressive Web Apps is compelling: your web application works offline, loads instantly on repeat visits, and sends background notifications. The reality for most teams is that the service worker was added via a Workbox plugin, verified once in Chrome DevTools, and then never touched again — and certainly never tested in an automated pipeline.
Offline behavior is the kind of thing that breaks silently. A dependency update swaps out a module, the cache strategy for a critical route changes, and suddenly your "offline-capable" app shows a generic Chrome dinosaur screen. This guide covers automated testing of every layer of offline functionality.
Service Worker Architecture: What You're Testing
flowchart TD
A[User requests resource] --> B{Network available?}
B -->|Yes| C{Cache strategy}
C -->|Cache-first| D[Serve from cache\nUpdate in background]
C -->|Network-first| E[Try network\nFall back to cache]
C -->|Stale-while-revalidate| F[Serve from cache immediately\nFetch and update cache]
B -->|No — Offline| G[Serve from cache ONLY]
G --> H{In cache?}
H -->|Yes| I[Serve cached version]
H -->|No| J[Serve offline fallback page]
Your tests need to cover all four paths: online+cached, online+uncached, offline+cached, and offline+uncached (fallback).
Playwright Offline Mode
Playwright has native APIs for simulating offline conditions:
// tests/pwa/offline.test.ts
import { test, expect } from '@playwright/test';
test.describe('Offline behavior', () => {
test('app loads from cache when offline after initial visit', async ({ page, context }) => {
// Step 1: Online visit to prime the service worker cache
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
// Wait for service worker to activate and cache resources
await page.evaluate(() => navigator.serviceWorker.ready);
// Step 2: Go offline
await context.setOffline(true);
// Step 3: Reload — should serve from cache
await page.reload();
// Should not show an error page
await expect(page.locator('[data-testid="app-shell"]')).toBeVisible();
// Should show an offline indicator if implemented
await expect(page.locator('[data-testid="offline-badge"]')).toBeVisible();
// Core navigation should still work from cache
await page.click('[data-testid="nav-home"]');
await expect(page).toHaveURL('/');
});
test('offline fallback page is shown for uncached routes', async ({ page, context }) => {
// Do NOT visit this page online first
await context.setOffline(true);
await page.goto('/some-uncached-page', { waitUntil: 'domcontentloaded' });
// Should show the custom offline fallback, not Chrome's dinosaur
await expect(page.locator('[data-testid="offline-fallback"]')).toBeVisible();
await expect(page.locator('text=You appear to be offline')).toBeVisible();
// Should NOT show a browser error page
const title = await page.title();
expect(title).not.toContain('ERR_INTERNET_DISCONNECTED');
});
test('previously viewed content is accessible offline', async ({ page, context }) => {
// Visit articles while online
const articleSlugs = ['article-1', 'article-2', 'article-3'];
for (const slug of articleSlugs) {
await page.goto(`/blog/${slug}`);
await page.waitForLoadState('networkidle');
}
await context.setOffline(true);
// Try accessing the same articles offline
for (const slug of articleSlugs) {
await page.goto(`/blog/${slug}`);
const h1 = page.locator('h1');
await expect(h1).toBeVisible();
}
});
});
Testing Cache Strategies
Workbox provides several caching strategies. Each has specific testable behaviors:
// tests/pwa/cache-strategies.test.ts
test('stale-while-revalidate serves stale content immediately', async ({ page, context }) => {
// Make initial request to populate cache
await page.goto('/products/popular-item');
await page.waitForLoadState('networkidle');
const initialContent = await page.locator('h1').textContent();
// Simulate slow network (not complete offline)
await context.route('**/api/products/**', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 5000));
await route.continue();
});
const startTime = Date.now();
await page.goto('/products/popular-item');
const loadTime = Date.now() - startTime;
// Should load immediately from stale cache, not wait 5 seconds
expect(loadTime).toBeLessThan(1000);
// Stale content should still be visible
await expect(page.locator('h1')).toBeVisible();
});
test('network-first falls back to cache on network failure', async ({ page, context }) => {
// Prime cache
await page.goto('/dashboard');
// Block network but keep "online" (simulates unreachable server)
await context.route('**/*', (route) => route.abort('connectionrefused'));
await page.goto('/dashboard');
// Dashboard loaded from cache despite network failure
await expect(page.locator('[data-testid="dashboard-header"]')).toBeVisible();
// Should indicate stale data
await expect(page.locator('[data-testid="stale-data-warning"]')).toBeVisible();
});
Testing Background Sync
Background sync allows queued actions (form submissions, data mutations) to complete when the user comes back online:
// tests/pwa/background-sync.test.ts
test('form submission is queued offline and synced when online', async ({ page, context }) => {
// Go online first to load the form
await page.goto('/feedback');
await page.waitForLoadState('networkidle');
// Go offline
await context.setOffline(true);
// Fill and submit the form while offline
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="message"]', 'Testing offline sync');
await page.click('[data-testid="submit-feedback"]');
// Should show "saved for later" indicator, not an error
await expect(page.locator('[data-testid="offline-queue-notice"]')).toBeVisible();
// Come back online
await context.setOffline(false);
// Should automatically sync pending submissions
// Listen for the sync completion
await page.waitForResponse((response) => response.url().includes('/api/feedback') && response.status() === 200, {
timeout: 15_000,
});
// Success confirmation should eventually appear
await expect(page.locator('[data-testid="submission-success"]')).toBeVisible({ timeout: 15_000 });
});
Service Worker Registration Testing
Verify your service worker registers correctly and handles updates:
// tests/pwa/registration.test.ts
test('service worker registers on first visit', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
const swRegistered = await page.evaluate(async () => {
if (!('serviceWorker' in navigator)) return false;
const registration = await navigator.serviceWorker.getRegistration();
return registration !== undefined;
});
expect(swRegistered).toBe(true);
});
test('service worker is active (not just installed)', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
const swState = await page.evaluate(async () => {
const registration = await navigator.serviceWorker.ready;
return registration.active?.state;
});
expect(swState).toBe('activated');
});
test('service worker scope is correct', async ({ page }) => {
await page.goto('/');
const scope = await page.evaluate(async () => {
const registration = await navigator.serviceWorker.ready;
return registration.scope;
});
// Should cover the entire app, not just a subdirectory
expect(scope).toBe(`${process.env.BASE_URL}/`);
});
Service Worker Testing Coverage Matrix
| Scenario | Test Type | Tools |
|---|---|---|
| Offline load from cache | E2E | Playwright context.setOffline(true) |
| Offline fallback page | E2E | Playwright + navigate to uncached URL |
| Stale-while-revalidate timing | E2E | Playwright route throttling |
| Background sync queue | E2E | Playwright offline + re-online |
| SW registration | E2E | page.evaluate + SW API |
| SW cache entries exist | Unit | Workbox testing utilities |
| SW update flow | E2E | Playwright + reload after deploy |
| Push notification permission | E2E | Playwright permissions API |
Workbox Configuration Best Practices
// next.config.mjs (with next-pwa or custom workbox)
import withPWA from 'next-pwa';
export default withPWA({
dest: 'public',
runtimeCaching: [
{
// API responses: network-first with 5s timeout
urlPattern: /^https:\/\/app\.scanlyapp\.com\/api\//,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
networkTimeoutSeconds: 5,
expiration: { maxEntries: 50, maxAgeSeconds: 300 },
},
},
{
// Static pages: stale-while-revalidate
urlPattern: /\.(js|css|html)$/,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'static-cache',
expiration: { maxAgeSeconds: 86400 },
},
},
{
// Images: cache-first, long TTL
urlPattern: /\.(png|jpg|jpeg|svg|gif|webp|avif)$/,
handler: 'CacheFirst',
options: {
cacheName: 'image-cache',
expiration: { maxEntries: 100, maxAgeSeconds: 604800 },
},
},
],
});
PWA offline testing is underinvested precisely because it requires extra setup — offline simulation, service worker lifecycle management, and background API mocking. But it is entirely automatable, and the payoff is confidence that your offline-first features actually work when users need them.
Further Reading
- Service Worker API — MDN Web Docs: The definitive reference for the Service Worker API, lifecycle events, caching strategies, and background sync
- Progressive Web Apps — web.dev: Google's comprehensive learning resource for building installable, offline-capable PWAs
- Workbox Documentation: Google's production-ready service worker library for advanced caching and offline strategies
- Playwright Network Interception: How to simulate offline mode and intercept service worker fetch events in Playwright tests
Monitor your PWA behavior automatically: Try ScanlyApp free and set up scheduled scans that validate your app's service worker and offline functionality.
