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:
- Open DevTools → Application → Service Workers
- Check "Offline" to simulate no connectivity
- Reload the page — see what loads
- 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.