Your Service Worker Outlives Your Deploy

Everything else you ship can be rolled back. Bad HTML? Redeploy. Bad Cache-Control? Purge the edge. Bad asset? It was content-hashed, so the old one is still sitting there and nobody is asking for it.
A service worker is the one thing you ship that you cannot take back. It lives on the user’s device, it survives your deploy, and it decides — on hardware you will never touch again — whether that user sees your new build or the one from four months ago.
Most static sites have one because a tutorial said offline support was a free win, or because a PWA checklist wanted a green tick. It is not free. It is the only piece of your architecture that runs code you shipped in the past against content you shipped today.
The registration outlives everything
A service worker is not a build artifact. It is a registration: a persistent association between your origin and a script URL, stored in the browser’s profile.
Deploying does not touch it. Deleting sw.js from your repo does not touch it. The registration stays until the browser performs an update check and something about that check convinces it to move on.
That check happens on navigation, throttled, and only for a browser that is actually opening your site. A reader who bookmarked a post in March and comes back in September gets the September check — and until it completes and takes effect, they are being served by March’s logic.
That is the whole problem in one sentence: your worker’s release cadence is your users’ visit cadence, not your deploy cadence.
Waiting is the default, and waiting is stale
Here is the lifecycle people think they understand:
- Browser fetches the new
sw.jsand byte-compares it to the installed one. - Different? It installs the new worker.
- The new worker goes to
waiting. - It activates only when every tab controlled by the old worker is gone.
Step four is where static sites lose. A reload does not clear the old worker — the navigation hands the same client straight back to the existing controller. The user has to close every tab on your origin, and on mobile that “tab” may sit in the app switcher for weeks.
So the new worker installs immediately and then does nothing. The user keeps reading the old build. Your deploy succeeded, your CDN is correct, your HTML at the edge is perfect, and the browser is serving something else out of Cache Storage.
You will not find this in your analytics, your logs, or your uptime monitor. The requests never leave the device.
skipWaiting() trades a stale bug for a broken one
The standard fix is to jump the queue:
self.addEventListener('install', (event) => {
self.skipWaiting()
})
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim())
})
This works, and it introduces a worse failure. You have just swapped the controller underneath a page that is already running.
That page was rendered from build A. Its HTML references /_astro/hero.a1b2c3.js. Its router is going to lazy-load /_astro/post.d4e5f6.js the moment someone clicks a link. Every one of those filenames came from build A’s manifest.
The worker now controlling that page is from build B, holding build B’s precache manifest, on an origin where build A’s hashed files may no longer exist. The next chunk request returns a 404 or an HTML error page with a JavaScript content type, and you get a blank screen, a hydration error, or a dynamic import rejection that nothing catches.
Cache-first hides this in testing. It reproduces in the field, for the subset of users who happened to have a tab open during a deploy — which is exactly the subset you cannot reproduce locally.
skipWaiting() is only safe when you force a full navigation right after the swap, and a forced reload mid-article is a user-hostile fix for a problem you introduced.
Precaching is a second cache policy, written worse
You already have a caching layer. It is HTTP, it is declarative, and it is served by a CDN with more edge capacity than you will ever need:
/_astro/* Cache-Control: public, max-age=31536000, immutable
/index.html Cache-Control: public, max-age=0, must-revalidate
That is the whole strategy. Hashed assets are immutable forever. HTML revalidates on every request and costs a 304 when nothing changed.
A precaching service worker duplicates that in JavaScript, with worse properties:
- It is not declarative. Your cache policy is now imperative code with branches, and it runs before the network does.
- You own eviction. Old caches persist until your
activatehandler deletes them by name. Get the name wrong and you leak storage on the user’s device. - The browser can evict anyway. Under storage pressure an origin’s Cache Storage can be cleared out from under you. Your “offline” guarantee is best-effort, and it fails silently.
- It applies to the whole scope. A worker registered at
/controls every path on the origin — including the docs subpath someone else deploys and the endpoint you added last week.
You wrote a second Cache-Control in a language that has no linter, no validator, and no way to inspect what is live on a given device.
Cache-first HTML is a stale site by design
This is the pattern that ships in most starter templates:
// The tutorial version. Do not do this to a content site.
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((hit) => hit || fetch(event.request)),
)
})
Read what it does to a document request. If a copy of that URL is in Cache Storage, the network is never consulted — not for a revalidation, not for an ETag check. The cached HTML wins unconditionally, for as long as that entry exists.
The entire argument for static architecture is that the freshest HTML is already at the edge and a purge is effectively instant. Cache-first HTML inverts it. You have moved the source of truth from a CDN you control to a storage bucket on a phone you do not.
If you keep a worker at all, HTML is network-first with a cached fallback. The cache is the fallback, not the answer:
self.addEventListener('fetch', (event) => {
const { request } = event
if (request.mode !== 'navigate') return // let hashed assets hit the HTTP cache
event.respondWith(
fetch(request).catch(() => caches.match('/offline.html')),
)
})
Note the early return. Hashed assets do not need a worker — immutable already means the browser will not even ask. Intercepting them puts a JavaScript thread hop in front of a cache hit that was already free.
You cannot unship it by deleting the file
When you decide the worker was a mistake, removing sw.js from your build is not a removal plan. The registration is still on the device, still controlling pages, and it only learns anything on the next update check — which requires that user to come back at all. Browsers differ in what they do with a script that has started returning a 404, and none of that helps the client that never navigates again.
The reliable way out is a tombstone: keep serving something at the exact same URL, and make its only job be to remove itself.
// public/sw.js — the entire file. No fetch handler, no precache.
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 windows = await self.clients.matchAll({ type: 'window' })
for (const client of windows) client.navigate(client.url)
})(),
)
})
It installs, skips waiting, empties Cache Storage, unregisters the registration, and reloads open tabs once onto real network HTML. Then it is gone.
Two things make this work, and both are easy to break.
Serve it with Cache-Control: no-cache. If the tombstone is sitting in an HTTP cache behind a long max-age, the update check can be answered from that cache and the browser never sees the new bytes. A worker script is the one file where a long cache lifetime can lock you out of your own kill switch.
Leave it deployed for a long time. Months, not days. Every removal is gated on a return visit, and you have no list of who is still registered. Delete the tombstone too early and you are back to having an orphaned worker in the wild.
The thing worth internalising: you should write that file before you ship the worker, not after you need it. If you cannot articulate the removal path on day one, you are not deploying a cache — you are deploying a dependency on your users’ goodwill.
When it is actually worth it
None of this is an argument that service workers are bad technology. They are the right tool for a narrow set of problems:
- The app must function with no network at all — field data collection, transit and maps, anything used in a basement or on a plane.
- You are shipping a genuine installed product, where the worker is the runtime and its update mechanism is a designed feature rather than an afterthought.
- You need background sync or push, which have no other implementation.
A blog is none of those. Neither is a docs site, a marketing site, or a portfolio. For those, the offline story that matters is already handled by the HTTP cache: a revisit with a warm cache serves hashed assets from disk without a request, and the HTML is one cheap revalidation away.
You do not get a meaningfully faster repeat visit by adding a worker in front of that. You get the same speed, plus a stateful deployment target you cannot observe.
Check what you already have
This is a two-minute audit, and most people running a static site have never run it. In the console, on your production origin:
const regs = await navigator.serviceWorker.getRegistrations()
console.log(
regs.map((r) => ({
scope: r.scope,
script: r.active?.scriptURL,
waiting: Boolean(r.waiting),
})),
)
console.log(await caches.keys())
Things that should stop you:
- A registration you did not know about, inherited from a template, a previous framework, or a starter you cloned two rewrites ago.
waiting: trueon your own machine, which means the exact staleness above is happening to real readers right now.- Cache names with old build IDs in them, which means your
activatehandler never cleaned up.
Then look at how you serve the script itself:
curl -sI https://example.com/sw.js | grep -i 'cache-control\|content-type'
If that comes back with a long max-age, fix it today, whether or not you plan to keep the worker. That header is the difference between having a kill switch and having a kill switch you cannot reach.
The takeaway
Static hosting is good precisely because deployment is total and instantaneous: new files at the edge, old files irrelevant, rollback is just another deploy. A service worker opts you out of that, per device, with no telemetry and no recall.
If you need offline, pay the cost deliberately — network-first HTML, a versioned cache you clean up on activate, and a tombstone written before launch.
If you do not need offline, go and check whether you are running one anyway. The worker you forgot about is still working.

