Back to Blog

Testing PWA Offline Functionality: Service Workers, Caching, and Background Sync

Progressive Web Apps promise offline-first experiences — but most teams never actually test offline behavior until a user reports a broken screen. This guide covers how to test service worker caching strategies, offline fallbacks, and background sync with Playwright's network conditioning APIs.

Published

7 min read

Reading time

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

Monitor your PWA behavior automatically: Try ScanlyApp free and set up scheduled scans that validate your app's service worker and offline functionality.

Related Posts

Automating MFA and 2FA Testing: Stop Skipping Auth Flows in Your Test Suite
Mobile & Cross-Platform
6 min read

Automating MFA and 2FA Testing: Stop Skipping Auth Flows in Your Test Suite

Multi-factor authentication is critical for security, but it is notoriously painful to test. OTP codes that expire in 30 seconds, SMS delivery delays, and TOTP clock sync issues create a testing nightmare. Here's how to automate MFA testing without relying on real SMS delivery or manual code entry.