Service Worker Cache Versioning Best Practices

Stale asset lockouts occur when a Service Worker serves outdated cached assets indefinitely, frequently breaking single-page applications after a new deployment. To avoid trapping users in obsolete application states, engineering teams implement strict versioning protocols, automated cache invalidation hooks, and decoupled caching policies for HTML versus content-hashed assets. This article outlines the architectural patterns and Service Worker lifecycle controls required to ensure seamless updates and eliminate stale asset deadlocks.

1. Automating Cache Keys via Build Pipelines

Hardcoding cache names (such as cache-v1) creates high operational risk because developers frequently forget to increment versions manually. Modern workflows integrate cache naming directly into continuous integration and build pipelines.

Build systems generate dynamic identifiers derived from Git commit SHAs, package versions, or content hashes:

// Generated during build step
const CACHE_NAME = `app-shell-v-${process.env.BUILD_HASH}`;

When a new deployment occurs, the updated script introduces a new CACHE_NAME. The browser recognizes byte-level changes in the Service Worker file, triggering the installation of the updated worker alongside the existing one without immediately interrupting the user.

2. Purging Obsolete Caches During the activate Event

Generating new caches without deleting old ones leads to browser storage bloat and asset collisions. The standard pattern cleans up obsolete caches during the Service Worker's activate event, which executes after the new worker finishes installation but before it takes control of clients.

self.addEventListener('activate', (event) => {
  const currentCaches = [CACHE_NAME];
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames.map((cacheName) => {
          if (!currentCaches.includes(cacheName)) {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

Iterating over caches.keys() and purging unrecognized versions ensures that previous application versions are stripped from the client's storage immediately upon worker activation.

3. Preventing Service Worker Script Caching

A primary cause of permanent lockouts is caching the Service Worker file itself. If the browser stores sw.js under long-lived HTTP caching directives, it will never fetch the updated worker from the server to initiate the update cycle.

Server configurations must explicitly disable caching for the Service Worker registration endpoint:

Cache-Control: no-store, no-cache, must-revalidate, proxy-revalidate
Expires: 0

While modern browsers routinely check for byte-level updates on the Service Worker script every 24 hours regardless of headers, setting no-cache ensures update checks execute on every single page navigation.

4. Splitting Navigation and Static Asset Strategies

A fatal architectural mistake is applying a Cache-First strategy to index.html. If the root HTML file points to chunked assets with unique content hashes (e.g., bundle.a1b2.js), caching the HTML causes the client to continually request historical script hashes that may no longer exist on the server.

Teams structure their routing strategies conditionally:

  • HTML / Navigational Requests: Use a Network-First or Stale-While-Revalidate strategy. The client always attempts to receive the newest HTML containing references to modern script hashes.
  • Fingerprinted Static Assets (JS, CSS, Media): Use a Cache-First strategy with long-lived client retention, as the unique hash in the filename guarantees immutability.

5. Managing Lifecycle Shifts Safely

By default, an updated Service Worker enters a waiting state until all open tabs running the old version are closed. Forcing an update prematurely using self.skipWaiting() in the install phase can corrupt an active session if lazy-loaded chunks have changed.

Safe lifecycle transitions follow one of two patterns:

  1. User-Prompted Reload: The application listens for the waiting worker state, displays a notification ("Update available. Click to reload"), and sends a message to the worker to invoke skipWaiting(), followed by a page reload.
  2. Immediate Activation with Fallback: If skipWaiting() and clients.claim() are triggered automatically, the web server must retain historical chunk files across multiple consecutive deployments. This ensures running sessions can still fetch lazy-loaded dependencies created during the previous build.