From lifecycle to the right caching strategy
A service worker runs as its own thread between the browser and the network, able to intercept requests, answer them from a local cache, and update that cache in the background. Used correctly, this delivers instant load times on repeat visits, working fallback pages without a network connection, and noticeably less server load, all without users noticing the difference from a native app.
Table of Contents
- 1. What a service worker is and why it matters for performance
- 2. The lifecycle: install, activate, fetch
- 3. Cache API vs. the browser's HTTP cache
- 4. Caching strategy: cache-first for static assets
- 5. Network-first and stale-while-revalidate
- 6. Precaching the app shell
- 7. Cache versioning and cleanup
- 8. PWA context in e-commerce
- 9. Debugging and common pitfalls
- 10. Summary
- 11. FAQ
1. What a service worker is and why it matters for performance
A service worker is a JavaScript program that the browser runs in its own thread, separate from the page's main thread and without direct access to the DOM. It is registered via navigator.serviceWorker.register(), after which it keeps running independently of any single tab's lifetime and can even stay active when no tab of the site is open, for example to handle push notifications. Conceptually, a service worker is a programmable proxy sitting between the page and the network that can see, redirect, or fully answer every outgoing request on its own.
That proxy position is exactly what matters for performance: a service worker can answer a request before it ever reaches the network. On a cache hit, the entire network round trip disappears, including DNS lookup, TLS handshake, and server processing, which on repeat visits is the difference between a load time of several hundred milliseconds and effectively zero. Unlike classic performance optimizations that operate on the server or during rendering, a service worker acts directly at the network layer, so it can control not just individual assets but potentially every single request the page makes.
2. The lifecycle: install, activate, fetch
The install event fires as soon as the browser detects a new or first-time version of the service worker file. A new version is detected through a byte-for-byte comparison against the previously registered file. Inside the handler, event.waitUntil(promise) tells the browser that the worker should only count as installed once the given promise resolves, typically after the app shell has been written to the cache. Calling self.skipWaiting() lets the new worker skip the waiting phase and activate immediately, instead of waiting for every open tab running the old version to close.
The activate event fires once a worker takes control. Without skipWaiting(), that only happens once no page is still controlled by the old worker. Inside the activate handler, the typical job is cleaning up outdated cache names and, if needed, migrating data structures. self.clients.claim() additionally makes the newly activated worker take over already open tabs immediately, without requiring a reload.
The fetch event fires for every network request within the worker's scope. Calling event.respondWith(promise) lets the handler decide which response to return: from the cache, from the network, or a combination of both. Because this decision can be made per request and per URL pattern, each resource type can get its own caching strategy instead of applying one global rule to the entire page.
// main.js: register the service worker from the page context
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/'
});
console.log('Service Worker registered with scope:', registration.scope);
// Detect when a new version has been found and installed
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
console.log('New content is available, please refresh.');
}
});
});
} catch (error) {
console.error('Service Worker registration failed:', error);
}
});
}
3. Cache API vs. the browser's HTTP cache
The classic browser HTTP cache is controlled through response headers like Cache-Control, ETag, and Last-Modified, and follows browser-specific heuristics. Developers only have indirect influence through header configuration and no guarantee that a resource stays available offline at all, because the HTTP cache is primarily designed to save bandwidth, not to guarantee offline availability. The Cache API, in contrast, is a fully scriptable low-level storage mechanism: the key is a Request object, the value a complete Response object, and what gets stored is decided entirely by your own code, independent of HTTP cache headers.
The core operations are deliberately simple: caches.open(name) opens or creates a named cache and returns a Cache object. cache.put(request, response) stores a specific request-response pair. cache.match(request) looks up a matching stored response. cache.addAll(urls) downloads multiple URLs and stores them atomically in a single call. caches.keys() lists all existing cache names for the origin. This Cache Storage is isolated per origin, shares its storage quota with IndexedDB, and can be fully inspected and manually edited in the Chrome DevTools panel under Application → Cache Storage.
4. Caching strategy: cache-first for static assets
With cache-first, the fetch handler first checks whether a matching response exists in the cache. On a hit, it is returned immediately, without making any network request at all. Only on a cache miss does the handler fall back to the network, and it then stores that response for future calls. This strategy is an excellent fit for static, versioned assets such as hashed JavaScript bundles, CSS files, and web fonts, whose content under a given URL never changes again. Latency on a cache hit is effectively zero, because no network round trip happens.
The trap with cache-first: if the strategy is accidentally applied to dynamic HTML, for example a product page without a versioned URL, the user gets permanently stuck on a stale version, even after a deployment. Safe use requires filenames that carry a content hash, such as app.a1b2c3.js, so a code change automatically produces a new URL and the old cache entry simply goes unused instead of needing active invalidation.
5. Network-first and stale-while-revalidate
With network-first, the handler first tries to fetch the response over the network and only falls back to the cached copy on failure, such as a lost connection or a timeout. This fits data that changes frequently, such as prices, stock levels, or login status, because every request in principle delivers the most current state. The downside: even when nothing has changed, every single request pays the full network latency, which makes network-first the slowest of the common strategies whenever the connection is anything less than ideal.
Stale-while-revalidate solves exactly that latency problem: the handler immediately returns the response sitting in the cache, potentially stale, while simultaneously triggering a background network request whose result refreshes the cache for the next call. The current request thus benefits from cache-level speed, while the cache itself is never more than one call behind reality. This strategy is a great fit for content like category pages or blog articles that change occasionally, but not every second.
// sw.js: fetch handler implementing stale-while-revalidate
const RUNTIME_CACHE = 'runtime-v3';
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return;
event.respondWith(
caches.open(RUNTIME_CACHE).then(async (cache) => {
const cachedResponse = await cache.match(event.request);
// Kick off the network request regardless of cache hit,
// so the cache is refreshed for the next visit
const networkFetch = fetch(event.request)
.then((networkResponse) => {
if (networkResponse.ok) {
cache.put(event.request, networkResponse.clone());
}
return networkResponse;
})
.catch(() => cachedResponse);
// Return cached copy immediately if available, otherwise wait for network
return cachedResponse || networkFetch;
})
);
});
6. Precaching the app shell
The app shell pattern separates the minimal, rarely changing skeleton of a page, meaning the HTML skeleton, critical CSS, and core JavaScript, from the dynamic content that loads fresh on every visit. Writing that shell fully into the cache during the install event means every later navigation renders the base structure instantly from the cache while the actual product data or content loads in parallel. For stores built on Hyva Theme and Alpine.js, this is particularly effective because the shell of header, footer, and layout skeleton usually stays unchanged between deployments and can be cleanly separated from the variable product content.
cache.addAll(urls) works atomically here: if the download of even a single URL from the list fails, for example due to a 404, the entire precache operation aborts and none of the files are stored. That forces careful maintenance of the precache list, ideally generated by a build step that produces an asset manifest with the actual, hashed filenames, rather than maintaining the list by hand in source code and forgetting to update it with every build.
// sw.js: install event precaching the app shell
const SHELL_CACHE = 'app-shell-v3';
const APP_SHELL_URLS = [
'/',
'/offline.html',
'/static/css/app.a1b2c3.css',
'/static/js/app.d4e5f6.js',
'/static/fonts/inter-var.woff2'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(SHELL_CACHE).then((cache) => {
// Atomic: if a single URL fails, nothing gets cached
return cache.addAll(APP_SHELL_URLS);
}).then(() => self.skipWaiting())
);
});
7. Cache versioning and cleanup
Caches created through the Cache API stay in storage indefinitely unless something actively removes them, the browser does not delete them automatically on a new deployment. The established solution is a fixed naming convention with an embedded version number, such as app-shell-v3 and runtime-v3. Every deployment bumps the version number, which automatically produces new, empty caches while the old versions keep existing untouched for the moment. In the activate event, caches.keys() lists every existing cache name, and the code deletes anything that does not belong to the current version list.
Without this cleanup step, storage usage keeps growing with every deployment, which becomes relevant on mobile devices with limited storage quota. navigator.storage.estimate() lets you query the currently used quota. If an origin significantly exceeds its quota, the browser can evict the oldest cache entries under storage pressure without warning, which leads to unpredictable behavior when there is no versioning strategy in place. The Cache API itself offers no automatic invalidation whatsoever, versioning is purely a naming discipline in your own code.
// sw.js: activate event cleaning up outdated cache versions
const CURRENT_CACHES = ['app-shell-v3', 'runtime-v3'];
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => !CURRENT_CACHES.includes(name))
.map((name) => {
console.log('Deleting outdated cache:', name);
return caches.delete(name);
})
);
}).then(() => self.clients.claim())
);
});
{
"cacheVersion": 3,
"cacheNames": {
"appShell": "app-shell-v3",
"runtime": "runtime-v3",
"images": "images-v3"
},
"precacheUrls": [
"/",
"/offline.html",
"/static/css/app.a1b2c3.css",
"/static/js/app.d4e5f6.js"
],
"maxRuntimeCacheEntries": 60,
"maxRuntimeCacheAgeSeconds": 604800
}
8. PWA context in e-commerce
A fully offline-capable checkout is not a realistic goal for a real store. Payment processing, live stock checks, tax calculation, and fraud prevention all require a round trip to the server, otherwise real business risks appear, such as overselling, stale prices, or unvalidated payment data. A service worker that accepts an order offline and merely pretends it is complete only hides a problem that resurfaces later as a data inconsistency. Offline checkout therefore deliberately does not belong on the goal list of a realistic e-commerce PWA strategy.
The realistic and genuinely valuable wins lie elsewhere: an offline fallback page that clearly communicates the lack of connectivity instead of a meaningless browser error; caching recently viewed product pages so users on a shaky mobile connection, say in a subway or an elevator, can keep browsing anyway; and the practically instant load time of header, footer, and category page skeleton on repeat visits thanks to app shell caching. The Background Sync API can additionally queue cart actions offline and replay them automatically once the connection returns, though final confirmation must always happen online.
9. Debugging and common pitfalls
The Chrome DevTools panel under Application → Service Workers shows the current registration status, allows a manual update through the "Update" button, and offers the "Update on reload" checkbox, which forces a new worker to install on every page refresh regardless of the byte comparison. The Cache Storage section lets you inspect individual cached entries, delete them manually, or clear the entire cache. The most common beginner mistake: the sw.js source code changes, but the browser never detects a new version because a forgotten, unchanged version constant lets the byte comparison pass as identical while the old worker stays in control.
Service workers only run over HTTPS, with the exception of localhost for local development, because the far-reaching interception capabilities would pose a significant security risk over plain HTTP. Scope defaults to the path of the sw.js file: a file registered at /app/sw.js only controls paths beneath /app/, unless the server allows a broader scope through the Service-Worker-Allowed header. And without the cleanup strategy from section 7, cache storage silently grows over months until the browser evicts data on its own under storage pressure, without warning.
The following overview compares the common caching strategies directly, to make the right choice easier for each resource type.
| Strategy | Use case | Latency | Data freshness | Offline capability |
|---|---|---|---|---|
| Cache-First | Static, versioned assets (JS, CSS, fonts) | very low (0 ms on hit) | only after new versioning | fully offline-capable |
| Network-First | Prices, stock levels, login status | high (network always first) | always current when online | falls back to cache on connection loss |
| Stale-While-Revalidate | Category pages, blog articles | low (instant from cache) | slightly delayed, updates in background | works offline with last known state |
| Network-Only | Checkout, payment processing | depends on network | always current | does not work offline |
| Cache-Only | Fixed bundled offline resources (app shell icons) | very low | never current without a new deploy | fully offline-capable |
Mironsoft
Performance engineering for Magento and Hyva stores
Ready for service worker and Cache API in your store?
We analyze which caching strategy fits which resource type in your store, implement a clean service worker lifecycle with proper versioning, and make sure offline behavior and cache cleanup work reliably.
Service Worker Audit
Checking lifecycle, scope, and cache versioning for error sources
Caching Strategy
Combining cache-first, network-first, and stale-while-revalidate deliberately
PWA Implementation
App shell precaching and offline fallback for realistic PWA goals
10. Summary
Service workers and the Cache API solve a very concrete problem: making repeat visits faster and making the page more resilient against unstable connections, without giving up server-side logic. The lifecycle of install, activate, and fetch provides the structure within which app shell resources get precached, old cache versions get cleaned up, and every single network request gets handled deliberately. Choosing the right strategy, cache-first for immutable assets, stale-while-revalidate for occasionally changing content, network-first for highly dynamic data, is what determines the balance between speed and freshness.
In an e-commerce context, realism matters most: a fully functioning offline checkout is not a sensible goal, because payment processing and stock levels require live data by necessity. Offline browsing of recently viewed products, a working fallback page, and instant load times on repeat visits are, by contrast, realistic and measurable improvements that can be achieved with manageable implementation effort, as long as cache versioning and cleanup are considered from the start.
Service Worker and Cache API - Key Takeaways
Lifecycle
install (precache, waitUntil), activate (cleanup, clients.claim), fetch (respondWith) form the fixed backbone of every service worker.
Caching strategies
Cache-first for static assets, network-first for dynamic data, stale-while-revalidate as a fast compromise in between.
Versioning & cleanup
Give cache names a version number and consistently delete old caches in the activate event, otherwise storage grows without bound.
Realistic PWA goals
Offline browsing and app shell caching are realistic, a fully offline checkout is not, due to the need for live data.