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 HTTPDateresponse header to figure out how old the cached copy is;?? 0treats 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 aResponsebody 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 originalresponseis returned to the caller untouched.
Gotchas
- The
Dateresponse header is not in the Fetch spec's CORS-safelisted response header set. For a cross-origin request,headers.get("date")returnsnullunless the server explicitly sendsAccess-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 onresponse.okhere dodges most of that, but isn't a complete substitute for checkingCache-Control: no-storeif 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()orcaches.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
Cacheinterface, but interceptingfetchevents 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/Responsepair, or needs querying beyond a URL lookup.