PWA Offline Mode Testing: How to Verify Your App Works Without Internet

PWA Offline Mode Testing: How to Verify Your App Works Without Internet

Offline mode is one of the defining features of a Progressive Web App. Users expect apps to work — or at least degrade gracefully — when connectivity drops. Testing this is harder than it sounds: offline behavior involves service workers, caches, IndexedDB, background sync, and UI state machines that all have to work together. This guide covers how to test offline PWA behavior comprehensively.

What "Offline Mode" Actually Covers

Offline mode isn't just "does the page load without internet." A well-tested offline experience covers:

  • Core content availability — can the user access their most recent data?
  • Write operations — can the user continue creating/editing, with changes queued for sync?
  • Graceful degradation — are missing features clearly communicated, not silently broken?
  • Recovery — when connectivity returns, does everything sync correctly without data loss?
  • Partial connectivity — what happens with a 2G connection or 50% packet loss?

Each of these requires different testing approaches.

Setting Up Offline Testing in Chrome DevTools

Before writing automated tests, understand what you're testing manually:

  1. Open DevTools → ApplicationService Workers
  2. Check "Offline" to simulate no connectivity
  3. Reload the page — see what loads
  4. Check Network tab to see which requests hit the service worker vs. the network

This gives you a baseline to automate against. Note exactly which URLs load from cache and which show errors.

Network Condition Simulation with Playwright

Playwright lets you control network conditions programmatically:

import { test, expect } from '@playwright/test';

test.describe('Offline Mode', () => {
  test('shows cached homepage when offline', async ({ page, context }) => {
    // First visit: prime the cache
    await page.goto('http://localhost:3000');
    await page.waitForLoadState('networkidle');
    
    // Small wait for SW to finish caching
    await page.waitForTimeout(500);
    
    // Go offline
    await context.setOffline(true);
    
    // Reload — should serve from SW cache
    await page.goto('http://localhost:3000');
    
    await expect(page.locator('body')).toBeVisible();
    await expect(page.locator('nav')).toBeVisible();
    
    // No error UI should appear for the main layout
    await expect(page.locator('.network-error')).toBeHidden();
  });
  
  test('shows offline indicator in UI', async ({ page, context }) => {
    await page.goto('http://localhost:3000');
    
    // Listen for offline event
    await context.setOffline(true);
    
    await expect(page.locator('[data-testid="offline-banner"]'))
      .toBeVisible({ timeout: 3000 });
    await expect(page.locator('[data-testid="offline-banner"]'))
      .toContainText('You are offline');
  });
  
  test('hides offline indicator when back online', async ({ page, context }) => {
    await page.goto('http://localhost:3000');
    
    await context.setOffline(true);
    await expect(page.locator('[data-testid="offline-banner"]')).toBeVisible();
    
    await context.setOffline(false);
    await expect(page.locator('[data-testid="offline-banner"]')).toBeHidden();
  });
});

Testing Offline Fallback Pages

A well-configured service worker serves a custom offline page when a requested resource isn't cached. Test this explicitly:

test('serves custom offline page for uncached routes', async ({ page, context }) => {
  // Prime cache for homepage only — NOT for /deep/uncached/route
  await page.goto('http://localhost:3000');
  await page.waitForTimeout(500);
  
  await context.setOffline(true);
  
  // Try an uncached route
  await page.goto('http://localhost:3000/deep/uncached/route');
  
  // Should see your custom offline page, not a browser error
  await expect(page.locator('h1')).toContainText("You're offline");
  await expect(page.locator('.retry-button')).toBeVisible();
});

test('offline page retry button works when reconnected', async ({ page, context }) => {
  await page.goto('http://localhost:3000');
  await page.waitForTimeout(500);
  
  await context.setOffline(true);
  await page.goto('http://localhost:3000/uncached');
  
  // Come back online
  await context.setOffline(false);
  
  await page.click('.retry-button');
  
  // Should successfully load now
  await expect(page.locator('h1')).not.toContainText("You're offline");
});

Testing Offline Write Operations

Most real PWAs need to handle user input while offline — form submissions, data edits, etc. These need to be queued and synced later:

test('queues form submission when offline', async ({ page, context }) => {
  await page.goto('http://localhost:3000/new-post');
  await page.waitForTimeout(500);
  
  // Go offline
  await context.setOffline(true);
  
  // Fill and submit a form
  await page.fill('[data-testid="post-title"]', 'My Offline Post');
  await page.fill('[data-testid="post-body"]', 'Written without internet');
  await page.click('[data-testid="submit-button"]');
  
  // Should show "saved locally" feedback, not an error
  await expect(page.locator('[data-testid="save-status"]'))
    .toContainText('Saved locally');
  
  // Post should appear in the list immediately (optimistic UI)
  await page.click('[data-testid="back-button"]');
  await expect(page.locator('[data-testid="post-list"]'))
    .toContainText('My Offline Post');
});

test('syncs queued submission when back online', async ({ page, context }) => {
  await page.goto('http://localhost:3000/new-post');
  await page.waitForTimeout(500);
  
  await context.setOffline(true);
  
  await page.fill('[data-testid="post-title"]', 'Sync Test Post');
  await page.click('[data-testid="submit-button"]');
  
  // Come back online
  await context.setOffline(false);
  
  // Wait for background sync to trigger
  await page.waitForTimeout(2000);
  
  // Post should now show "synced" status
  await expect(page.locator('[data-testid="sync-status"]'))
    .toContainText('Synced');
  
  // Verify it appears in the API (via page state or network call)
  await page.goto('http://localhost:3000/posts');
  await expect(page.locator('[data-testid="post-list"]'))
    .toContainText('Sync Test Post');
});

Testing IndexedDB Offline Storage

IndexedDB is the persistence layer for offline data. Test it directly:

test('stores data in IndexedDB when offline', async ({ page, context }) => {
  await page.goto('http://localhost:3000');
  await page.waitForTimeout(500);
  
  await context.setOffline(true);
  
  // Perform an action that should persist to IDB
  await page.click('[data-testid="add-to-favorites"]');
  
  // Check IndexedDB directly
  const idbData = await page.evaluate(async () => {
    return new Promise((resolve) => {
      const request = indexedDB.open('app-db', 1);
      request.onsuccess = (event) => {
        const db = event.target.result;
        const tx = db.transaction('favorites', 'readonly');
        const store = tx.objectStore('favorites');
        const getAll = store.getAll();
        getAll.onsuccess = () => resolve(getAll.result);
      };
    });
  });
  
  expect(idbData).toHaveLength(1);
  expect(idbData[0].id).toBeDefined();
});

Testing with Throttled Networks

Pure offline is easy. Slow networks are where bugs hide:

test('handles slow network gracefully', async ({ page }) => {
  // Simulate slow 3G
  const client = await page.context().newCDPSession(page);
  await client.send('Network.emulateNetworkConditions', {
    offline: false,
    latency: 400,          // 400ms latency
    downloadThroughput: 50000,   // 50kb/s
    uploadThroughput: 20000,     // 20kb/s
  });
  
  await page.goto('http://localhost:3000');
  
  // Should show skeleton/loading state, not blank page
  const skeleton = page.locator('.skeleton-loader');
  await expect(skeleton).toBeVisible();
  
  // Eventually content should appear
  await expect(page.locator('[data-testid="main-content"]'))
    .toBeVisible({ timeout: 15000 });
});

test('API timeout shows retry option', async ({ page }) => {
  // Intercept API calls with a long delay
  await page.route('**/api/**', async route => {
    await new Promise(resolve => setTimeout(resolve, 10000)); // 10s delay
    await route.continue();
  });
  
  await page.goto('http://localhost:3000/dashboard');
  
  // After timeout, should show retry UI
  await expect(page.locator('[data-testid="retry-prompt"]'))
    .toBeVisible({ timeout: 8000 });
});

Testing Cache Freshness

Offline content is only useful if it's reasonably fresh. Test your cache invalidation:

test('cached data shows timestamp and staleness warning', async ({ page, context }) => {
  // Load page with fresh data
  await page.goto('http://localhost:3000/dashboard');
  await page.waitForTimeout(500);
  
  // Advance time by more than your stale threshold
  await page.evaluate(() => {
    // If your app checks cache age in localStorage
    const cacheTime = Date.now() - (30 * 60 * 1000); // 30 minutes ago
    localStorage.setItem('last-sync', cacheTime.toString());
  });
  
  await context.setOffline(true);
  await page.reload();
  
  // Should show a "showing cached data from X ago" warning
  await expect(page.locator('[data-testid="stale-data-warning"]'))
    .toBeVisible();
});

Testing the Offline/Online Event Handlers

Your JavaScript code listens to window.online and window.offline events. Test that your app responds correctly:

test('triggers sync when connection is restored', async ({ page, context }) => {
  await page.goto('http://localhost:3000');
  
  // Set up a listener to count sync API calls
  let syncCallCount = 0;
  await page.route('**/api/sync', route => {
    syncCallCount++;
    route.fulfill({ status: 200, body: 'OK' });
  });
  
  // Create some offline changes
  await context.setOffline(true);
  await page.click('[data-testid="create-item"]');
  await page.fill('[data-testid="item-name"]', 'Offline item');
  await page.click('[data-testid="save"]');
  
  // Restore connection
  await context.setOffline(false);
  
  // Wait for sync to trigger
  await page.waitForTimeout(1000);
  
  expect(syncCallCount).toBeGreaterThan(0);
});

Automated Monitoring with HelpMeTest

Testing offline behavior manually — and keeping those tests green after every deployment — is where teams fall down. HelpMeTest lets you schedule offline mode tests to run automatically against your production PWA:

*** Test Cases ***
PWA Offline Mode Monitoring
    [Documentation]    Verify offline mode works after each deployment
    Open Browser    https://your-pwa.com    chromium
    Wait For Service Worker    activated
    Set Browser Offline    True
    Reload Page
    Element Should Be Visible    css:[data-testid="offline-banner"]
    Element Should Be Visible    css:main
    Element Should Not Be Visible    css:.crash-screen
    Set Browser Offline    False
    Element Should Not Be Visible    css:[data-testid="offline-banner"]

Schedule this to run after every deployment. If your service worker update breaks offline mode, you'll know within minutes — not after users file support tickets.

What to Skip in Offline Testing

Don't test every single route offline. Focus on:

  • Core user journeys — the things users actually do
  • Data entry flows — anything that writes data must handle offline gracefully
  • The offline page itself — navigation, retry buttons, layout
  • Recovery scenarios — what happens 5 minutes after reconnecting

Skip: testing that static analytics scripts fail gracefully, testing 3rd party widgets offline (that's their problem), testing browser-extension injected content.

Conclusion

Offline testing is non-negotiable for PWAs. Users who encounter a blank screen or "ERR_INTERNET_DISCONNECTED" error on a PWA are more frustrated than if they'd used a regular website — because a PWA promised them better.

The Playwright-based tests in this guide give you reliable, automated offline coverage. Run them in CI on every deployment, and schedule them to run against production daily. Offline bugs are subtle and often introduced by service worker updates that affect caching without obviously breaking anything in online mode.

Test offline. Test reconnection. Test with slow networks. Your users are counting on you.

Read more

Start now free