Web Caching - Cache-Control max-age, stale-while-revalidate

didof

Francesco Di Donato

Posted on November 23, 2023

Web Caching - Cache-Control max-age, stale-while-revalidate

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.

Support code


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));
}


Enter fullscreen mode Exit fullscreen mode

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);
};


Enter fullscreen mode Exit fullscreen mode

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
};


Enter fullscreen mode Exit fullscreen mode

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"
);


Enter fullscreen mode Exit fullscreen mode

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.

1. On the initial page request, it takes 3 seconds to load, and the response is cached for 2 minutes with an associated ETag. The client will include this ETag in the If-None-Match header for subsequent requests.
graph of point 1.

2. Close and reopen the browser (or request from another page) within the 2-minute window: the page is instantly shown without hitting the server.
graph of point 2.

3. After the 2-minute cache expires, the server is contacted. The resource has not changed, the client receives 304 Not Modified. The cache expiry date is extended with the provided max-age value. No need to update what the user is seeing.
graph of point 3.

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" }'


Enter fullscreen mode Exit fullscreen mode

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).
graph of point 5.

🔑 message
Although the displayed content is stale, the browser updates its cache silently, preserving the user experience.

  1. 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.

💖 💪 🙅 🚩
didof
Francesco Di Donato

Posted on November 23, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related