
In the previous article, we covered the two-tier browser caching system — strong cache and negotiated cache. But that article's perspective stayed on the browser ↔ server line. In reality, the request chain is much longer:
User (Browser) → CDN Edge Node → CDN Regional Node → Reverse Proxy (Nginx) → Origin
↑ ↑ ↑
Has its own Has its own Has its own
cache too! cache too! cache too!Caching isn't just for browsers. CDN nodes have a cache layer. Reverse proxies like Nginx have a cache layer. And Service Worker lets you write your own caching logic in JavaScript right inside the browser. Each layer has its own directives, its own expiry rules, and its own refresh strategy.
This article picks up where we left off, focusing on the cache layers beyond the browser — and a transformative idea: letting front-end developers take full control of caching.
s-maxage — why you need different caching directives for CDN vs. the browserstale-while-revalidate mechanism — how to balance "fast" and "fresh"Vary header — why the same URL returns different content for different requestsUnderstand HTTP caching fundamentals: strong cache (Cache-Control), negotiated cache (ETag, Last-Modified)
Your company website is hosted on Alibaba Cloud OSS, accelerated through Alibaba CDN, with the origin server in Beijing. A user in Shenzhen visits the homepage for the first time and loads main.abc123.js.
You set max-age to one year, and the CDN caches this JS file. Here's the question: does the CDN cache the "complete response" or just "a copy of the headers + body"? Does the CDN respect Cache-Control? Does its caching behavior exactly match the max-age you wrote? If you use private to restrict caching to "browsers only," will the CDN listen?
Here's an even more critical scenario: your website returns different versions of HTML for Chinese and English users (based on the Accept-Language header). Same /index.html — Chinese users get the Chinese version, English users get the English version. If the CDN uses the URL as its cache key (/index.html → some cached copy), a Chinese user might get the English version — because the CDN doesn't know it needs to distinguish by language.
And finally, the most extreme scenario: you want the page to be fully accessible even when the user is offline — not a "Please check your network connection" screen, but a fully browsable cached page. Browser cache can't do this (you can't control it from JavaScript), but Service Worker can.
These three scenarios — CDN cache differentiation, Vary response differentiation, and offline availability — are what this article sets out to solve.
max-age applies to both CDN and browser. But sometimes you want different instructions for each:
max-age can't do this because it's a uniform directive. s-maxage was designed precisely for this.
s-maxage has the exact same syntax as max-age (unit is seconds), but it only applies to shared caches:
Cache-Control: s-maxage=86400, max-age=600Interpretation:
When s-maxage is present, the CDN ignores max-age and uses its own s-maxage. One more important detail: s-maxage implicitly sets public. Responses carrying s-maxage will be cached by the CDN — no need to add public separately.
# Scenario: homepage HTML — high origin cost, but content updates frequently
Cache-Control: s-maxage=3600, max-age=0, must-revalidatemax-age=0, must-revalidate: validates on every request (similar effect to no-cache)private means "only cache for me (the browser); CDN, don't store this." A typical scenario: a user profile page — different users see different content, and the CDN has no way to distinguish users, so caching it would produce wrong results.
Cache-Control: private, max-age=300Conversely, if a response has both s-maxage and private, the CDN won't cache — private has the highest priority and directly bans shared caching.
Suppose you set max-age=60 (1 minute). At 59 seconds, the user hits the cache normally. At 61 seconds, the cache expires — the browser must send a request, and the user waits. After this one-minute cliff, the user suddenly drops from "instant load" to "waiting for the network."
You might think, "Just set max-age longer." But the longer it is, the staler the content becomes. Is there a way to prevent "expired" from meaning "immediately unavailable"?
stale-while-revalidate lets an expired cache survive for an additional window while asynchronously updating:
Cache-Control: max-age=60, stale-while-revalidate=600Timeline:
0s ──────────── 60s ─────────────────────────── 660s ────→
Within max-age stale-while-revalidate Fully stale
(use cache directly) window (serve stale, (must wait
refresh in background) for network)Inside this window, the user experience is:
stale-while-revalidate window → immediately return the stale cache (user perceives instant load)A classic use case: a news homepage API. When a user opens the app, it can instantly display the previous content while fetching the latest data in the background. A second later, the page silently updates. What the user sees is "content on open," not "loading..."
stale-while-revalidate is not suitable for scenarios with strict data correctness requirements:
It's ideal for scenarios that are tolerant of staleness but sensitive to response speed: news feeds, blog content, social media timelines.
The CDN's default cache key is the URL. When you request GET /index.html, the CDN checks: "Do I have a cache for the URL /index.html?" — if yes, serve it; if no, go back to origin.
But in some scenarios, the same URL needs to return different content based on request characteristics:
Accept-Encoding: gzip → return compressed version (compressed binary) vs. uncompressed (plain text)Accept-Language: zh-CN → return Chinese HTML vs. en-US → English HTMLUser-Agent → return lightweight version for mobile vs. full version for desktopIf the CDN doesn't distinguish these, and first caches the English HTML from an English user, the next Chinese user will get the English version.
The Vary header tells the CDN: "When caching my response, don't just remember the URL — also remember the value of this request header."
HTTP/1.1 200 OK
Content-Type: text/html
Vary: Accept-Language
Cache-Control: max-age=3600
<html lang="zh">Chinese content</html>This response means: "The HTML I got using a zh-CN request header should be labeled as 'this version corresponds to Accept-Language: zh-CN' when cached." When an en-US request arrives, the CDN checks — same URL, but different Accept-Language — cache doesn't match, must go back to origin.
Multiple Vary values can be separated by commas:
Vary: Accept-Encoding, Accept-LanguageThis means the CDN's cache key = URL + Accept-Encoding value + Accept-Language value. The cache space gets "sharded" — each combination has its own independent copy.
You might think: "Let me add Vary: User-Agent so the CDN distinguishes cache by device type." But User-Agent strings are incredibly diverse — Chrome 118 on Windows 10, Chrome 118 on macOS, Safari 17 on iOS, Firefox 120 on Ubuntu... each combination generates an independent cache shard. This tanks CDN cache hit rates (each version's user base is tiny), and origin pressure actually increases.
Vary: User-Agent should be used with caution. The better approach is usually responsive CSS / media queries for device differences, rather than returning different HTML server-side based on UA.
You actually use Vary every day — Nginx / CDNs add it automatically:
Vary: Accept-EncodingWhen you request a compressed resource, Nginx automatically adds this header, ensuring the CDN doesn't mistakenly serve a gzip version to a client that doesn't support gzip (though all modern browsers support it now).
All the caching strategies we've discussed — Cache-Control, ETag, strong cache, negotiated cache — share a common premise: you can only declare rules through server response headers; you cannot dynamically control them from JavaScript.
You can't write this in JS:
// This is wishful thinking — it doesn't work
if (user.isPremium) {
cacheControl('max-age=86400');
} else {
cacheControl('max-age=60');
}The browser's HTTP Cache is read-only from your perspective — you can set rules on the server side, but you can't intervene on demand client-side. Service Worker changes this.
A Service Worker is a JavaScript thread running in the browser's background, independent of the page, capable of intercepting all HTTP requests under the current origin. Think of it as "a JS proxy layered between the browser and the network":
Page calls fetch('/api/data')
│
▼
Service Worker (intercepts!)
│
┌────┴────┐
│ │
Return from Make real request
cache (fetch)
(Cache API) │
▼
NetworkEvery request passes through this proxy, and you can write arbitrary logic inside it: serve from cache, fetch from network, cache + network race, fall back to cache on network failure...
The Cache API is the storage interface paired with Service Worker, purpose-built for caching Request/Response pairs. It's not LocalStorage (which only stores strings), and it's not IndexedDB (which is good for structured data) — it's specifically designed for "HTTP request caching."
// Basic usage
const cache = await caches.open('my-cache-v1');
// Cache a single request-response pair
await cache.add('/styles/main.css');
// Cache multiple
await cache.addAll(['/scripts/app.js', '/images/logo.png']);
// Manually store a Response
const response = await fetch('/api/data');
await cache.put('/api/data', response);
// Retrieve from cache
const cachedResponse = await cache.match('/api/data');
// Delete old caches
await caches.delete('my-cache-v0');1. Cache First
Check cache first; if it's there, use it; if not, go to network. Ideal for static resources that rarely change (JS/CSS with content hashes):
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
// In cache → return directly
if (cachedResponse) return cachedResponse;
// Not in cache → go to network
return fetch(event.request);
})
);
});2. Network First
Go to network first; if it succeeds, return and update cache; if it fails, fall back to cache. Ideal for APIs that need freshness but allow offline degradation:
self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(event.request)
.then((networkResponse) => {
// Network success → update cache, return network data
return caches.open('dynamic-cache').then((cache) => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
})
.catch(() => {
// Network failure (offline) → serve fallback from cache
return caches.match(event.request);
})
);
});This pattern shares a philosophy with stale-while-revalidate, but in the opposite direction: stale-while-revalidate serves cache first then updates; this serves network first then falls back. Your choice depends on whether you prioritize "first-paint speed" or "data freshness."
3. Stale While Revalidate
The Service Worker version of stale-while-revalidate — return cache immediately while updating in the background:
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.open('stale-cache').then((cache) => {
const cachedPromise = cache.match(event.request);
const networkPromise = fetch(event.request).then((networkResponse) => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
// Use whichever returns first (cache is usually faster), but network result updates the cache
return cachedPromise.then((cached) => cached || networkPromise);
})
);
});These two cache layers can coexist, but with an important interaction:
fetch() calls made from the SW still go through HTTP cache: if you call fetch(event.request) inside a SW, the browser still follows normal HTTP caching rules for that request (checking Cache-Control, max-age, etc.)This means you can use a three-tier caching strategy:
Tier 1: Service Worker Cache API (JS logic, maximum flexibility)
Tier 2: HTTP Cache (max-age/ETag, server-controlled, best performance)
Tier 3: CDN Cache (s-maxage/Vary, reduces origin pressure)curl -H "Accept-Language: zh-CN" -I https://example.com and curl -H "Accept-Language: en-US" -I https://example.com to compare the Vary and Cache-Control headers in responses.stale-while-revalidate=300 to an API's Cache-Control header and observe the "immediately serve stale data after cache expiry" effect./api/time requests — fetch from network and cache on first call, then serve the cached timestamp when offline. Verify "data is still visible even without network."s-maxage issues different caching directives to CDN vs. the browser, and implicitly sets publicstale-while-revalidate keeps expired cache available during async refresh — trading "might not be the latest" for "definitely not frozen"Vary header lets CDNs cache not just by URL, but also by request header values (like Accept-Language) to differentiate content versionsThis article completes the final piece of HTTP caching — from browser strong/negotiated cache, to CDN differentiated directives, to Service Worker's full control. Caching, at its core, is a strategy of "trading space (storage) for time (latency)," and each cache layer practices this philosophy at a different granularity.
The next article returns to the main thread of application-layer protocols — HTTP/2. HTTP/1.1's head-of-line blocking, header redundancy, and connection concurrency limits were unsolvable within the HTTP/1.1 semantic framework. HTTP/2 fundamentally changed the communication model with a binary framing layer, multiplexing, and header compression (HPACK) — a single TCP connection carrying all concurrent requests, eliminating HTTP-layer head-of-line blocking. But TCP-layer head-of-line blocking remains, which is the story of HTTP/3 and QUIC.