Caching and conditional requests
Use freshness and validators so repeat requests cost nothing, and build a small client cache that also collapses duplicate in-flight calls.
Cache-Control and freshness
| Directive | Meaning for a fetch | Typical use |
|---|---|---|
max-age=60 | fresh for 60 seconds; no request is made | read-mostly resources |
no-cache | store it, but revalidate before every use | content that changes often |
no-store | do not write it to any cache | responses containing personal data |
private | only the browser may cache it, never a shared proxy | anything behind a login |
must-revalidate | once stale, a failed revalidation must not fall back to the stale copy | money and permissions |
s-maxage | overrides max-age for shared caches | CDN-controlled freshness |
// the browser handles freshness for you when the server allows it
const res = await fetch('/api/config'); // may come from the HTTP cache
res.headers.get('age'); // seconds since the response was generated
res.headers.get('cache-control');
// bypass the cache deliberately: a pull-to-refresh, an after-write reload
await fetch('/api/tasks', { cache: 'no-store' });
await fetch('/api/tasks', { cache: 'reload' });
// force revalidation and accept a 304
await fetch('/api/tasks', { cache: 'no-cache' });- Only GET and HEAD responses are cached. A POST is never served from the HTTP cache.
- Caching and updating are different problems. A stale list after a successful write is an invalidation bug, not a caching bug.
cache: 'no-store'in fetch stops the browser cache but not a service worker or a CDN, so it is not a way to guarantee freshness end to end.- A response with no
Cache-Controlmay still be cached heuristically, and heuristic freshness is engine-specific. Send the header explicitly.
ETag and revalidation
A validator lets the server answer a full request with 304 Not Modified and an empty body. You keep the parsing work and the bandwidth, and you pay only the round trip. On a mobile connection that is often the difference between a usable list and a slow one.
let stored = null; // { etag, body, at }
async function getTasks({ maxAgeMs = 30000, force = false } = {}) {
if (!force && stored && Date.now() - stored.at < maxAgeMs) return stored.body;
const headers = {};
if (stored && stored.etag) headers['If-None-Match'] = stored.etag;
const res = await fetch('/api/tasks', { headers });
if (res.status === 304) {
stored.at = Date.now(); // refresh the freshness window, keep the body
return stored.body;
}
if (!res.ok) throw new Error('HTTP ' + res.status);
const body = await res.json();
stored = { etag: res.headers.get('etag'), body: body, at: Date.now() };
return body;
}- A 304 has no body. Calling
res.json()on it throws, which is the single most common bug in conditional-request code. Last-ModifiedandIf-Modified-Sincework the same way at one-second resolution, which is coarser than an ETag. Prefer the ETag when the server sends both.- Revalidation pays a round trip. It saves transfer and parsing, not latency, so it is not a substitute for a client cache with a freshness window.
- A wrong
ETagthat changes on every response disables the benefit while looking like it works: every request is a 200 with a full body.
A client cache that also collapses in-flight calls
const cache = new Map(); // key -> { at, ttl, body }
const pending = new Map(); // key -> Promise
function cachedGet(url, ttlMs = 15000) {
const now = Date.now();
const hit = cache.get(url);
if (hit && now - hit.at < hit.ttl) return Promise.resolve(hit.body);
if (pending.has(url)) return pending.get(url); // join the existing call
const p = fetch(url)
.then(res => {
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
})
.then(body => {
cache.set(url, { at: Date.now(), ttl: ttlMs, body: body });
return body;
})
.finally(() => pending.delete(url));
pending.set(url, p);
return p;
}
function invalidate(url) { cache.delete(url); } // call after a write💡
Keep the client cache out of the transport layer when you can. Wrap it around a small client module so there is one place that knows the cache exists, and one place that invalidates it after a write.
FAQ
My list is stale after a write. Is that a caching bug?
Almost always an invalidation bug. The cache is doing what it was told. After a successful write, either drop the affected keys or write the returned record into the cache, and do it in the same place that performed the request.
Why do I get a JSON error on a request that used to work?
The server answered 304 and the body is empty. Check
res.status === 304 before parsing and reuse the stored body, remembering to refresh the freshness timestamp.Related
Async patterns for real request flows Building an API client layer
Last refreshed 2026-09-18.