---
title: 'Caching Strategies'
source: 'https://academia.sh/en/courses/browser-platform/caching-strategies'
course: 'The Browser and the Web Platform'
language: en
updated: '2026-08-17T18:09:12+00:00'
license: 'CC BY-SA 4.0'
---

# Caching Strategies

The division of labor between the browser's two cache layers, the freshness decision given with headers and conditional requests, cache key production, the result four strategies give in the same states, and versioning with cleanup.

The previous lesson built the authority to intervene and left open what to do with it. There
is no single correct way to answer a request: the station's logo and the measurement list
cannot be served by the same rule. One never changes and asking the network is unnecessary; the
other changes every minute and an old copy is misleading.

This lesson reduces that per-resource decision to two questions: where is a copy found, and
when is it counted as fresh.

## Two Separate Cache Layers

There are two separate caches in the browser, and confusing them leads to wrong diagnoses.

The **HTTP cache** is the layer the browser manages itself, invisible to a program. What gets
stored and for how long is decided by headers the server sends; page code has no say. There is
no program-side way to delete an entry or read its content.

The **program-controlled cache** is a name-record store the service worker explicitly writes
to and deletes from. What gets stored, how long it is kept, and when it is served is decided
by the code that writes to it; headers do nothing on their own in this layer.

The two sit in a chain. When the service worker goes out to the network, the request passes
through the HTTP cache. This produces double caching: the same resource can be stored in both
layers, and the copy written to the program layer can be the HTTP layer's stale copy. The way
to avoid this is to set up the request to bypass the HTTP layer when caching critical
resources.

## Where the Freshness Decision Is Made

The HTTP layer's decision rests on three headers: storage permission, lifetime, and a
validator. These can be read in a response's headers.

```js
// server.mjs — a local server serving three resources with three separate freshness policies
import { createServer } from "node:http";

const RESOURCES = {
  "/logo.svg": {
    type: "image/svg+xml",
    etag: '"logo-3f2a"',
    control: "public, max-age=31536000, immutable",
    body: '<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"></svg>',
  },
  "/measurements.json": {
    type: "application/json",
    etag: '"measurement-7"',
    control: "no-cache",
    body: '{"temperature":-4.2,"humidity":72}',
  },
  "/session": {
    type: "text/plain; charset=utf-8",
    etag: null,
    control: "no-store",
    body: "personal content",
  },
};

createServer((request, response) => {
  response.sendDate = false; // to keep the output deterministic: no Date header is written
  const resource = RESOURCES[request.url];
  if (!resource) { response.writeHead(404).end(); return; }

  response.setHeader("Cache-Control", resource.control);
  if (resource.etag) response.setHeader("ETag", resource.etag);

  // Conditional request: if the client's etag matches the server's, no body is sent.
  if (resource.etag && request.headers["if-none-match"] === resource.etag) {
    response.writeHead(304).end();
    return;
  }
  response.setHeader("Content-Type", resource.type);
  response.setHeader("Content-Length", Buffer.byteLength(resource.body));
  response.writeHead(200).end(resource.body);
}).listen(8137, "127.0.0.1", () => console.log("listening: 127.0.0.1:8137"));
```

The script below is run in the same directory as this file. Port 8137 is chosen arbitrarily
and must be free; if it is in use, change it in both files.

```bash
#!/usr/bin/env bash
# Starts server.mjs, reads the three resources' response headers, then stops it.
node server.mjs > /dev/null &
server=$!
sleep 1

echo "--- /logo.svg (versioned asset) ---"
curl -sS -D - -o /dev/null http://127.0.0.1:8137/logo.svg

echo "--- /measurements.json (revalidated on every use) ---"
curl -sS -D - -o /dev/null http://127.0.0.1:8137/measurements.json

echo '--- /measurements.json + If-None-Match: "measurement-7" ---'
curl -sS -D - -o /dev/null -H 'If-None-Match: "measurement-7"' http://127.0.0.1:8137/measurements.json

echo "--- /session (never stored) ---"
curl -sS -D - -o /dev/null http://127.0.0.1:8137/session

kill "$server"
```

```
--- /logo.svg (versioned asset) ---
HTTP/1.1 200 OK
Cache-Control: public, max-age=31536000, immutable
ETag: "logo-3f2a"
Content-Type: image/svg+xml
Content-Length: 67
Connection: keep-alive
Keep-Alive: timeout=5

--- /measurements.json (revalidated on every use) ---
HTTP/1.1 200 OK
Cache-Control: no-cache
ETag: "measurement-7"
Content-Type: application/json
Content-Length: 34
Connection: keep-alive
Keep-Alive: timeout=5

--- /measurements.json + If-None-Match: "measurement-7" ---
HTTP/1.1 304 Not Modified
Cache-Control: no-cache
ETag: "measurement-7"
Connection: keep-alive
Keep-Alive: timeout=5

--- /session (never stored) ---
HTTP/1.1 200 OK
Cache-Control: no-store
Content-Type: text/plain; charset=utf-8
Content-Length: 16
Connection: keep-alive
Keep-Alive: timeout=5
```

Two headers concern the connection and have nothing to do with freshness; the `Date` header is
deliberately turned off so the output is the same on every run, and would be present on a real
server.

The three policies represent three separate resource classes. An asset with a versioned name
gets a long lifetime and declares its content will never change; for such a resource even a
validation request is unnecessary. Frequently changing data can be stored but **must be
validated on every use** — the header's name is misleading; it forbids not storage but use
without validation. Personal content is stored in neither layer.

The third request's response shows the gain of validation: the client sends the validator it
has, the server announces with a bodyless response that the content has not changed. A network
round trip has been made but no body was transferred. This does not remove latency, it saves
bandwidth; on a dropped connection, validation cannot happen either, so it does not by itself
provide offline behavior.

## The Cache Key

In the program-controlled cache, an entry's key is a request, and by default the mapping is
done over the **entire URL**. This has three consequences.

First, the query string is part of the key. `/measurements?type=temperature` and
`/measurements?type=humidity` are two separate entries. If there are query parameters known to
point to the same resource, ignoring the query in the mapping can be wanted; this keeps the
same page from being stored dozens of times because of tracking parameters.

Second, requests to the same URL with different methods can be distinguished; by default the
cache is suited to storing only safe methods. Storing the response of a request with a body
under the URL key means different bodies fall into the same entry.

Third, the server can announce which request header a response varies by. For an endpoint that
gives a different response based on language preference or accepted content format, if this
announcement is not made, the response one user got is served to another. Whether this
announcement is taken into account during mapping can also be chosen.

A worker that takes key production into its own hands normalizes the URL before writing it:
drops tracking parameters, orders the remaining parameters, strips the fragment. Having the
same content land under a single key improves both space and hit rate.

## Four Strategies and the Promise They Make

There are four basic ways to answer a request. The difference between them becomes visible
through what they serve in the same three states and when they use the network.

```js
// strategy.mjs — the result four caching strategies give in the same three states
const PATH = "/station/measurements.json";

function setup({ cached, online }) {
  const state = { networkCalls: 0, cache: new Map() };
  if (cached) state.cache.set(PATH, "v1");
  state.network = async () => {
    state.networkCalls += 1;
    if (!online) throw new Error("no network");
    return "v2";
  };
  return state;
}

async function cacheFirst(s) {
  const cached = s.cache.get(PATH);
  if (cached) return cached;
  const response = await s.network();
  s.cache.set(PATH, response);
  return response;
}

async function networkFirst(s) {
  try {
    const response = await s.network();
    s.cache.set(PATH, response);
    return response;
  } catch {
    return s.cache.get(PATH) ?? "(fallback page)";
  }
}

async function staleWhileRevalidate(s) {
  const cached = s.cache.get(PATH);
  const refresh = s.network().then((response) => s.cache.set(PATH, response)).catch(() => {});
  if (cached) return cached;          // the old copy is served without waiting
  await refresh;
  return s.cache.get(PATH) ?? "(fallback page)";
}

async function networkOnly(s) {
  try { return await s.network(); } catch { return "(fallback page)"; }
}

const strategies = [
  ["cache-first", cacheFirst],
  ["network-first", networkFirst],
  ["stale-while-revalidate", staleWhileRevalidate],
  ["network-only", networkOnly],
];

const states = [
  ["cache empty, online", { cached: false, online: true }],
  ["v1 cached, online", { cached: true, online: true }],
  ["v1 cached, offline", { cached: true, online: false }],
];

console.log("strategy".padEnd(26) + "state".padEnd(28) + "served".padEnd(16) + "net  left in cache");
for (const [strategyName, run] of strategies) {
  for (const [stateName, options] of states) {
    const s = setup(options);
    const served = await run(s);
    await new Promise((r) => setImmediate(r)); // let the background refresh finish
    console.log(
      strategyName.padEnd(26) + stateName.padEnd(28) +
      String(served).padEnd(16) + String(s.networkCalls).padEnd(4) +
      (s.cache.get(PATH) ?? "(empty)"));
  }
}
```

```
strategy                  state                       served          net  left in cache
cache-first               cache empty, online         v2              1   v2
cache-first               v1 cached, online           v1              0   v1
cache-first               v1 cached, offline          v1              0   v1
network-first             cache empty, online         v2              1   v2
network-first             v1 cached, online           v2              1   v2
network-first             v1 cached, offline          v1              1   v1
stale-while-revalidate    cache empty, online         v2              1   v2
stale-while-revalidate    v1 cached, online           v1              1   v2
stale-while-revalidate    v1 cached, offline          v1              1   v1
network-only              cache empty, online         v2              1   (empty)
network-only              v1 cached, online           v2              1   v1
network-only              v1 cached, offline          (fallback page) 1   v1
```

The second row is **cache-first**'s defining property: while a copy exists, the network is
never used. The response arrives the fastest and is independent of the connection; the cost is
that even if the resource changes on the server, the old copy is served indefinitely. This is
right only for assets with a versioned name whose content never changes.

The fifth and sixth rows summarize **network-first**: always current while online, whatever is
on hand while offline. When the network is slow, the user waits as long as the network is slow;
in real deployments this wait is bounded by a timeout, and the copy is fallen back to once it
expires.

The eighth row shows **stale-while-revalidate**'s trade-off in a single line: served is `v1`,
left in cache is `v2`. The user gets a response without waiting, but one version behind;
current content appears on the next visit. This is the right choice for content whose
freshness can tolerate one round trip's delay, and the interface should not leave this
implicit.

The last three rows show that **network-only** never writes to the cache at all: in the third
state, `v1` sits in the cache but the fallback page is served. Requests that cannot be
queued — a measurement submission, for instance — belong to this strategy; what to do while
offline is not show old data, it is announce that the request did not go through.

## Versioning, Cleanup, and Quota

Caches are named, and the name carries a version. A new worker version writes to its own name
while installing; while activating, it deletes every cache other than its own name. These two
steps together keep old and new assets from mixing: a version's assets either all exist or
none do.

The resource list gathered during installation is kept short. The smallest set needed for the
application to open — the document shell, base style, base script, offline fallback page —
goes into installation; the rest of the assets are written as they are used. Counting
installation as failed as a whole means a single item on a long list blocking the entire
deployment.

It is also accounted for that stored data is not unlimited. The browser applies an upper bound
per origin, and can delete an origin's data as a whole when space runs low. Two rules follow
from this: the cache is not used like a persistent database, and the possibility that an entry
read from the cache is not found is always handled.

## Summary

- There are two cache layers in the browser: the HTTP cache managed by headers, and the
  program-controlled cache managed by code; the two sit one after the other in a chain.
- Freshness headers announce three decisions: whether it can be stored, how long it counts as
  fresh, and which validator it is checked with. Validation results in a bodyless response and
  saves bandwidth, not latency.
- The cache key is, by default, the entire URL; the query string is part of the key, and
  without normalization the same content lands in more than one entry.
- Cache-first is the fastest and most open to staleness, network-first is the freshest and
  slowest, stale-while-revalidate is fast but one version behind, network-only is the
  strategy with no offline behavior.
- Cache names carry a version; they are written at install and old names are deleted at
  activation. The install list is kept short, and storage is limited.

## Next Step

This lesson made the page independent of the network: resources are local, decisions are
defined. What results is now more than a page, and it needs to look that way to the user too.
The person using the North Slope page in the field does not want to open it by typing an
address every time; they expect it to open in its own window, with its own icon, without a
browser interface. This needs a manifest file that introduces the page to itself, criteria for
installability, and a permission model for notifications arriving from the background. The
next lesson takes up these three pieces.
