EthicsComputerCourses
Join training +91 90532 10052

Service Workers & Offline Caching

JavaScript · Lesson 53 of 74 Advanced 9 min read Updated

Open most websites with the network switched off and you see the browser's "No internet" page. Apps such as online shops, news readers and train-ticket sites often do better: they still open, show what was loaded last time, and tell you politely that you are offline. The tool behind this is a service worker.

A service worker is a JavaScript file that the browser runs separately from your pages. Once installed, it sits between your site and the network, and every request your pages make (for HTML, CSS, scripts, images and API data) passes through its fetch event. It can answer from a local cache, go to the network, or combine both. In this lesson you will learn the service worker lifecycle, store responses with the Cache API, write the three most common caching strategies, and understand the HTTP cache headers that work alongside them.

What a service worker can and cannot do

A service worker is a special kind of Web Worker:

  • It has no DOM access. It talks to pages with postMessage.
  • It is event driven. The browser starts it when an event arrives (a request, a push message) and stops it when idle, so you cannot keep data in global variables between events. Store data in the Cache API or IndexedDB.
  • It works only on HTTPS (and on http://localhost for development), because a script that can rewrite every response must not be injected by an attacker on the network.
  • It controls pages inside its scope: by default, the folder its file lives in and everything below. A worker at /sw.js can control the whole site; one at /js/sw.js controls only pages under /js/.

Registering one takes a single call from your page. Always check that the browser supports it first:

JavaScript
// main.js, loaded by every page
if ("serviceWorker" in navigator) {
  window.addEventListener("load", async () => {
    try {
      const registration = await navigator.serviceWorker.register("/sw.js");
      console.log("Service worker registered for", registration.scope);
    } catch (error) {
      console.error("Service worker failed to register:", error);
    }
  });
}

Waiting for load means the service worker's own downloads do not compete with the first visit to your page.

The lifecycle

A service worker goes through fixed stages. Understanding them prevents most "why is my old code still running?" confusion:

StageWhat happensTypical code
InstallThe browser downloads the new file and fires install oncePrecache the files the site needs
WaitingA new version is installed but an older one still controls open tabsNothing; it waits for those tabs to close
ActivateThe new version takes over and fires activateDelete old caches
FetchEvery request from controlled pages fires fetchAnswer from the cache or the network

The browser checks for a new version when the user visits your pages; if sw.js has changed by even one byte, the new version installs. By default it then waits until every tab using the old version is closed, so one page never mixes two versions of your code. A service worker can skip this wait by calling self.skipWaiting() during install, but only do that when the new code works with pages loaded by the old one.

event.waitUntil(promise) tells the browser "this stage is not finished until my promise settles". If the promise rejects during install, the new version is thrown away and the old one keeps working, which is exactly what you want when a download fails.

Storing responses with the Cache API

The Cache API stores pairs of a request (usually a URL) and a Response. You can open named caches with caches.open(name), then put(), match(), keys() and delete() entries. It is available in service workers and in normal pages, so you can try it right here:

JavaScriptRun it yourself
await caches.delete("menu-v1"); // start fresh on every run

const cache = await caches.open("menu-v1");

// Store a response under a URL, as a service worker would
const menu = { dish: "Masala dosa", price: 120 };
await cache.put("/api/menu", new Response(JSON.stringify(menu), {
  headers: { "Content-Type": "application/json" },
}));

// Look it up later, even with no network
const hit = await cache.match("/api/menu");
const data = await hit.json();
console.log(`${data.dish}: ₹${data.price}`);

console.log(await cache.match("/api/offers")); // nothing stored

const keys = await cache.keys();
console.log(keys.map((request) => new URL(request.url).pathname));
console.log(await caches.has("menu-v1"));
Output
Masala dosa: ₹120
undefined
[ '/api/menu' ]
true

match() gives back a real Response, so you read it with .json() or .text() exactly like a fetch() result. A miss gives undefined, not an error. Unlike the browser's normal HTTP cache, nothing in the Cache API expires by itself: entries stay until your code deletes them.

A complete offline-ready service worker

Here is a small but realistic sw.js for a site. It precaches the "app shell" (the files every page needs), cleans up old caches on activation, and answers requests with a different strategy for pages and for static files:

JavaScript
// sw.js, at the root of the site
const VERSION = "v3";
const SHELL_CACHE = `shell-${VERSION}`;
const SHELL_FILES = ["/", "/offline.html", "/css/site.css", "/js/main.js", "/img/logo.svg"];

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

self.addEventListener("activate", (event) => {
  event.waitUntil((async () => {
    for (const name of await caches.keys()) {
      if (name !== SHELL_CACHE) await caches.delete(name); // remove old versions
    }
  })());
});

self.addEventListener("fetch", (event) => {
  const { request } = event;
  if (request.method !== "GET") return; // let POST and others go to the network as usual

  if (request.mode === "navigate") {
    // Pages: network first, so users see fresh content; offline page as a fallback
    event.respondWith(
      fetch(request).catch(async () => (await caches.match(request)) ?? caches.match("/offline.html")),
    );
    return;
  }

  // Static files: cache first, network if missing
  event.respondWith(
    caches.match(request).then((cached) => cached ?? fetch(request)),
  );
});

cache.addAll() downloads every file and fails as a whole if any one fails, so a half-filled cache is never used. event.respondWith() gives the browser your answer; if a fetch handler does not call it, the request goes to the network normally. Changing VERSION changes the file, which installs the new worker, fills a new cache, and lets activate delete the old one.

Choosing a caching strategy

There is no single right strategy; choose per type of request:

StrategyHow it worksGood for
Cache firstUse the cache; go to the network only if missingVersioned CSS, JS, fonts, logos
Network firstTry the network; use the cache if offlineHTML pages, account data, prices
Stale-while-revalidateAnswer from the cache at once, refresh the cache in the backgroundNews lists, menus, profiles that may be a little old
Network onlyNever cachePayments, login, anything private or real-time

Stale-while-revalidate is popular because it feels instant and stays reasonably fresh. You can try it in a page with the Cache API and a real request:

JavaScriptRun it yourself
await caches.delete("api-v1");

// Stale-while-revalidate: answer from the cache at once, refresh it in the background
async function cachedJson(url) {
  const cache = await caches.open("api-v1");
  const cached = await cache.match(url);

  const refresh = fetch(url).then(async (response) => {
    if (response.ok) await cache.put(url, response.clone()); // a body can be read only once, so store a copy
    return response;
  });

  if (cached) {
    refresh.catch(() => {}); // offline? keep showing the cached copy
    return { source: "cache", data: await cached.json() };
  }
  const response = await refresh;
  return { source: "network", data: await response.json() };
}

const url = "https://jsonplaceholder.typicode.com/users/1";
const first = await cachedJson(url);
console.log(first.source, first.data.name);

await new Promise((resolve) => setTimeout(resolve, 500)); // let the cache write finish
const second = await cachedJson(url);
console.log(second.source, second.data.name);
Output
network Leanne Graham
cache Leanne Graham

The first call has nothing cached, so it waits for the network. The second answers straight from the cache while a fresh copy downloads for next time. Note response.clone(): a response body can be read only once, so you store one copy and return the other.

HTTP caching works alongside

Before a request even reaches the network, the browser checks its own HTTP cache, controlled by headers your server sends. A service worker does not replace these headers; good sites use both.

HeaderMeaning
Cache-Control: max-age=31536000, immutableKeep for a year without asking again. Use only for files whose name changes when their content changes, such as app.4f9a1c.js from Vite.
Cache-Control: no-cacheYou may store it, but check with the server before each use. Good for HTML.
Cache-Control: no-storeNever store it. Use for private data such as bank statements.
ETagA version tag; the browser sends it back and the server can answer 304 Not Modified with no body.

This is why build tools put a content hash in file names: the HTML is always checked, and it points to file names that can be cached forever because new content always gets a new name.

Common mistakes

  • Caching API responses forever with "cache first". Prices and stock change. Use network first or stale-while-revalidate for data.
  • Never deleting old caches. Version your cache names and clean up in activate.
  • Caching private or POST requests. Skip non-GET requests, and never cache pages that show one user's private data on a shared computer.
  • Putting sw.js in a subfolder and wondering why it does not control the whole site. Its location sets its scope.
  • Calling skipWaiting() without thinking. Old pages may then receive files from the new version and break.

Key points

  • A service worker runs separately from pages, on HTTPS only, and intercepts requests from pages in its scope through the fetch event.
  • Lifecycle: install (precache), waiting, activate (clean old caches), then fetch; event.waitUntil() extends each stage.
  • The Cache API stores request/response pairs under names you choose; entries never expire by themselves.
  • Use cache first for hashed static files, network first for pages and changing data, stale-while-revalidate for data that may be slightly old, and network only for private or real-time requests.
  • HTTP Cache-Control headers and content-hashed file names work together with service workers.

Exercise

Write the fetch handler of a service worker for a cricket score site. Pages should use network first with an /offline.html fallback, requests to /api/scores must never be cached, and images under /img/ should use cache first, saving new images into a cache called images-v1.

Show solution
JavaScript
self.addEventListener("fetch", (event) => {
  const { request } = event;
  const url = new URL(request.url);
  if (request.method !== "GET") return;

  if (url.pathname.startsWith("/api/scores")) return; // network only: live data

  if (request.mode === "navigate") {
    event.respondWith(fetch(request).catch(() => caches.match("/offline.html")));
    return;
  }

  if (url.pathname.startsWith("/img/")) {
    event.respondWith((async () => {
      const cache = await caches.open("images-v1");
      const cached = await cache.match(request);
      if (cached) return cached;
      const response = await fetch(request);
      if (response.ok) cache.put(request, response.clone());
      return response;
    })());
  }
});

Returning without calling respondWith() lets the request go to the network normally, which gives live scores network-only behaviour. Images are saved only when the response is OK, so error pages are never cached.

Quick Quiz

1. Why do service workers require HTTPS (except on localhost)?

2. A new version of sw.js is installed while the old one controls two open tabs. What happens by default?

3. Which strategy fits a versioned file like app.4f9a1c.js?

4. Why does stale-while-revalidate call response.clone() before saving?

Learn JavaScript with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call