Start a project

Article · Web performance · Service workers · SEO · 12 min read

Service workers, caching and SEO

A service worker can make a site feel faster on a return visit, and it can keep a simple offline page within reach when the network drops. It can also serve yesterday’s article, or an empty app shell, as if it were the live page. The SEO risk is almost always about HTML in the cache, not about the existence of a service worker.

On this page 10 sections
  1. A service worker sits between the browser and the network
  2. What crawlers are likely to do with one
  3. HTML in the cache is the risky part
  4. Assets, offline fallbacks and ‘app shell’ thinking
  5. A conservative default for a content site
  6. Updating, skipping waiting, and a kill switch
  7. When a PWA needs more than this
  8. How to test the cache you think you have
  9. Key takeaways
  10. 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:

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:

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:

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:

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:

  1. No service worker, unless you have a specific offline or installability need you can describe in a sentence.
  2. 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.
  3. Precache only the hashed assets and the offline page. Don’t precache the homepage or the article index just because they are popular.
  4. Navigations are network first, or network only, with /offline as the last resort.
  5. 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.
  6. 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:

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:

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.

  1. 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.
  2. 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.
  3. 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.js itself is being cached too hard.
  4. 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.
  5. Confirm /offline is noindex and 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

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.

All articles · Swift A–Z · Apps

Questions, or an app in mind?

Let's bring your ideas to life. Whether you're envisioning a game-changing Apple Watch app or a transformative iOS experience, I'm here to make it happen.

Start a project