Progressive Web Apps: Service Worker Caching and Offline Patterns

Service workers sit between your application and the network. They intercept every fetch request, decide whether to serve from cache or network, and handle failures gracefully. This interception model makes progressive web apps possible — but the caching strategies you choose determine whether your PWA feels native or feels broken.

The gap between a demo PWA and a production PWA is almost entirely about cache management. Demos show a single caching strategy applied uniformly. Production apps mix three or four strategies, version their caches carefully, and handle the edge cases that demos ignore: partial network failures, stale authentication tokens, and cache storage pressure on low-end devices.

Service Worker Lifecycle Fundamentals

Understanding the service worker lifecycle prevents the most common PWA bugs. A service worker goes through three phases: registration, installation, and activation. Each phase serves a specific purpose, and skipping the nuances of any phase leads to caching bugs that are difficult to reproduce.

Registration tells the browser to download and parse the service worker file. Installation fires the install event, where you precache critical assets. Activation fires the activate event, where you clean up old caches. The critical detail most developers miss: a new service worker installs in the background but does not activate until all tabs using the old service worker close.

// sw.js — lifecycle with versioned caching
const CACHE_VERSION = 'v3';
const PRECACHE_ASSETS = [
  '/',
  '/app.css',
  '/app.js',
  '/offline.html',
  '/icons/logo-192.png'
];

self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_VERSION)
      .then(cache => cache.addAll(PRECACHE_ASSETS))
  );
});

self.addEventListener('activate', event => {
  event.waitUntil(
    caches.keys().then(keys =>
      Promise.all(
        keys
          .filter(key => key !== CACHE_VERSION)
          .map(key => caches.delete(key))
      )
    )
  );
});

The event.waitUntil call is not optional. Without it, the browser may terminate the service worker before the cache operation completes, leaving the precache in a partial state. A partial precache is worse than no precache — the app shell loads but critical assets are missing, producing a broken experience that appears to work.

Caching Strategies in Practice

Five caching strategies cover the needs of most progressive web apps. Choosing the right strategy for each resource type is the core architectural decision. Get it wrong and users see stale content, missing images, or white screens.

Cache First (Cache Falling Back to Network)

Cache first serves the cached response if one exists and only hits the network on a cache miss. This strategy works for assets that rarely change: fonts, icons, third-party library files, and versioned static assets where the URL changes when the content changes.

self.addEventListener('fetch', event => {
  if (event.request.destination === 'font' ||
      event.request.url.includes('/static/')) {
    event.respondWith(
      caches.match(event.request)
        .then(cached => cached || fetch(event.request)
          .then(response => {
            const clone = response.clone();
            caches.open('static-v1')
              .then(cache => cache.put(event.request, clone));
            return response;
          })
        )
    );
  }
});

The response.clone() call matters because a response body can only be consumed once. Reading the response to store it in the cache consumes the stream, so the original response would be empty when returned to the page. Cloning creates a second readable stream. This is a common source of bugs — forgetting to clone silently breaks the response.

Network First (Network Falling Back to Cache)

Network first attempts the network request and falls back to the cache if the network fails. This strategy suits content that should be fresh when possible but available offline: API responses, news articles, and dashboard data. The trade-off is latency — the user waits for the network request to either succeed or time out before seeing cached content.

async function networkFirst(request, cacheName, timeout = 3000) {
  const cache = await caches.open(cacheName);
  try {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), timeout);
    const response = await fetch(request, { signal: controller.signal });
    clearTimeout(timeoutId);
    if (response.ok) {
      cache.put(request, response.clone());
    }
    return response;
  } catch (err) {
    const cached = await cache.match(request);
    return cached || new Response('Offline', { status: 503 });
  }
}

Adding a timeout prevents the user from staring at a loading spinner while the network request hangs on a slow connection. Three seconds is a reasonable default — long enough for most mobile networks, short enough to avoid frustrating the user. The AbortController cancels the in-flight request cleanly when the timeout fires.

Stale While Revalidate

Stale while revalidate is the strategy most PWAs should use for the majority of their content. It serves the cached version immediately and updates the cache in the background. The user sees content instantly, and the next visit gets the fresh version. This works well for content that changes but does not need to be current on every single load — blog articles, product listings, user profiles, and core web vitals optimized pages.

async function staleWhileRevalidate(request, cacheName) {
  const cache = await caches.open(cacheName);
  const cached = await cache.match(request);

  const fetchPromise = fetch(request).then(response => {
    if (response.ok) {
      cache.put(request, response.clone());
    }
    return response;
  }).catch(() => cached);

  return cached || fetchPromise;
}

The App Shell Architecture

The app shell pattern precaches the minimal HTML, CSS, and JavaScript needed to render the application frame. Content loads dynamically after the shell renders. This architecture delivers instant first paint on repeat visits because the shell is always available from the cache, regardless of network conditions.

An effective app shell is smaller than you think. The HTML template, critical CSS, and navigation logic should total under 50 KB compressed. Anything larger delays the initial service worker installation and increases cache storage consumption on low-end devices where storage quotas are tight.

// Precache the app shell during install
const APP_SHELL = [
  '/shell.html',
  '/css/critical.css',
  '/js/navigation.js',
  '/js/router.js',
  '/images/logo.svg'
];

// Serve the shell for navigation requests
self.addEventListener('fetch', event => {
  if (event.request.mode === 'navigate') {
    event.respondWith(
      fetch(event.request)
        .catch(() => caches.match('/shell.html'))
    );
  }
});

The navigation fallback pattern shown above returns the cached shell HTML when a navigation request fails. The shell's client-side router then handles rendering the appropriate content. This pattern integrates cleanly with single-page application frameworks, and the architectural principles also apply when building web components that need offline resilience.

Background Sync for Offline Mutations

Reading data offline is straightforward — serve it from the cache. Writing data offline is harder. Background sync solves this by queuing mutations and replaying them when connectivity returns.

// In the page: queue the mutation
async function submitComment(comment) {
  try {
    const response = await fetch('/api/comments', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(comment)
    });
    return response.json();
  } catch (err) {
    // Store in IndexedDB for background sync
    const db = await openDB('offline-queue');
    await db.add('pending-comments', comment);
    const reg = await navigator.serviceWorker.ready;
    await reg.sync.register('sync-comments');
    return { queued: true };
  }
}

// In the service worker: replay when online
self.addEventListener('sync', event => {
  if (event.tag === 'sync-comments') {
    event.waitUntil(replayComments());
  }
});

async function replayComments() {
  const db = await openDB('offline-queue');
  const comments = await db.getAll('pending-comments');
  for (const comment of comments) {
    await fetch('/api/comments', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(comment)
    });
    await db.delete('pending-comments', comment.id);
  }
}

The sync event fires even if the user has closed the tab, which makes it more reliable than custom retry logic running in the page. Browser support is good in Chromium-based browsers. For Safari and Firefox, implement a fallback that retries pending mutations on page load using the same IndexedDB queue.

Cache Versioning and Updates

Cache versioning prevents users from seeing stale content indefinitely. The simplest approach uses a version string in the cache name. When you deploy a new version, the service worker opens a new cache, precaches updated assets, and deletes old caches during activation.

Build tools like Vite generate content-hashed filenames that make cache invalidation automatic for static assets. The challenge is the service worker file itself — browsers check for byte-level changes in the service worker script to trigger updates. Embedding the cache version in the service worker ensures updates propagate.

// Generate this at build time
const CACHE_VERSION = 'app-20261001-abc123';

// workbox-style precache manifest
const PRECACHE_MANIFEST = [
  { url: '/app.abc123.css', revision: null },
  { url: '/app.def456.js', revision: null },
  { url: '/index.html', revision: '20261001' }
];

Files with content hashes in their names (like app.abc123.css) set revision to null because the URL itself changes when the content changes. Files with stable URLs (like index.html) need an explicit revision that changes on each deploy. This dual strategy avoids unnecessary cache updates for unchanged files while ensuring mutable URLs stay fresh.

Handling Cache Storage Limits

Browsers enforce storage quotas that vary by device and browser. Chrome allows up to 60% of available disk space per origin. Safari on iOS limits each origin to roughly 50 MB. Exceeding the quota causes cache operations to fail silently unless you handle the error.

async function cacheWithQuotaCheck(cacheName, request, response) {
  try {
    const cache = await caches.open(cacheName);
    await cache.put(request, response);
  } catch (err) {
    if (err.name === 'QuotaExceededError') {
      // Evict least recently used entries
      const cache = await caches.open(cacheName);
      const keys = await cache.keys();
      const evictCount = Math.ceil(keys.length * 0.2);
      for (let i = 0; i < evictCount; i++) {
        await cache.delete(keys[i]);
      }
      // Retry the put
      const retryCache = await caches.open(cacheName);
      await retryCache.put(request, response);
    }
  }
}

A practical eviction strategy removes the oldest 20% of cached entries when storage pressure hits. For image-heavy applications, consider a separate cache for images with a strict size limit — images consume the most storage and are the easiest to re-fetch. Use the Storage Manager API to check available quota before deciding whether to cache large resources.

Testing Service Workers

Service worker bugs are notoriously difficult to reproduce because they depend on cache state, network timing, and lifecycle events that vary between sessions. A systematic testing approach covers three layers: unit tests for caching logic, integration tests for fetch interception, and end-to-end tests for offline behavior.

For unit tests, extract caching strategies into pure functions that take a request and return a response. Mock the Cache API and fetch to test each strategy in isolation. For integration tests, use Puppeteer or Playwright to register service workers, intercept network requests, and verify cache behavior. For end-to-end tests, toggle the network offline in the browser dev tools programmatically and verify the app remains functional.

// Playwright test for offline behavior
test('app works offline after first visit', async ({ page }) => {
  await page.goto('/');
  await page.waitForSelector('[data-loaded]');

  // Go offline
  await page.context().setOffline(true);

  // Navigate to a cached page
  await page.goto('/dashboard');
  const heading = await page.textContent('h1');
  expect(heading).toContain('Dashboard');

  // Verify offline indicator appears
  const indicator = await page.isVisible('.offline-banner');
  expect(indicator).toBe(true);
});

The most common testing gap is the update flow. Test that a new service worker version activates correctly, old caches are cleaned up, and the user sees the update prompt. This requires registering two versions of the service worker sequentially and verifying the transition. Many production PWA bugs live in this transition — the tests that cover it prevent regressions that only surface after deploys.

Workbox for Production PWAs

Workbox is Google's library for service worker management. It provides pre-built caching strategies, precache manifest generation, and routing helpers that eliminate the boilerplate of hand-written service workers. For most production PWAs, Workbox is the right choice — it handles edge cases around cache versioning, quota management, and TypeScript integration that hand-written service workers often miss.

// workbox-config.js
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate, CacheFirst } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';

precacheAndRoute(self.__WB_MANIFEST);

registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'images',
    plugins: [
      new ExpirationPlugin({
        maxEntries: 100,
        maxAgeSeconds: 30 * 24 * 60 * 60
      })
    ]
  })
);

registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new StaleWhileRevalidate({
    cacheName: 'api-responses',
    plugins: [
      new ExpirationPlugin({ maxEntries: 50 })
    ]
  })
);

Workbox's ExpirationPlugin solves the cache storage problem by enforcing both entry count limits and time-based expiration. When the cache exceeds maxEntries, the oldest entries are evicted automatically. This prevents the unbounded cache growth that eventually triggers quota errors on constrained devices.

Common Pitfalls and How to Avoid Them

Caching HTML pages aggressively breaks service worker updates. If the HTML page that registers the service worker is cached indefinitely, the browser never checks for a new service worker version because it never fetches the fresh HTML. Always use network-first or stale-while-revalidate for HTML navigation requests.

Caching API responses that contain authentication tokens creates a security vulnerability. If a user logs out and a different user logs in on the same device, cached API responses from the first user may be served to the second user. Clear authentication-sensitive caches on logout and avoid caching responses that contain user-specific data in shared caches.

Serving cached responses for POST requests breaks form submissions. The Cache API can technically store POST responses, but replaying a cached POST response skips server-side processing. Only cache GET requests. Use background sync for offline POST operations.

Registering the service worker on every page load adds unnecessary overhead. Check for existing registration before calling navigator.serviceWorker.register(), and defer registration until after the page has finished loading to avoid competing with critical resources for bandwidth and CPU.

Architecture Decision Guide

For content-heavy sites like blogs and documentation, use stale-while-revalidate for pages and cache-first for static assets. The app shell pattern is unnecessary — full-page caching provides a simpler architecture with the same offline benefit.

For interactive applications with user-generated content, use the app shell pattern with network-first for API calls and background sync for mutations. This combination delivers instant shell rendering with fresh data and reliable offline writes.

For e-commerce applications, use network-first for product pages (prices must be current) and stale-while-revalidate for category listings. Cache the checkout flow's static assets aggressively but never cache payment API responses. The integration with WebSocket-based real-time inventory requires careful coordination between the service worker cache and the WebSocket connection state.

Whatever strategy you choose, measure its impact on Core Web Vitals. A well-configured service worker improves Largest Contentful Paint on repeat visits by 40-60% because cached assets load from disk instead of the network. But a misconfigured service worker that intercepts and delays requests can actually hurt performance. Monitor the service worker's fetch event handler duration and cache hit rates to ensure the caching layer helps rather than hinders.