1. The Bug That Took Three Weeks to Find
A retailer called me about a pricing problem. Some customers were seeing an old promotional price on a product page — a price that had ended eleven days earlier — and a handful had complained at checkout when the total didn't match. Support couldn't reproduce it. The dev team couldn't reproduce it. The CDN was purged, the platform cache was flushed, and it kept happening to a small, apparently random group of people.
The culprit was a service worker deployed eight months earlier as part of a PWA project that had otherwise been abandoned. It cached HTML responses with a cache-first strategy and no expiry. For any customer who had visited during the promotion, the service worker was serving that page from local storage, forever, with no mechanism to ever stop.
Nothing on the server could fix it. The CDN never saw the request. The customers were being served a page from their own device by code the company had forgotten it shipped.
That's the thing about service workers: they're the only part of your stack that lives on the customer's machine and keeps running after you've stopped thinking about it. Used well, they make a storefront feel instant and keep it usable on a bad connection. Used carelessly, they're a distributed cache you don't control, can't purge, and may not remember installing.
This article covers both halves — the caching strategies that are genuinely worth deploying on ecommerce, and the safety mechanisms that stop you shipping the bug above.
2. What a Service Worker Actually Is
A script that sits between your pages and the network. Once registered, it intercepts outgoing requests from any page on your origin and decides what to do with each: fetch it, serve it from a local cache, serve something else, or fail.
Three properties make it different from anything else you deploy.
It persists. Installed once, it stays until it's replaced or explicitly removed. Closing the tab doesn't uninstall it. Neither does clearing the ordinary cache, in most browsers.
It runs independently of your pages. It has its own lifecycle, can wake for background events, and continues to exist between visits.
It's a man-in-the-middle you authorised. Which is why it only works over HTTPS, and why a compromised service worker is one of the more serious client-side incidents available — it can rewrite every response the customer sees on your domain, and it survives.
The lifecycle is where most bugs live, so it's worth being precise:
Install. Fires when a new or changed worker file is fetched. Typically where you pre-cache assets. The new worker installs but does not take control.
Waiting. The new worker sits idle while the old one still controls open pages. This is the step people forget. A customer with your site open in a tab keeps the old worker until every tab is closed — not refreshed, closed.
Activate. The new worker takes over. Where you clean up old caches.
Fetch. The steady state, intercepting requests.
That waiting step explains why a service worker bug can outlive the fix by days. You deploy a corrected worker; a customer with a pinned tab keeps running the broken one until they close it. Which is why skipWaiting and clients.claim come up constantly, and why they're both more dangerous than they look.
3. Why This Is Harder Than a CDN Cache
Teams who are comfortable with CDN caching often assume a service worker is the same idea moved closer to the user. The mechanics rhyme; the operational properties do not, and the differences are exactly where the incidents come from.
You can purge a CDN. You cannot purge a service worker cache. A CDN gives you an API, a dashboard button, and a guarantee that within seconds every edge node has dropped the object. There is no equivalent here. The cache lives on hardware you have no access to, and the only way to change its behaviour is to get new worker code onto that device — which happens on the browser's schedule, not yours.
A CDN sees your headers. A service worker overrides them. Your carefully considered Cache-Control tells the browser's HTTP cache what to do. A service worker's fetch handler runs before that, and if it decides to answer from its own cache, your headers are irrelevant. This surprises people badly: they set no-store on a page, watch it get served stale anyway, and spend a day suspecting the CDN.
A CDN is one system you operate. A service worker is thousands of independent caches in unknown states. At any moment your customers are running some distribution of worker versions, with caches populated at different times containing different content. There is no console that shows you this. Reasoning about "what are people seeing" becomes genuinely difficult, and it gets harder the longer a bad version has been out.
A CDN fails safe. A service worker fails weird. When a CDN has a problem, requests fall through to your origin and you see them. When a service worker has a problem, requests stop reaching you entirely — your error rates look healthy, your traffic dips slightly, and the only signal is support tickets from people describing something you cannot reproduce.
None of this argues against using one. It argues for a specific posture: deploy the smallest thing that delivers the benefit, make the safety mechanisms the first code you write rather than the last, and treat every caching decision as something you will not be able to take back quickly. If you would not be comfortable serving a given response for a week without the ability to revoke it, do not put it in a service worker cache.
4. The Caching Strategies
Five patterns cover essentially everything. The skill is matching them to resource types, and the mistakes are nearly always a strategy applied to the wrong thing.
Cache-first
Check the cache; if it's there, serve it and never touch the network.
async function cacheFirst(request, cacheName) {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
}
return response;
}
Fastest possible response, and correct for exactly one category: immutable, content-hashed static assets. app.4f2a9c.js can never change, so caching it forever is safe.
Applied to anything mutable, this is the bug from the opening of this article. Never cache-first an HTML document. Never cache-first an API response containing price or stock.
Network-first
Try the network; fall back to cache if it fails.
async function networkFirst(request, cacheName, timeoutMs = 3000) {
const cache = await caches.open(cacheName);
try {
// Don't let a hanging request block the fallback indefinitely
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const response = await fetch(request, { signal: controller.signal });
clearTimeout(timer);
if (response.ok) cache.put(request, response.clone());
return response;
} catch {
const cached = await cache.match(request);
if (cached) return cached;
throw new Error('offline and not cached');
}
}
The right default for HTML on a storefront. Customers get fresh content when they're online, and something rather than a browser error when they're not. The timeout matters: without it, a customer on a flaky connection waits for a TCP timeout — which can be tens of seconds — before the fallback kicks in, and that's a worse experience than being offline.
Stale-while-revalidate
Serve the cached copy immediately, fetch a fresh one in the background for next time.
async function staleWhileRevalidate(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
const fetching = fetch(request).then(response => {
if (response.ok) cache.put(request, response.clone());
return response;
}).catch(() => cached);
return cached || fetching;
}
Excellent for things where slightly stale is fine and instant is valuable: category thumbnails, brand logos, review widgets, the site header's navigation JSON. Wrong for anything where "slightly stale" means "wrong price".
Network-only
Don't touch it. This is a strategy, and it's the correct one for a large part of a storefront:
const NEVER_CACHE = [
/\/checkout/,
/\/cart/,
/\/customer\//,
/\/api\/(cart|checkout|payment|account)/,
/\/admin/
];
function isNeverCache(url) {
return NEVER_CACHE.some(re => re.test(url.pathname));
}
Write this list before you write anything else. Everything involving money, identity, or session state goes on it. A cached cart response is a bug report; a cached checkout page is an incident.
Cache-only
Rare in ecommerce. Useful for a pre-cached offline fallback page and little else.
5. Pre-caching, and Why Less Is More
Most service worker tutorials open by pre-caching a list of files during install, so the site works offline from the very first visit. It is the most-copied pattern in this whole area and it deserves more scepticism than it gets.
The cost is real and lands at the worst moment. Pre-caching happens during a customer's first visit — while they are actively trying to look at your site — and it competes for the same bandwidth as the page they are waiting for. Pre-cache thirty assets on a mobile connection and you have made the first impression measurably slower in exchange for a benefit that only arrives if they come back.
There is a correctness cost too. A pre-cache manifest is a list of filenames, and filenames change every build. Get the list out of step with the build output and the install step fails, which in most implementations means the worker never activates at all — so the entire feature silently does nothing. This failure is quiet, and I have found it running in production more than once, months after the team assumed the worker was working.
What I do instead on a storefront: pre-cache almost nothing, and let the caches populate naturally as the customer browses. The runtime strategies described above fill the static cache during the first visit anyway, as a side effect of the page loading normally. By the second visit you have everything, at no cost to the first.
The exception worth making is a single offline fallback page — a small, self-contained document with inline styles and no external dependencies, which is the thing you show when someone is offline and asks for a page you have never cached. That's one small file, it never changes, and it's the difference between a branded message and the browser's error page.
If you do pre-cache more than that, generate the manifest from your build output rather than maintaining it by hand. A hand-written list is a list that will be wrong within two sprints, and the failure mode is that your entire service worker stops working without telling you.
6. Mapping Strategies to a Storefront
Concretely, what I'd deploy on a typical catalogue site:
| Resource | Strategy | Reason |
|---|---|---|
| Hashed JS/CSS | cache-first | Immutable by construction |
| Fonts | cache-first | Change almost never |
| Product images | cache-first, capped | Immutable URLs; bound the size |
| HTML documents | network-first, 3s timeout | Must be fresh; degrade gracefully |
| Category/nav JSON | stale-while-revalidate | Instant, staleness harmless |
| Price/stock API | network-only | Wrong is worse than slow |
| Cart, checkout, account | network-only | Session state, money |
| Search results | network-only | Unbounded keyspace, changes constantly |
Put together, the fetch handler stays readable if you route by type rather than by URL guessing:
const VERSION = 'v7';
const CACHES = {
static: `static-${VERSION}`,
images: `images-${VERSION}`,
pages: `pages-${VERSION}`,
data: `data-${VERSION}`
};
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Only handle same-origin GETs. Everything else goes straight to the network.
if (request.method !== 'GET') return;
if (url.origin !== self.location.origin) return;
if (isNeverCache(url)) return;
if (request.destination === 'document') {
event.respondWith(networkFirst(request, CACHES.pages, 3000));
} else if (request.destination === 'image') {
event.respondWith(cacheFirst(request, CACHES.images));
} else if (['script', 'style', 'font'].includes(request.destination)) {
event.respondWith(cacheFirst(request, CACHES.static));
} else if (url.pathname.startsWith('/api/catalog/')) {
event.respondWith(staleWhileRevalidate(request, CACHES.data));
}
// Anything not matched falls through to normal browser handling
});
Two deliberate choices in that handler. Returning early rather than calling respondWith means the browser handles the request normally — that's a safer default than routing everything through your code. And request.destination is more reliable than sniffing file extensions, which breaks on query strings and extensionless URLs.
7. Bounding the Cache
Storage is finite, and browsers evict whole origins when quota is exceeded — not gracefully, and not with any regard for which entries you cared about. A product-image cache with no limit on a large catalogue will grow until the browser deletes everything, including your app shell.
async function trimCache(cacheName, maxEntries) {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
if (keys.length <= maxEntries) return;
// keys() returns insertion order, so the oldest are first
await Promise.all(
keys.slice(0, keys.length - maxEntries).map(k => cache.delete(k))
);
}
// After writing an image, trim in the background
event.waitUntil(trimCache(CACHES.images, 120));
A hundred or so product images is plenty — enough to make back-navigation instant, small enough to stay well inside quota. Check how much you're actually using:
const { usage, quota } = await navigator.storage.estimate();
console.log(`${(usage / 1048576).toFixed(1)}MB of ${(quota / 1048576).toFixed(0)}MB`);
Also worth knowing: cached responses count toward the origin's quota alongside IndexedDB and everything else, and Safari is considerably more aggressive about evicting data from origins the user hasn't visited recently. Design so eviction is a performance regression, never a correctness one.
8. Deploying an Update Safely
This is the part that produces the incidents, so it deserves care.
The default behaviour is conservative: a new worker waits until every tab running the old one has closed. That's frustrating during development and correct in production, because it means a page never has its assets swapped underneath it mid-session.
self.skipWaiting() overrides it, activating immediately. It is widely copied and it has a real hazard: a page loaded with version 6's HTML can suddenly be served version 7's chunked JavaScript, and if the chunk names changed, the page breaks in ways that are miserable to reproduce.
The pattern I use instead — tell the user, let them choose:
// In the service worker: only skip waiting when the page asks
self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') self.skipWaiting();
});
// In the page: detect a waiting worker and offer a refresh
navigator.serviceWorker.register('/sw.js').then((registration) => {
registration.addEventListener('updatefound', () => {
const installing = registration.installing;
installing.addEventListener('statechange', () => {
if (installing.state === 'installed' && navigator.serviceWorker.controller) {
showUpdateBanner(() => {
registration.waiting?.postMessage({ type: 'SKIP_WAITING' });
});
}
});
});
});
// Reload once the new worker takes control
let reloading = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (reloading) return;
reloading = true;
window.location.reload();
});
The reloading guard is not optional. Without it, controllerchange can fire during the reload and trigger another, and you have built an infinite refresh loop that customers experience as a page flickering forever. I have shipped this. It is as bad as it sounds.
Cleaning up old caches belongs in activate, and it must be an allowlist rather than a denylist:
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
const keep = new Set(Object.values(CACHES));
const names = await caches.keys();
await Promise.all(names.filter(n => !keep.has(n)).map(n => caches.delete(n)));
await self.clients.claim();
})());
});
9. The Kill Switch
Write this before you ship anything else. It is the single most important paragraph in this article.
If a service worker starts serving something wrong, you cannot fix it from the server, because the request never reaches you. Your only lever is the service worker file itself, which the browser checks periodically — and that check is your one route back. So the worker must always be able to unregister itself on command.
// sw.js — check a kill switch before doing anything else
const KILL_URL = '/sw-kill.json';
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
try {
const res = await fetch(KILL_URL, { cache: 'no-store' });
if (res.ok) {
const { disabled } = await res.json();
if (disabled) {
const names = await caches.keys();
await Promise.all(names.map(n => caches.delete(n)));
await self.registration.unregister();
const clients = await self.clients.matchAll({ type: 'window' });
clients.forEach(c => c.navigate(c.url));
return;
}
}
} catch {
// Network unavailable — carry on as normal rather than disabling
}
await self.clients.claim();
})());
});
Then, if something goes wrong, you publish {"disabled": true} and every browser stands down as it next updates the worker.
Two important limits, stated honestly. Browsers check the worker file roughly every 24 hours, or on navigation if the cached copy is over a day old — so this is not instant. And it only works if the worker file itself isn't being served from cache, which is why the fetch uses no-store and why the server should send Cache-Control: no-cache for /sw.js. A service worker file cached for a year is a worker you cannot update. That is the failure mode from the top of this article, and it is unrecoverable except by waiting out the cache.
location = /sw.js {
add_header Cache-Control "no-cache, max-age=0, must-revalidate" always;
add_header Service-Worker-Allowed "/" always;
}
location = /sw-kill.json {
add_header Cache-Control "no-cache, max-age=0" always;
}
And the standalone removal worker, for when you want service workers gone entirely — deploy this as sw.js, replacing whatever was there:
// sw.js — the eviction worker. Ship this to remove a bad deployment.
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
const names = await caches.keys();
await Promise.all(names.map(n => caches.delete(n)));
await self.registration.unregister();
const clients = await self.clients.matchAll({ type: 'window' });
clients.forEach(c => c.navigate(c.url));
})());
});
10. What Offline Should Actually Do
"Works offline" gets promised more than it gets thought through. A customer with no connection cannot check stock, cannot see accurate prices, and definitely cannot place an order. Pretending otherwise produces a worse experience than an honest failure.
What genuinely helps:
Browsing pages already visited. Someone on a train, in a lift, or on patchy rural data can keep reading a product page they opened two minutes ago instead of hitting a browser error. This is the real win and it comes free with network-first HTML.
A branded offline page instead of the browser's dinosaur, with an honest message and a retry button.
Instant back-navigation. Cached images and static assets make returning to a previous page feel immediate even on a good connection.
What doesn't help: caching the cart, queueing orders for later submission, or showing prices you can't verify. Order queueing in particular sounds clever and is a genuine minefield — stock changes, prices change, cards expire, and a background sync that submits an order the customer has forgotten about is a chargeback waiting to happen. I'd not build it for a general storefront.
Be explicit about staleness where it matters:
// Mark cached HTML so the page can tell the customer what it's showing
const stamped = new Response(await cached.text(), {
status: cached.status,
headers: new Headers([...cached.headers, ['X-Served-From-Cache', 'true']])
});
Then have the page read that and show a quiet banner: "You're offline — showing a saved version of this page. Prices may have changed." Customers forgive staleness they were warned about.
11. Background Sync and Push: Worth It?
Two capabilities frequently bundled into the same project, both worth a sceptical look on an ecommerce site.
Background Sync
Lets you register an action to be retried when connectivity returns, even if the customer has navigated away or closed the tab. The demo is always the same: a form submission that survives a tunnel.
For a newsletter signup or a product review, that's genuinely nice — the action is idempotent-ish, the stakes are low, and a delayed submission harms nobody. For anything commercial it gets uncomfortable fast.
Consider an order queued while offline and submitted forty minutes later. In that window the price may have changed, the item may have sold out, a promotion may have expired, and the customer may have concluded the order failed and placed it again somewhere else. Now they have two charges, or an order they don't want, or a support conversation that starts with "I never pressed buy". Every one of those is worse than an honest error message at the moment of failure.
The pattern I'd accept is narrower: queue the intent, not the transaction. Save what the customer was trying to do, and when connectivity returns, bring them back to a live page with that state restored so they can confirm against current prices and stock. It's more work and it is the only version I'd put in front of a payment.
Support is also uneven across browsers, so anything you build must degrade to an ordinary online-only flow regardless.
Push notifications
Technically enabled by the same service worker, and a different discipline entirely. The engineering is straightforward; the judgement is where sites go wrong.
The failure mode is asking for permission on the first page view. Conversion on that prompt is poor, and a denial is close to permanent — browsers make it deliberately awkward to reverse, and several now suppress repeated prompting from sites that abuse it. You get roughly one ask per customer, ever.
So spend it well: ask at a moment where the value is obvious and specific, such as after a customer taps "notify me when back in stock". That's a request with an evident purpose, and acceptance rates for it are far better than a generic prompt about offers. And whatever you promise at the moment of asking is what you should send — a permission granted for stock alerts and then used for weekly promotions is how a site earns a permanent block.
My general advice: build the caching layer first, ship it, and treat push as a separate project with its own justification. Bundling them means a permission prompt lands in the same release as a caching change, and if either causes a problem you will not know which.
12. Service Workers and SEO
Short version: they don't affect indexing, and the ways they can hurt you are indirect.
Googlebot does not run service workers. Each crawl is effectively a first visit with no worker registered, so whatever your worker does is invisible to indexing. You cannot use one to serve different content to crawlers, and you'd be cloaking if you tried.
The indirect effects that are real:
Core Web Vitals improve for returning visitors. Cached static assets mean faster repeat views, and field data includes those sessions. A modest but genuine improvement.
Stale content can be indexed indirectly. Not through Googlebot, but through customers: if your worker serves an outdated price and a customer complains publicly, or if a stale page gets shared, the consequences are real even though the crawler never saw it.
A broken worker can hide errors from you. Customers served from cache don't hit your server, so your error rates look fine while a portion of your audience sees a broken page. This is worth remembering when metrics disagree with support tickets.
If you're building a PWA specifically for search reasons: don't. There's no ranking benefit for being installable. Build one because the offline and repeat-visit experience is worth it, and treat the performance improvement as the benefit.
13. The Security Side
A service worker can rewrite every response on your origin and it persists across sessions. That combination makes it a more attractive target than most client-side code, and worth a few specific precautions.
Scope is the first control. A worker's authority is limited by the path it's served from — one at /js/sw.js can only control /js/ unless you explicitly widen it with the Service-Worker-Allowed header. Serving it from the root gives it the whole origin, which is usually what you want and should be a deliberate choice rather than an accident of where your build put the file.
It is a script like any other, so CSP applies. The worker-src directive governs what may be registered as a worker, and restricting it to 'self' means an injected script cannot register a worker from an attacker's domain. This is cheap and worth having:
Content-Security-Policy: worker-src 'self'; script-src 'self' 'nonce-abc123'
The reason it matters more than it looks: an XSS bug that registers a hostile service worker converts a transient injection into a persistent one. The attacker's code survives the page reload, survives the fix you deploy, and keeps intercepting requests until the worker is evicted. It's one of the few ways a cross-site scripting bug becomes a long-lived compromise, and it's why worker-src belongs in your policy even if you don't use service workers at all.
Never cache authenticated responses. The never-cache list earlier in this article is a security control as much as a correctness one. A cached account page on a shared or family device is a data exposure, and the storage outlives the session in a way an ordinary browser cache generally does not.
Clear caches on logout. Whatever you have cached that could relate to a specific customer should go when they sign out, and the page should tell the worker to do it:
// On logout, from the page
navigator.serviceWorker.controller?.postMessage({ type: 'CLEAR_PRIVATE' });
// In the worker
self.addEventListener('message', async (event) => {
if (event.data?.type === 'CLEAR_PRIVATE') {
await caches.delete(CACHES.pages);
await caches.delete(CACHES.data);
}
});
Treat the worker file as a high-value artefact. Anyone who can write to it controls every response your customers see. It deserves the same review, deploy controls, and integrity checks as your payment code — and rather more scrutiny than its eighty lines would normally attract.
14. Debugging
Service worker bugs are hard because your browser is in a different state from your customers'. Some habits that help.
Use Application → Service Workers in DevTools. "Update on reload" during development, and "Bypass for network" when you need to check whether the worker is the cause of something. If a bug disappears with bypass enabled, you've found your culprit in ten seconds.
Test in a fresh profile, not an incognito window. Incognito behaves differently with service workers across browsers, and testing there will mislead you.
Log what the worker decides. Not every request — that's unusable — but the decisions:
const DEBUG = self.location.hostname !== 'shop.example.com';
function decide(request, strategy) {
if (DEBUG) console.log('[sw]', strategy, new URL(request.url).pathname);
return strategy;
}
Reproduce the customer's state. Visit with the old worker, deploy, then observe without clearing anything. Most update bugs only appear in that transition, which is exactly the state a developer never sees because they clear storage reflexively.
Keep a version marker you can read from the page so support can ask a customer what they're running:
// In the page
navigator.serviceWorker.controller?.postMessage({ type: 'VERSION' });
navigator.serviceWorker.addEventListener('message', (e) => {
if (e.data?.type === 'VERSION') console.log('sw version:', e.data.version);
});
15. Measuring Whether It Helped
The benefit of a service worker is concentrated in sessions you would otherwise never hear about, so the measurement needs a little thought. Three things worth tracking, and one trap.
Split your field data by whether a worker was controlling the page. This is the comparison that answers "did it work", and it's a single property:
const controlled = Boolean(navigator.serviceWorker?.controller);
// Attach to your existing RUM payload as a dimension
reportMetric('lcp', value, { controlled, repeat: isRepeatVisit() });
First visits are never controlled — the worker installs during that visit and takes effect on the next — so comparing controlled against uncontrolled is largely comparing repeat visits against first visits, which is not a fair fight. Segment by repeat-visit status as well, or you'll credit the worker with the ordinary benefit of a warm HTTP cache.
Count offline rescues. The number of times the worker served a page while the network was unavailable. This is the metric that justifies the project to anyone asking, because each one is a session that would have ended at a browser error:
// In the worker's network-first fallback path
self.registration && reportEvent('offline_served', { path: url.pathname });
Watch storage usage in the field, not just locally. Quota pressure produces the worst class of bug — eviction of the assets you most wanted cached — and it only appears for customers who browse heavily. Sample navigator.storage.estimate() from real sessions and look at the upper percentiles rather than the average.
The trap: cache hit rate is a tempting metric and a misleading one on its own. A worker with a 95% hit rate might be doing excellent work, or it might be aggressively serving stale content that should have come from the network. Hit rate only means something alongside a correctness signal — stale-content complaints, support tickets about prices, or a deliberate check comparing cached responses against live ones. High hit rate is what the bug at the top of this article looked like in a dashboard.
16. A Deployment That Went Well
A specialist outdoor equipment retailer, meaningful traffic from customers in areas with poor mobile coverage — which is unsurprising given what they sell.
Scope. Deliberately narrow. Cache-first for hashed assets and fonts, capped cache-first for product images, network-first with a three-second timeout for HTML, network-only for everything transactional. No background sync, no order queueing, no install prompt in the first phase.
Safety first. The kill switch went in before any caching logic, and they tested it — deployed the worker, confirmed caching worked, flipped the kill file, confirmed it stood down. That rehearsal took an hour and is the reason I'd call this deployment a success regardless of the numbers.
Rollout. Five percent of traffic for a week, via a flag that controlled whether the registration script ran at all. Then 25%, then everyone. At 25% they found a real bug: their cart badge read a cached JSON endpoint they'd forgotten was under /api/catalog/, so it showed a stale item count. Caught at 25%, fixed in a day, and it would have been a much worse week at 100%.
Results. Repeat-visit LCP improved by roughly 40% — expected, since the assets were local. Bounce rate on mobile in low-connectivity regions dropped noticeably, which was the actual goal. Around 3% of sessions served at least one page from cache while offline, which sounds small until you consider those were sessions that previously ended at a browser error page.
What we got wrong. The initial image cache limit was 500 entries, which pushed some users close to their storage quota and caused eviction of the static cache — so repeat visits got slower for a subset of customers. We found it because a support ticket mentioned the site being slow "after browsing a lot". Dropped to 120 and the problem disappeared. Bounded caches are a correctness feature, not a tidiness one.
17. Testing Before It Reaches Anyone
The hardest thing about service worker changes is that the interesting states are transitions — old worker to new, populated cache to evicted, online to offline — and none of them occur in the state a developer's browser is usually in. A short checklist that catches most of what matters.
The first-visit path. Fresh profile, no worker, no caches. Does the page load at its normal speed, or has installation slowed it? Compare against the same page with the registration script disabled.
The second-visit path. Reload after the worker has installed. This is where the benefit should appear, and where you confirm the worker is actually controlling requests rather than sitting idle.
The upgrade path. Install version A, browse a few pages, deploy version B, and then — without clearing anything — reload. This is the state every returning customer will be in on the day you deploy, and it is the state almost nobody tests. Check that the old caches are cleaned up, that the page doesn't break mid-session, and that the update banner behaves.
The offline path. Browse three pages online, then switch to offline in DevTools and navigate back to them. Then request a page you never visited and confirm you get the fallback rather than a browser error.
The quota path. The one people skip. Browse enough of the catalogue to fill the image cache past its limit and confirm the trimming actually runs, then check that the static cache survived. If your eviction logic is wrong, this is where it shows.
The kill switch. Flip it in a staging environment and watch the worker unregister and the caches disappear. Do this before you need it, not during an incident, because the one time you reach for it you will be under pressure and you want to already know it works.
None of this is automatable in any pleasant way, which is part of why service worker bugs survive to production. It's perhaps twenty minutes of manual work per meaningful change, and it is the cheapest insurance available against a category of bug that is unusually expensive to fix once it's out.
18. Questions That Come Up
"Do I need a service worker at all?" Honestly, often not. If your audience is on reliable connections and your site is already fast, the benefit is modest and the risk is real. The case is strongest for audiences with genuinely poor connectivity and for sites with high repeat-visit rates.
"Can I cache the cart to make it feel faster?" Don't. Cart state changes from other tabs, other devices, and stock movements. The failure mode is a customer checking out with a cart they don't have.
"What about Workbox?" A reasonable choice — it handles the fiddly parts of the lifecycle, expiration, and routing, and its strategies map onto the ones described here. Understand the model first, because a bug in a library you don't understand is harder to diagnose than a bug in eighty lines you wrote. And whatever you use, add the kill switch yourself.
"How do I get it off a site that already has one?" Deploy the eviction worker above as sw.js and wait. Browsers pick it up within a day or so. Don't delete the file — a 404 leaves the existing worker installed and running, which is the opposite of what you want.
"Does it work on iOS?" Yes, though Safari has historically been stricter about evicting storage from origins the user hasn't visited recently, and some capabilities lag. Assume caches may vanish and treat that as normal.
"Should I add an install prompt?" Separate decision from caching. If you do, don't fire it on the first page view — that converts badly and annoys people. Trigger it after a signal of genuine engagement, such as a second visit or an add-to-cart.
19. The Rule I'd Take Away
A service worker is the only code you deploy that keeps running on someone else's device after you've forgotten about it. Everything else — a bad CDN rule, a broken template, a wrong price in the database — you can fix centrally and know it's fixed. This you cannot.
So the order of work is: write the kill switch, then the never-cache list, then the caching. Roll out behind a flag to a small percentage. Set Cache-Control: no-cache on the worker file itself, because getting that wrong is the one mistake with no recovery.
The retailer at the top of this article wasn't careless. They shipped a reasonable-looking service worker as part of a PWA project, the project was deprioritised, the people moved on, and the code kept running for eight months. Nobody was watching because nobody remembered it existed.
If you take one action from this: open DevTools on your own storefront right now, go to Application → Service Workers, and find out whether you have one. A surprising number of teams discover they do.