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:
- User-Prompted Reload: The application listens for
the
waitingworker state, displays a notification ("Update available. Click to reload"), and sends a message to the worker to invokeskipWaiting(), followed by a page reload. - Immediate Activation with Fallback: If
skipWaiting()andclients.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.