Lesson 18 / 24
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.
Contents
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.
// 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.
#!/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.
// 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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.