DEV Community

WAVX solutions
WAVX solutions

Posted on Originally published at wavxsolutions.in

Service Workers and Caching Strategies, Explained with Code

A service worker is a JavaScript file the browser runs in the background, separate from your pages, that can intercept every network request those pages make. A caching strategy is the rule it applies to one kind of request: answer from a cache, go to the network, or combine the two. Most sites need three rules: cache first for versioned CSS, JavaScript and fonts; network first for HTML pages, with an offline fallback; and stale-while-revalidate for images. Anything that changes data or involves money goes straight to the network.

The code in this guide is written against the Service Worker and Cache APIs as documented on MDN, web.dev and Chrome for Developers on 2 October 2026. It is an example for this article, not a published, device-tested demo. Run it in the browsers your visitors use before you ship it.

If the term is new, start with the short definition of a service worker, or with what a progressive web app is.

What a service worker is allowed to do

Four rules explain most of the surprises.

  • HTTPS only. MDN states that service workers are restricted to running across HTTPS. localhost counts as secure so that you can develop locally.
  • Scope follows the file's location. By default a worker controls only URLs in or below the directory it is served from. A file at /js/sw.js controls /js/ and nothing else. To control the whole site, serve it from the root as /sw.js. A server can widen the scope with the Service-Worker-Allowed response header.
  • It does not control the page that installs it. On the first visit the page loads without a worker, registers one, and stays uncontrolled until the next load unless the worker calls clients.claim(). Do not judge your caching on a first visit.
  • It is event-driven. The browser starts the worker when an event such as fetch or push arrives and may stop it afterwards. Treat variables in the worker as temporary and keep anything that must survive in the Cache API or IndexedDB.

The five caching strategies

These names come from Jake Archibald's Offline Cookbook on web.dev and are used the same way in the Chrome and MDN documentation.

Strategy What it does How fresh Works offline Use it for
Cache only Answers from the cache and never touches the network As fresh as the last release Yes Files stored at install: the offline page, the app shell
Network only Passes the request through untouched Always current No POST requests, cart, checkout, payments, analytics pings
Cache first, falling back to network Looks in the cache; on a miss, fetches and stores Stale until the file name or cache name changes Yes, once stored Hashed CSS, JavaScript and fonts
Network first, falling back to cache Tries the network; on failure, serves the last stored copy Current whenever online Yes, for pages already visited HTML pages, API reads that should be current
Stale-while-revalidate Serves the cached copy at once and refreshes the cache in the background One visit behind Yes, once stored Product images, avatars, content that can be slightly old

MDN's caching guide makes the point that matters most: a single app normally uses several of these, one per type of resource. Choosing one strategy for the whole site is the usual beginner mistake.

A complete service worker

Save this as sw.js at the root of the site. It stores an offline page and two core files at install, removes old caches when a new version activates, and routes each request to one of three strategies.

// sw.js
const VERSION = "v1";
const STATIC_CACHE = `static-${VERSION}`;
const PAGE_CACHE = `pages-${VERSION}`;
const IMAGE_CACHE = `images-${VERSION}`;
const OFFLINE_URL = "/offline.html";
const PRECACHE = [OFFLINE_URL, "/styles/main.css", "/scripts/app.js"];
const NEVER_CACHE = ["/api/", "/cart", "/checkout", "/account"];

self.addEventListener("install", (event) => {
  event.waitUntil(
    caches.open(STATIC_CACHE).then((cache) => cache.addAll(PRECACHE))
  );
});

self.addEventListener("activate", (event) => {
  const keep = [STATIC_CACHE, PAGE_CACHE, IMAGE_CACHE];
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(
        keys.filter((key) => !keep.includes(key)).map((key) => caches.delete(key))
      )
    )
  );
});

async function putInCache(cacheName, request, response) {
  const cache = await caches.open(cacheName);
  await cache.put(request, response);
}

async function cacheFirst(event, cacheName) {
  const cached = await caches.match(event.request);
  if (cached) return cached;
  const response = await fetch(event.request);
  if (response.ok) {
    event.waitUntil(putInCache(cacheName, event.request, response.clone()));
  }
  return response;
}

async function networkFirst(event, cacheName) {
  try {
    const response = await fetch(event.request);
    if (response.ok) {
      event.waitUntil(putInCache(cacheName, event.request, response.clone()));
    }
    return response;
  } catch (error) {
    const cached = await caches.match(event.request);
    return cached || (await caches.match(OFFLINE_URL)) || Response.error();
  }
}

async function staleWhileRevalidate(event, cacheName) {
  const cached = await caches.match(event.request);
  const network = fetch(event.request).then((response) => {
    if (response.ok) {
      event.waitUntil(putInCache(cacheName, event.request, response.clone()));
    }
    return response;
  });
  if (cached) {
    event.waitUntil(network.catch(() => undefined));
    return cached;
  }
  return network;
}

self.addEventListener("fetch", (event) => {
  const { request } = event;
  const url = new URL(request.url);

  // Leave non-GET and cross-origin requests to the browser.
  if (request.method !== "GET" || url.origin !== self.location.origin) return;

  // Network only: never store cart, checkout, account or API traffic.
  if (NEVER_CACHE.some((path) => url.pathname.startsWith(path))) return;

  if (request.mode === "navigate") {
    event.respondWith(networkFirst(event, PAGE_CACHE));
  } else if (["style", "script", "font"].includes(request.destination)) {
    event.respondWith(cacheFirst(event, STATIC_CACHE));
  } else if (request.destination === "image") {
    event.respondWith(staleWhileRevalidate(event, IMAGE_CACHE));
  }
});
Enter fullscreen mode Exit fullscreen mode

Register it from your pages. The feature check keeps older browsers working as before.

<script>
  if ("serviceWorker" in navigator) {
    window.addEventListener("load", () => {
      navigator.serviceWorker.register("/sw.js").catch((error) => {
        console.error("Service worker registration failed:", error);
      });
    });
  }
</script>
Enter fullscreen mode Exit fullscreen mode

You also need a plain /offline.html page that does not depend on anything outside the precache list.

How each part behaves

Install. cache.addAll() is all or nothing. MDN documents that it rejects if any response is outside the 200 range, and when the promise passed to event.waitUntil() rejects, the install fails. One wrong path in PRECACHE therefore means no service worker at all. Keep the list short and check every entry.

Activate. MDN recommends cleaning up old caches in the activate event, because at that point no earlier version of the worker is running. The VERSION constant is what makes this work: change it and every old cache is deleted on activation.

Cache first. Fast, and dangerous with file names that never change. If /styles/main.css is stored under cache first, visitors keep the old file until VERSION changes. Either bump VERSION on every release or, better, use hashed file names such as main.4f9a1c.css, which most build tools produce.

Network first. The right default for HTML. Online visitors always get the current page; offline visitors get the last copy they saw, and the offline page for anything they have not visited. The cost is that a slow connection is still slow, because the cache is consulted only after the network fails.

Stale-while-revalidate. The visitor sees the stored image at once and the worker refreshes the cache behind it, so a changed image appears on the following visit. Do not use it for prices, stock levels or anything a customer acts on.

Three details in the code are easy to get wrong:

  1. response.clone(). A response body can be read once. The clone goes to the cache and the original goes to the page.
  2. response.ok. Without this check a 404 or 500 page gets cached and served again. It also skips opaque cross-origin responses, whose reported status is 0.
  3. event.waitUntil(). It tells the browser the worker still has work to finish after the response has been returned. MDN's own example uses the same pattern for the cache write.

What must never be cached

The early return statements in the fetch handler matter more than the strategies. Requests that fall through are handled by the browser as if no worker existed.

  • Anything that is not a GET request. MDN notes that caching is never appropriate for requests such as POST.
  • Cart, checkout and payment pages and the calls behind them.
  • Login, account and order-history pages. A page cached for one user must never be shown to another on a shared device.
  • API responses that carry prices, stock, balances or anything else that must be current.
  • Third-party scripts for payments and analytics. Let them load normally.

On an online store, serving a stale price from a cache is worse than showing an error. The guide to PWAs for e-commerce in India covers the split for a storefront.

How updates reach users

This is the part that catches teams after launch.

  1. The browser checks for a new worker on every navigation to a page in scope, and on events such as push unless it checked in the previous 24 hours. The lifecycle article on web.dev notes that most browsers ignore HTTP caching headers for this check by default; the updateViaCache option on register() controls it.
  2. Any byte of difference counts. If the new sw.js differs at all, it is installed alongside the old one. Changing VERSION is enough.
  3. The new worker waits. It activates only when the old worker controls no open pages. A visitor who keeps a tab open keeps the old version.
  4. You can skip the wait. Calling self.skipWaiting() in the install handler activates the new worker immediately. The risk is that a page loaded with old HTML is suddenly served by new logic and new assets. Skip waiting only if your pages tolerate that, or prompt the visitor to reload.

Whichever you choose, write it down. When someone asks why the old version is still showing after a release, the answer is almost always in this list.

Storage limits and eviction

A cache is borrowed space. As of October 2026, MDN documents these limits:

Browser Per-origin limit Notes
Chrome, Edge and other Chromium browsers Up to 60% of total disk size Same in best-effort and persistent modes
Firefox The smaller of 10% of disk or 10 GiB Up to 50% of disk if persistent storage is granted
Safari, from iOS 17 and macOS 14 Around 60% of total disk for browser apps Around 15% for other apps that embed web content

When a device runs short of space, browsers delete data for the least recently used origins first. Safari also deletes script-created data, which includes service worker registrations and caches, for a site with no user interaction in seven days of browser use when cross-site tracking prevention is on. navigator.storage.estimate() reports approximate usage and navigator.storage.persist() asks the browser not to evict your origin.

The practical rule: cache only what can be downloaded again, and never treat the cache as the only copy of user data. iPhone specifics are in PWAs on iPhone: what works and what does not.

Two newer options

Navigation preload lets the browser start the network request for a page while the service worker is still starting up, which removes a delay from network-first navigations. MDN's compatibility data lists it from Chrome 59, Firefox 99 and Safari 15.4.

The static routing API (InstallEvent.addRoutes()) lets a worker declare routes that the browser can handle without starting the worker at all. The same data lists it from Chrome 123 and Safari 27, released in September 2026, and not in Firefox, so treat it as an optimisation and not something to depend on.

Hand-written or a library

Workbox, described in the Chrome documentation as production-ready service worker libraries and tooling, wraps the same strategies in modules for routing, precaching and expiry. It earns its place when the precache list should be generated by the build, when caches need size or age limits, or when there are many routes. For Next.js projects, the Next.js documentation names Serwist as one option for service-worker-based offline caching.

For a site with three or four rules, a hand-written file like the one above is easier to reason about, and nothing in it is hidden.

Testing before release

Chrome DevTools has what you need in the Application panel:

  • Service Workers shows the registered worker and its state, with controls to go offline, update on reload, bypass the worker for network, and unregister it.
  • Cache Storage lists every cache and the responses in it.
  • Clear storage unregisters workers and wipes caches in one click, so you can repeat a first visit.

Test four things: a first visit, a repeat visit, a repeat visit with the Offline box ticked, and a release with a new VERSION. Then repeat on a real Android phone and a real iPhone.

A kill switch

A faulty service worker keeps serving whatever it cached, to every returning visitor, until it is replaced. Chrome's documentation on removing buggy service workers recommends deploying a replacement worker that has no fetch handler, so that the browser handles every request itself. The file below follows that advice and also deletes the caches and unregisters the worker. Keep it ready.

// sw.js (emergency replacement)
self.addEventListener("install", () => self.skipWaiting());

self.addEventListener("activate", (event) => {
  event.waitUntil(
    (async () => {
      const keys = await caches.keys();
      await Promise.all(keys.map((key) => caches.delete(key)));
      await self.registration.unregister();
    })()
  );
});
Enter fullscreen mode Exit fullscreen mode

Deploy it at the same URL as the worker it replaces. The browser picks it up at its next update check, which is why the fix is a new file at that address and not a change anywhere else.

When you do not need a service worker

A service worker adds a second cache with its own rules, and a new class of bug. It is not worth it when:

  • The site is a brochure or a blog that visitors read once. Correct HTTP cache headers and a CDN give most of the speed benefit with none of the risk.
  • Nobody will own it. A worker written at launch and never revisited is how sites end up serving a months-old home page.
  • The real problem is a slow first load. A service worker cannot help a first visit, because it is not in control yet. Fix images, scripts and server response time first.

It is worth it when visitors return often, when connections drop in the middle of a task, or when the site should be installable and usable offline. That is the case for a progressive web app, and it is the work WavX takes on under PWA development, including the caching rules, the update plan and the testing described here.

Sources

Top comments (0)