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

DirectiveMeaning for a fetchTypical use
max-age=60fresh for 60 seconds; no request is maderead-mostly resources
no-cachestore it, but revalidate before every usecontent that changes often
no-storedo not write it to any cacheresponses containing personal data
privateonly the browser may cache it, never a shared proxyanything behind a login
must-revalidateonce stale, a failed revalidation must not fall back to the stale copymoney and permissions
s-maxageoverrides max-age for shared cachesCDN-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-Control may 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-Modified and If-Modified-Since work 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 ETag that 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.

Async patterns for real request flows Building an API client layer

Last refreshed 2026-09-18.