Web Caching - Cache-Control max-age, stale-while-revalidate
Francesco Di Donato
Posted on November 23, 2023
Until now, thanks to Last-Modified/If-Modified-Since
or ETag/If-None-Match
we mainly saved on bandwidth. However, the server always had to process each request.
The server can instruct the client about using the stored resources for a certain duration, deciding if and when the client should revalidate the content and whether or not to do so in the background.
Real-world endpoints are not as instantaneous as those in this tutorial. The response may take milliseconds to generate, without even considering the location of the server relative to the requester!
Let's exacerbate the asynchronicity between server and client to highlight the need for the Cache-Control
Header.
// src/utils.mjs
export async function sleep(duration) {
return new Promise((resolve) => setTimeout(resolve, duration));
}
Based on the previously implemented /only-etag
endpoint, register /cache-control-with-etag
. For the time being, it's identical, except that it waits three seconds before responding.
Also, add some log before dispatching the response
export default async (req, res) => {
console.info("Load on the server!");
work; // computation;
await sleep(3000) // simulate async.
const etag = createETag(html);
res.setHeader("ETag", etag);
const ifNoneMatch = new Headers(req.headers).get("If-None-Match");
if (ifNoneMatch === etag) {
res.writeHead(304).end();
return;
}
res.writeHead(200).end(html);
};
Let's visualize the problem. When you request a page from the browser, it enters a loading state for three seconds. Even if you refresh within that time, the browser makes the request anew, regardless of the ETag
or the Last-Modified
mechanisms. The page content persists because you're essentially staying on the same page. To observe the behavior more clearly, try reopening the page from a new tab or starting from a different site.
Most importantly, the server is hit on every request!
max-age
It is possible to instruct the browser to use the cached version for a certain duration. The server will set the Response Header Cache-Control
with a value of max-age=<seconds>
.
export default async (req, res) => {
console.info("Load on the server!");
work; // db retrieval and templating;
await sleep(3000) // simulate async.
// Instruct the browser to use the cached resource
// for 60 * 60 seconds = 1 hour
res.setHeader("Cache-Control", "max-age: 3600");
etag; // as seen before
// 200 or 304
};
Give it another try now, and request the page. The first request makes the browser load for three seconds. If you open the same page in another tab, you'll notice it's already there, and the server wasn't contacted.
This behavior persists for the specified seconds. If, after the cache expires, a new request returns a 304 Not Modified
(thanks to the If-None-Match
header), the resource will be newly cached for that amount of time.
- ➕ Better UX
- ➕ Less load on the server
- ❔ If the resource changes, the client remains unaware until the cache expires. After expiration, with a different Etag, a new version will be displayed and cached.
When attempting a refresh while staying on the page, you might observe the loading delay, indicating that the server is being contacted. Make sure you're not doing a hard refresh, as it overrides the described behavior.
stale-while-revalidate
If your application needs resource validation but you still want to show the cached version for a better user experience when available, you can use the stale-while-revalidate=<seconds>
directive.
res.setHeader("Cache-Control",
"max-age=120, stale-while-revalidate=300"
);
In this case, the browser is instructed to cache the response for 2 minutes. Once this period elapses, if the resource is requested within the next 5 minutes, the browser will use the cached resource (even if it's stale) but will perform a background validation call.
I want to emphasize the "even if stale" by playing around with the endpoint configured as above. Only soft refreshes are performed.
ℹ️ Info
Tap/click the next items to show the related graph.
4. Now that the 2-minute window is open again, let's update the content of the resource using the db endpoint implemented in the previous blog post. Basically, next time the ETag will not match.
curl -X POST http://127.0.0.1:8000/db \
-d '{ "title": "ETag", "tag": "code" }'
5. Still within the 2-minute window, request the page again. Thanks to
max-age
, the browser shows it immediately. It proceeds with background validation as already seen in step 3. But this time the ETag does not match; the server responds with a 200 OK
and provides a new ETag (which overrides the previous entry in the cache).
🔑 message
Although the displayed content is stale, the browser updates its cache silently, preserving the user experience.
- Request the page anew; this time, it finally displays the latest stored version. If within the newly restarted 2-minute window, the server won't be contacted.
🔑 message
Any request outside the time window indicated by stale-while-revalidate (which I recall, starts from the expiration of that of max-age), will behave like step 1 - blank state.
Posted on November 23, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.