Frontend Development 12 min read

Implementing Stale‑While‑Revalidate with Service Workers for Faster Page Loads

This article explains how to use Service Workers to apply a Stale‑While‑Revalidate caching strategy, detailing the three‑step process, essential APIs, code examples for intercepting and cloning responses, and a complete Express‑based demo to improve first‑visit page performance.

Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Implementing Stale‑While‑Revalidate with Service Workers for Faster Page Loads

In most scenarios preloading improves page performance, but for first‑visit pages like a homepage it cannot be used; instead, the Stale‑While‑Revalidate strategy can accelerate access by serving cached content while updating it in the background.

The strategy consists of three steps: (1) check the cache on request and return it if found, (2) simultaneously fetch the latest version from the network, and (3) update the cache with the fresh response for the next request.

The behind‑the‑scenes work is done by a Service Worker, which acts as a proxy between the network and cache, providing rich caching control.

Service Worker Core Concepts

Key Service Worker APIs such as event.respondWith and event.waitUntil allow interception of fetch events and ensuring asynchronous tasks complete before the worker is terminated.

Intercepting and Modifying the Response

Using event.respondWith inside the fetch listener lets the browser wait for a custom Promise that resolves to the response.

self.addEventListener('fetch', event => {
  event.respondWith(
    /* custom response logic */
  );
});

A typical pattern checks the cache first and falls back to the network when the cache misses:

self.addEventListener('fetch', event => {
  const url = new URL(event.request.url);
  if (!url.pathname.startsWith('/page/')) return;

  event.respondWith(
    caches.match(event.request).then(cachedResponse => {
      if (cachedResponse) {
        return cachedResponse;
      }
      return fetch(event.request);
    })
  );
});

Cloning the Response for Caching

Because a Response object is a readable stream that can be consumed only once, it must be cloned before caching:

const responseToCache = networkResponse.clone();

Example of cloning, returning the original response to the client, and storing the clone:

fetch(event.request).then(networkResponse => {
  const responseToCache = networkResponse.clone();
  event.respondWith(networkResponse);
  caches.open(CACHE_NAME).then(cache => {
    cache.put(event.request, responseToCache);
  });
});

Ensuring Asynchronous Tasks Finish

Use event.waitUntil(promise) to keep the Service Worker alive until critical async work, such as cleaning old caches, completes.

self.addEventListener('activate', event => {
  const cacheWhitelist = ['my-cache-v2'];
  event.waitUntil(
    caches.keys().then(cacheNames => {
      return Promise.all(
        cacheNames.map(cacheName => {
          if (!cacheWhitelist.includes(cacheName)) {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

Stale‑While‑Revalidate Implementation

1. Project Structure

.
├── app
│   └── index.js
├── package.json
└── public
    ├── favicon.ico
    ├── index.html
    └── sw.js

2. Simple Express Server

The server serves the public folder and adds a mock x-page-version header that changes every five seconds.

const express = require('express');
const path = require('path');

const app = express();
const port = 3000;

app.use(express.static(path.join(__dirname, '../public'), {
  setHeaders: (res) => {
    res.set('x-page-version', Math.ceil(Date.now() / 5000));
  }
}));

app.listen(port, () => {
  console.log(`Example app listening at http://localhost:${port}`);
});

3. Service Worker Core

Installation caches the main document; activation cleans old caches.

const CACHE_NAME = 'HOMEPAGE_CACHE_v1';
const urlsToCache = ['/'];

self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME).then(cache => cache.addAll(urlsToCache))
  );
});

self.addEventListener('activate', event => {
  const cacheWhitelist = [CACHE_NAME];
  event.waitUntil(
    caches.keys().then(cacheNames => {
      return Promise.all(
        cacheNames.map(cacheName => {
          if (!cacheWhitelist.includes(cacheName)) {
            return caches.delete(cacheName);
          }
        })
      );
    }).then(() => self.clients.claim())
  );
});

4. Fetch Handler with Stale‑While‑Revalidate

self.addEventListener('fetch', event => {
  const requestUrl = new URL(event.request.url);
  if (!urlsToCache.includes(requestUrl.pathname)) return;

  event.respondWith(
    caches.match(event.request).then(cachedResponse => {
      if (cachedResponse) {
        console.log(`[Service Worker] Serving from cache: ${event.request.url}`);
        event.waitUntil(
          fetch(event.request).then(networkResponse => {
            if (networkResponse && networkResponse.status === 200) {
              return caches.open(CACHE_NAME).then(cache => {
                cache.put(event.request, networkResponse.clone());
                console.log(`[Service Worker] Fetched and cached (background): ${event.request.url}`);
              });
            }
          })
        );
        return cachedResponse;
      }
      return fetch(event.request);
    })
  );
});

5. Registering the Service Worker

if ("serviceWorker" in navigator) {
  navigator.serviceWorker.register("/sw.js").then(registration => {
    console.log(`Service Worker registered with scope: ${registration.scope}`);
  }).catch(error => {
    console.log(`Service Worker registration failed: ${error}`);
  });
}

Further Improvements

After fetching the latest version, compare the x-page-version header of the cached and network responses; if they differ, update the cache and send a message to the client to reload the page.

// Updated fetch handler with version check and client notification
self.addEventListener('fetch', event => {
  const requestUrl = new URL(event.request.url);
  if (!urlsToCache.includes(requestUrl.pathname)) return;

  event.respondWith(
    caches.match(event.request).then(cachedResponse => {
      if (cachedResponse) {
        console.log(`[Service Worker] Serving from cache: ${event.request.url}`);
        event.waitUntil(
          fetch(event.request).then(networkResponse => {
            if (networkResponse && networkResponse.status === 200 && networkResponse.type === 'basic') {
              const cachedVersion = cachedResponse.headers.get('x-page-version');
              const networkVersion = networkResponse.headers.get('x-page-version');
              if (networkVersion !== cachedVersion) {
                return caches.open(CACHE_NAME).then(cache => {
                  cache.put(event.request, networkResponse.clone());
                  return sendMessage({
                    version: networkVersion,
                    action: 'update',
                    url: event.request.url,
                  });
                });
              }
            }
          })
        );
        return cachedResponse;
      }
      return fetch(event.request);
    })
  );
});

function sendMessage(data) {
  return self.clients.matchAll().then(clients => {
    clients.forEach(client => client.postMessage(data));
  });
}

On the main thread, listen for the message and reload the page when an update is signaled.

navigator.serviceWorker.addEventListener("message", event => {
  if (event.data.action === "update" && event.data.url === window.location.href) {
    location.href = event.data.url;
  }
});

This demonstrates how Alibaba.com achieves instant page loads using Service Workers and the Stale‑While‑Revalidate pattern.

frontendCachingweb performanceservice-workerStale-While-Revalidate
Rare Earth Juejin Tech Community
Written by

Rare Earth Juejin Tech Community

Juejin, a tech community that helps developers grow.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.