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.

ScanlyApp Team

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

Testing WebSockets and Real-Time Features: Catch Race Conditions Before They Reach Users
Mobile & Cross-Platform
6 min read

Testing WebSockets and Real-Time Features: Catch Race Conditions Before They Reach Users

Real-time features — live dashboards, collaborative editing, notifications, and chat — create testing challenges that HTTP-based thinking doesn't handle well. This guide covers testing WebSocket connections, Supabase Realtime channels, Socket.io handlers, and reconnection behavior with Playwright and unit testing strategies.

ScanlyApp Team

Read more
Visual Regression Testing for Dark Mode: Catch the UI Bugs That Only Appear at Night
Mobile & Cross-Platform
7 min read

Visual Regression Testing for Dark Mode: Catch the UI Bugs That Only Appear at Night

Dark mode is no longer optional — 81% of smartphone users enable it. But dark mode introduces a parallel set of visual bugs: low contrast, hardcoded hex colors, invisible icons, and flickering during theme transition. This guide covers automated visual regression testing for dark mode and dynamic themes using Playwright.

ScanlyApp Team

Read more
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.

ScanlyApp Team

Read more