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.
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.js2. 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.
Rare Earth Juejin Tech Community
Juejin, a tech community that helps developers grow.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.