didof.dev

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

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.


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.

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.

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.

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