10LOC
Web Platformadvanced

The Cache API for direct HTTP response caching

Published May 4, 2027

const CACHE_NAME = "api-cache-v1";

export const fetchWithCache = async (url: string, maxAgeMs: number) => {
  const cache = await caches.open(CACHE_NAME);
  const cached = await cache.match(url);
  const cachedAt = cached ? new Date(cached.headers.get("date") ?? 0).getTime() : 0;

  if (cached && Date.now() - cachedAt < maxAgeMs) return cached.clone();

  const response = await fetch(url);
  if (response.ok) await cache.put(url, response.clone());
  return response;
};

What

fetchWithCache stores and retrieves whole Request/Response pairs using the Cache interface — the same storage a service worker uses — called directly from ordinary page script, with a caller-supplied max age standing in for the expiry logic HTTP caching doesn't otherwise give you here.

Why it matters

The Cache interface is defined by the Service Worker spec but, as MDN is explicit about, it's exposed to window scopes too — you don't need a service worker registered to use it. That makes it a real alternative to hand-rolling a Map<string, { body, expiresAt }> in memory: Cache stores actual Response objects (headers, status, and body all intact), persists across page reloads in the same way the HTTP cache would, and is inspectable in DevTools' Application panel like any other cache storage.

The gap it fills versus the browser's own HTTP cache is control: the HTTP cache follows whatever Cache-Control headers the server sent, which you often don't control for a third-party API. fetchWithCache lets the caller decide the freshness window from the client side, independent of server headers.

How it works

  • caches.open(CACHE_NAME) opens (or creates) a named cache bucket — same API whether called from a window or a service worker.
  • cache.match(url) looks up a previously stored response for that exact URL. Response.headers.get("date") reads the HTTP Date response header to figure out how old the cached copy is; ?? 0 treats a missing header as infinitely stale rather than throwing.
  • If the cached copy is younger than maxAgeMs, cached.clone() is returned instead of hitting the network at all. Cloning matters because a Response body can only be read once — the caller might read it again later, so we never hand out the one stored in the cache directly.
  • On a cache miss or stale hit, fetch(url) runs for real, and — only for a successful (response.ok) response — cache.put(url, response.clone()) stores a clone for next time while the original response is returned to the caller untouched.

Gotchas

  • The Date response header is not in the Fetch spec's CORS-safelisted response header set. For a cross-origin request, headers.get("date") returns null unless the server explicitly sends Access-Control-Expose-Headers: Date — this code still works in that case, it just always treats the cached copy as stale and silently falls back to fetching every time, rather than throwing.
  • cache.put() throws if the response isn't cacheable in principle (e.g. status codes some engines reject) — guarding on response.ok here dodges most of that, but isn't a complete substitute for checking Cache-Control: no-store if the server sets it and you need to honor it.
  • Nothing evicts old entries automatically. A cache you keep writing to without ever calling cache.delete() or caches.delete(CACHE_NAME) grows without bound; version the cache name (as with a service worker) when the shape of what you're storing changes.

Related

  • A minimal cache-first service worker — the same Cache interface, but intercepting fetch events so every request is covered automatically, not just the specific calls wired through a function like this one.
  • IndexedDB — reach for it instead when what you're storing isn't naturally a Request/Response pair, or needs querying beyond a URL lookup.