Say you are trying to make your React app load faster on a flaky mobile connection. You have read about service workers, you know they can intercept network requests, and you have seen the Lighthouse badge for “installable PWA” in your audit report. So you add a service worker, register it in your index.js, and call it done.

A week later, users start reporting that they see stale content even after you deployed a fix. Some see old JavaScript bundles for hours. One user cannot log out because the service worker keeps serving a cached session response.

This is the gap between the beginner approach to service worker caching and the advanced approach. The beginner pattern gets you a fast first load. The advanced pattern keeps your app fast, correct, and updateable over months of production use.

The Beginner Pattern: Precache Everything, Serve From Cache First

The most common starting point looks like this:

// sw.js — the naive version
const CACHE_NAME = 'my-app-v1';
const ASSETS = [
  '/',
  '/index.html',
  '/static/js/main.chunk.js',
  '/static/js/0.chunk.js',
  '/static/css/main.css',
];

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

self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request).then(cached => cached || fetch(event.request))
  );
});

This works on day one. The app loads offline, the shell renders instantly on repeat visits, and Lighthouse gives you a perfect score for “precaches URLs” and “has cache busting.” The problem is not what this code does. The problem is what it fails to do when your app changes.

The cache name is my-app-v1. When you deploy v2, this service worker keeps serving the old index.html and old chunks from my-app-v1 because the fetch handler checks that cache first and never looks for a newer version. The skipWaiting() call activates the new service worker, but the new worker’s install event only runs cache.addAll on whatever your build process listed at the time you wrote the file. If you hardcoded the asset list, the new version has a different list, but the old cache still holds the old files. Nothing invalidates them.

The result: users on the old service worker keep getting the old app indefinitely. Your deployment is invisible to them. You have built a caching layer that resists your own updates.

The Advanced Pattern: Versioned Caches with Stale-While-Revalidate

The production-grade approach separates two concerns: the app shell (static assets that rarely change) and the runtime responses (API calls, images, dynamic content). Each gets its own cache strategy.

For the static shell, you want precache-and-update semantics. Your build tool generates a manifest of files with content hashes in their filenames — main.a1b2c3.js, main.d4e5f6.js. The service worker precaches the exact list from the current build, and when a new build deploys, the new service worker has a new list. Old hashed files are deleted, new ones are added.

// sw.js — advanced shell handling
const CACHE_VERSION = 'shell-v2';
const PRECACHE_URLS = self.__WB_MANIFEST; // generated by Workbox

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

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

Notice the difference in the activate handler. Old cache versions are removed. The service worker takes control of all open clients immediately with clients.claim(), not just the first tab that registered it.

For runtime API requests, the strategy that survives real-world usage is stale-while-revalidate: serve the cached response immediately if it exists, then fetch a fresh copy in the background and update the cache. The user sees instant content on repeat visits, and the next visit gets even fresher data.

self.addEventListener('fetch', event => {
  const url = new URL(event.request.url);
  
  // API requests: stale-while-revalidate
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(
      caches.open('api-cache-v1').then(async cache => {
        const cached = await cache.match(event.request);
        const networkPromise = fetch(event.request).then(response => {
          if (response.ok) {
            cache.put(event.request, response.clone());
          }
          return response;
        }).catch(() => cached);
        return cached || networkPromise;
      })
    );
    return;
  }

  // Navigation requests: network-first, fallback to cache
  if (event.request.mode === 'navigate') {
    event.respondWith(
      fetch(event.request)
        .then(response => {
          const clone = response.clone();
          caches.open('shell-v2').then(cache => cache.put('/index.html', clone));
          return response;
        })
        .catch(() => caches.match('/index.html'))
    );
    return;
  }
});

The critical difference from the beginner version: navigation requests go to the network first. If the network succeeds, the fresh index.html replaces the cached one. Users who revisit after a deployment get the new shell immediately. Only when offline does the cache fallback kick in. This eliminates the “stale app after deploy” complaint entirely.

Registering the Service Worker: Updates That Miss

The beginner registration code in index.js usually looks like this:

if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/sw.js');
  });
}

That registers the worker but does nothing about updates. The browser checks for a new service worker on every navigation, but only if the register() call re-fetches the script. It does — the browser revalidates the script URL on each load. The problem is what happens after the new worker installs. Without a message or an update prompt, users on existing tabs keep the old worker until they close every tab and reopen.

For a content site, that delay is acceptable. For a React app with user state, sessions, and interactive features, it causes the exact confusion described earlier. The advanced approach listens for the updatefound event and prompts the user to refresh when a new version is ready:

if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/sw.js').then(registration => {
      registration.addEventListener('updatefound', () => {
        const newWorker = registration.installing;
        if (newWorker) {
          newWorker.addEventListener('statechange', () => {
            if (newWorker.state === 'activated' && navigator.serviceWorker.controller) {
              // New version is ready; use a toast or banner to prompt refresh
              showUpdateAvailableBanner();
            }
          });
        }
      });
    });
  });
}

The controller check is what separates a background update from a user-visible update. The first time a service worker registers, there is no controller — the app is loading fresh. On subsequent visits, when a new worker activates and there was already a controller, that is the signal to tell the user a refresh will load the newer version.

Handling the Cache for Images and Media

Images are the most common cache miss in a service worker strategy. The beginner pattern caches them all in one store with the same precache approach, which means every image that ever loads stays forever. Over weeks, that cache balloons to hundreds of megabytes.

The advanced pattern uses a size-limited cache with a max-entries policy:

self.addEventListener('fetch', event => {
  const url = new URL(event.request.url);
  
  if (url.pathname.startsWith('/images/')) {
    event.respondWith(
      caches.open('image-cache-v1').then(async cache => {
        const cached = await cache.match(event.request);
        if (cached) return cached;
        
        const response = await fetch(event.request);
        if (response.ok) {
          cache.put(event.request, response.clone());
          trimCache('image-cache-v1', 50); // keep max 50 entries
        }
        return response;
      })
    );
  }
});

async function trimCache(cacheName, maxEntries) {
  const cache = await caches.open(cacheName);
  const keys = await cache.keys();
  if (keys.length > maxEntries) {
    await cache.delete(keys[0]); // oldest first
  }
}

This keeps the cache bounded. For a gallery-heavy React app, the difference between an unbounded image cache and a bounded one is visible in the storage panel of DevTools. Users on constrained devices benefit the most from this discipline.

The Beginner Mistake That Breeds Confusion: Mixing Cache Strategies

A frequent source of bugs is applying a single fetch strategy to all requests. The beginner pattern caches everything with the same rule, which means API responses get served from cache even when the user has just performed a mutation. Consider a login endpoint: the fetch handler sees a POST request, matches the cache (which has an old success response), and returns that instead of hitting the network. The user sees a successful login from last week’s session.

The advanced pattern distinguishes request methods:

if (event.request.method !== 'GET') {
  // Never cache non-GET requests
  return;
}

That one line at the top of your fetch handler prevents a whole class of session and mutation bugs. Every POST, PUT, DELETE bypasses the cache entirely.

Comparing the Two Approaches Side by Side

Concern Beginner Pattern Advanced Pattern
Cache versioning Hardcoded version, never invalidated Generated manifest, old versions deleted on activate
Navigation requests Serve cached index.html forever Network-first, fall back to cache only offline
API requests Cached indiscriminately Stale-while-revalidate for GET, never cache mutations
Image storage Precached and kept forever Bounded cache with max-entries eviction
Update visibility Silent, users stuck on old version updatefound listener prompts refresh when new worker activates
First load performance Fast after precache Same, plus network-first for navigations ensures fresh shell

What the Advanced Pattern Costs You

The production-grade service worker is more code. It requires a build step to generate the manifest — Workbox handles this cleanly with its webpack or Vite plugin. It introduces a user-facing update prompt that needs a UI component. It demands that your API endpoints set proper cache headers, because stale-while-revalidate works best when the network response includes a Cache-Control header that tells the browser how long the cached version remains useful.

None of this is overhead you should skip for a marketing landing page. For a React app that people use daily, with accounts, data entry, and session state, the advanced pattern is the difference between a static snapshot and a live application. The beginner pattern impressed your Lighthouse audit on day one. The advanced pattern still impresses the audit months later, after three deployments, a schema change, and a user base on low-end Android devices.

Start with the naive version to understand the lifecycle. Then, before you put it in production, replace the hardcoded asset list with a build-generated manifest, switch navigation to network-first, and add the updatefound listener. Your future self, debugging a stale report on Monday morning, will know exactly which pattern you chose.