Web Caching - Cache-Control max-age, stale-while-revalidate
Tags: webdev, cache, performance, tutorial
Published: 2023-11-23 17:00:00

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](https://github.com/didof/post-caching)

***

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.

```js
// src/utils.mjs
export async function sleep(duration) {
  return new Promise((resolve) => setTimeout(resolve, duration));
}
```

Based on the [previously implemented `/only-etag` endpoint](https://dev.to/didof/web-caching-etagif-none-match-517n), 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

```js
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>`.

```js
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.
```js
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.

{% details <b>1.</b> On the initial page request, it takes 3 seconds to load, and the response is cached for 2 minutes with an associated <b>ETag</b>. The client will include this <b>ETag</b> in the <code>If-None-Match</code> header for subsequent requests. %}
![graph of point 1.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/j06fitwxm1mlrok2qv4f.png)
{% enddetails %}

{% details <b>2.</b> Close and reopen the browser (or request from another page) <b>within the 2-minute window</b>: the page is <b>instantly shown without hitting the server<b/>. %}
![graph of point 2.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/35m4y8lc82b5vkz2i0so.png)
{% enddetails %}

{% details <b>3.</b> After the 2-minute cache expires, <b>the server is contacted</b>. The resource has not changed, the client receives <code>304 Not Modified</code>. The cache expiry date is extended with the provided <code>max-age</code> value. No need to update what the user is seeing. %}
![graph of point 3.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/7m00fh11tcp34jseh5ep.png)
{% enddetails %}

**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](https://dev.to/didof/web-caching-etagif-none-match-517n). Basically, **next time the ETag will not match**.
```bash
curl -X POST http://127.0.0.1:8000/db \
-d '{ "title": "ETag", "tag": "code" }'
```
{% details <b>5.</b> Still <b>within the 2-minute window</b>, request the page again. Thanks to <code>max-age</code>, the browser shows it immediately. It proceeds with background validation as already seen in step 3. But this time the <b>ETag</b> does not match; the server responds with a <code>200 OK</code> and provides a new <b>ETag</b> (which overrides the previous entry in the cache). %}
![graph of point 5.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/b0hooul1taitkerwihnz.png)
{% enddetails %}

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

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