On this page 10 sections
- A service worker sits between the browser and the network
- What crawlers are likely to do with one
- HTML in the cache is the risky part
- Assets, offline fallbacks and ‘app shell’ thinking
- A conservative default for a content site
- Updating, skipping waiting, and a kill switch
- When a PWA needs more than this
- How to test the cache you think you have
- Key takeaways
- Frequently asked questions
For a small content site I would rather have no service worker than a clever one that caches documents. If you do add one, the conservative default is: hashed assets may be cached, HTML should come from the network first, and any offline fallback should live on a URL you do not want indexed. The Service Worker Cache Strategy Builder is a way to think that split through before you write the script.
A service worker sits between the browser and the network
A service worker is a script the browser installs, then runs in the background. Once it controls a page, it can intercept fetch events and decide whether to answer from a cache, from the network, or from a fallback you prepared.
That is powerful, and it is local to the browser that installed the script. It is not a replacement for HTTP cache headers, and it is not a CDN. The first visit still has to download the worker and the pages; only later visits on that device can be served from the worker’s cache.
A few properties shape everything that follows:
- It must be served over HTTPS, from a scope you control. A worker registered at
/sw.jswith the default scope can control the whole origin. A worker under/app/sw.jscan be limited to/app/. - It has a lifecycle. The browser downloads a new worker, it sits in a waiting state until the old one is released, then it activates. If you never skip waiting, users can stay on an old worker. If you always skip waiting and claim clients immediately, they can change behaviour mid-session.
- The cache is yours to name and to empty.
Cache-Controlon the HTTP response still matters for the network request the worker makes. The HTTP Caching Header Simulator is useful for that layer; the worker is a second, separate store on top. I go into the header side at more length in HTTP caching headers explained.
Registration should be progressive, and it should happen only on the origins you mean to control:
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js");
});
}Don’t register a worker from a preview hostname if that hostname is not supposed to behave like production, and don’t register one just because a starter template did.
What crawlers are likely to do with one
This is the part it is tempting to over-claim. Crawler behaviour changes, and it is not identical across Google, other search engines, and AI fetchers.
What we can say honestly:
- Most crawlers start with an HTTP request to the URL. The HTML in that response is still the foundation. If the article is in the first response, you are already in a better place than if it appears only after client-side rendering. I go into that split in Making a Cloudflare Workers site searchable.
- Google can render JavaScript in a later phase of processing, as described in Google’s JavaScript SEO basics. That rendering pipeline is not a full copy of a long-lived user browser: storage is not a reliable way to pass state from one URL to the next, and you should not assume every web API is available.
- I will not claim that Googlebot always installs, or never installs, your service worker. Official guidance is clearer about rendering JavaScript, HTTP requests and cache headers than it is about service-worker interception. Treat any stale or empty document in Search Console as a problem to debug from the origin and the CDN first, and only then look at what browsers are being served from the worker.
Other crawlers, including many AI tools, will often not run your JavaScript at all. For those, the worker is irrelevant; the raw HTML is everything.
The practical conclusion does not depend on a controversial claim about Googlebot. If a service worker can serve stale HTML or a shell to people, you have a product problem. If the origin or the CDN can serve that same shell with status 200, you also have an indexing problem. Those two bugs often share a cause: an app-shell architecture that doesn’t have a real document per URL.
HTML in the cache is the risky part
Caching a logo for a week is usually fine. Caching /articles/some-slug for a week is how a typo, a price change or a legal correction stays on screen after you have fixed it.
The risky patterns:
- Precaching a list of HTML documents at install time. The moment you deploy a new version of an article, every browser with the old worker still has the old HTML, until the worker updates and those entries are replaced.
cache-firstfor navigations. The first cached copy wins, even if the network has a newer one.- An app shell stored under real page URLs. The worker, or the origin, returns a generic
index.htmlwith status 200 for/pricingor/blog/a-title. The user (and any crawler that sees that response) gets a frame without the article. - A Workbox-style
navigateFallbackthat points at a shell or an offline page, if that fallback can be mistaken for the requested URL at the origin. The fallback itself is useful; serving it as if it were the article is not.
A safer pattern for documents is network first, with a short-lived cache as a fallback:
self.addEventListener("fetch", (event) => {
const request = event.request;
if (request.mode !== "navigate") return;
event.respondWith((async () => {
try {
const fresh = await fetch(request);
const copy = fresh.clone();
const cache = await caches.open("pages-v1");
cache.put(request, copy);
return fresh;
} catch {
const cache = await caches.open("pages-v1");
const cached = await cache.match(request);
return cached || caches.match("/offline");
}
})());
});Even that pattern needs a versioned cache name and a cleanup on activate, or the fallback will eventually be old. For a marketing or article site that is rarely used offline, you can go further and not cache HTML at all, only falling back to /offline.
Assets, offline fallbacks and ‘app shell’ thinking
Hashed CSS, JavaScript and images are the easy case. When the filename changes with the content, you can cache them aggressively in the worker and leave them out of the next release’s precache list. Unhashed assets (/styles.css) should be revalidated, or not cached in the worker for long, for the same reason unhashed HTML is risky.
An offline fallback is worth having if people genuinely use the site on a train. Give it its own URL, such as /offline, and make it a small, honest page: “You appear to be offline. Pages you have already opened may still be available.” That page should:
- send
noindex(a meta tag and, if you can,X-Robots-Tag) - stay out of the sitemap and out of the site’s main navigation
- not be the 200 response for any other URL at the origin
Crawlers can still discover /offline from the registration script or a navigateFallback string. noindex is what keeps it out of search. A robots.txt Disallow alone can leave a URL “indexed, though blocked,” which is not what you want.
App-shell thinking is right for a web app whose UI frame is stable and whose content is loaded as data. It is usually wrong for an article site. If each URL is a document, let it be a document. The Web App Manifest Validator is useful if you also ship a manifest, but a manifest and a service worker do not require a shell architecture.
A conservative default for a content site
If you publish articles, a homepage, a contact form and a handful of tools, this is the default I would ship:
- No service worker, unless you have a specific offline or installability need you can describe in a sentence.
- If you do need one: register it only on the canonical host, after a successful load, with a narrow scope if it does not have to control the whole site.
- Precache only the hashed assets and the offline page. Don’t precache the homepage or the article index just because they are popular.
- Navigations are network first, or network only, with
/offlineas the last resort. - Never return the offline page or a shell from the origin as a 200 for a missing or real article URL. The origin should keep sending real documents and real 404s, for the reasons in the indexability check: status, canonical and robots should describe the same page.
- Bump the cache name when the worker’s behaviour changes, and delete old caches on activate.
That default will not make the site feel like a native app. It also will not surprise you after a typo fix. Performance still comes mostly from small HTML, cacheable hashed assets and a sensible HTTP cache, which is the same budget thinking I use when I look at battery work on iOS or when I am finding performance problems with Instruments: do less, and be sure about the work you keep.
Updating, skipping waiting, and a kill switch
A worker that never updates is a liability. The browser will check for a new sw.js on a schedule, but only if the file’s bytes change. Serve sw.js with a short Cache-Control (seconds or a few minutes, not weeks) so you can ship a fix.
When a new worker is installed, decide how aggressive to be:
- Default: let it wait until all tabs on the old worker are closed. Safer; slower to roll out.
skipWaiting()plusclients.claim(): the new worker takes over sooner. Use this when the old worker is wrong, and test that in-flight form submits or downloads do not break.
self.addEventListener("install", (event) => {
event.waitUntil(self.skipWaiting());
});
self.addEventListener("activate", (event) => {
event.waitUntil((async () => {
const keep = "pages-v2";
const names = await caches.keys();
await Promise.all(names.filter((n) => n !== keep).map((n) => caches.delete(n)));
await self.clients.claim();
})());
});You also need a kill switch: a tiny worker that installs, skips waiting, claims clients, and deletes the caches, so a bad release can be undone without telling every visitor to clear site data. Keep that script ready in the repo. Shipping it is the same kind of careful rollback I want when something goes wrong after an app launch.
Unregistering from the page is the other half:
const regs = await navigator.serviceWorker.getRegistrations();
await Promise.all(regs.map((r) => r.unregister()));Do this on the canonical host after the kill-switch worker has had a chance to run, or you will fight a race with the old script.
When a PWA needs more than this
A genuine web app — something people open daily, with a signed-in state and a reason to work offline — can justify a richer strategy: precached shell, background sync, push notifications, a web app manifest, and careful cache versions per data type. That is a product, and it needs tests, a release process and a way to see what a given user is running.
It still needs:
- a distinct document, or a distinct rendered state, for each public URL you want in search
- noindex on internal, offline and signed-in-only routes
- a plan for stale data, not only stale CSS
Don’t take on that machinery for a brochure site because a checklist said “add a service worker.” The cost is in the failure modes, not in the fifteen lines of registration.
How to test the cache you think you have
Guessing is how stale pages survive. Test what the browser actually holds.
- In DevTools, look at Application → Service Workers and Cache Storage. Is the worker the version you just deployed? Which URLs are in which cache? Open a cached HTML file and read it.
- Use a fresh profile, then a return visit, then an offline checkbox. The first visit should match the origin. The offline visit should show either a recently seen page or the offline fallback, never a different article at the same URL.
- Ship a visible typo to a preview host, fix it, and see how long the typo lasts with the worker enabled. If it lasts longer than a refresh, your strategy is cache-first or your
sw.jsitself is being cached too hard. - Fetch the live URL from a machine that has no worker (
curl, or a fresh CI job) and compare it with what Search Console’s URL inspection shows. If those two disagree, the problem is at the origin or the CDN, not in a visitor’s cache. If they agree with each other and disagree with your phone, the worker or the HTTP cache on the phone is the suspect. - Confirm
/offlineisnoindexand absent from the sitemap, using the Indexability Checker or a look at the headers.
Write the cache strategy down in a comment at the top of sw.js, in one paragraph. If you cannot describe it, you cannot test it.
Key takeaways
- A service worker is a browser-side cache and interceptor. It can help returning visitors, and it can serve stale or empty HTML if you let it.
- Don’t treat crawler support as a settled fact; make the origin’s HTML correct, then keep the worker conservative.
- For a content site, cache hashed assets, use network-first or network-only for documents, and put any offline page on a noindex URL.
- Give
sw.jsa short cache lifetime, version your caches, and keep a kill-switch worker you can ship if a release goes wrong. - Test with DevTools, a fresh profile, an offline check, and a comparison between
curland what a real browser shows.
If you want a second pair of eyes on a caching setup, tell me about it.
Frequently asked questions
Do service workers affect Google indexing?
Indirectly, if they change what people see or if the same architecture serves a shell from the origin. Crawlers fetch the URL over HTTP, and Google may also render JavaScript later. I would not rely on a service worker to help indexing, and I would not assume it is the cause of a stale snippet until I have checked the origin and the CDN.
Can a service worker serve stale HTML to crawlers?
It can certainly serve stale HTML to users. Whether a particular crawler installs your worker and then intercepts its own requests is not something I will claim as a universal rule. Debug stale results from the origin first, and keep HTML out of a cache-first strategy so users are not stuck on an old document either.
Should a marketing site have a service worker?
Only if you can name a reason, such as a useful offline page. Many article and marketing sites are better served by good HTTP cache headers and no worker at all. A worker that caches documents is a cost you have to keep paying.
What is a good cache strategy for HTML versus assets?
Hashed assets can be cache-first with a versioned cache. HTML should be network first, or not cached in the worker at all, with a dedicated /offline fallback. Unhashed CSS and JavaScript should be revalidated, not frozen.
How do I update or kill a service worker that is stuck?
Change the bytes of sw.js, serve it with a short cache lifetime, and on activate delete old caches. For a bad release, ship a tiny worker that skips waiting, claims clients and clears the caches, then unregister. Ask people to clear site data only if that rollout failed.
Written by Lee Johnson. Published 10 November 2026.