A Frontend Caching Playbook: From the Browser to the Edge
Caching is the highest-leverage performance work most teams never do properly. A layered model — HTTP, service worker, data, and CDN — with the headers that actually matter.
Most frontend performance advice fixates on bundle size. Important, but caching is where the real wins hide — a correctly cached asset costs zero bytes and zero milliseconds on repeat visits. The catch is that 'caching' is not one thing; it is at least four layers, each with its own rules. Get them confused and you ship stale data to users or, worse, cache something you never meant to.
Layer 1: HTTP caching and the two-bucket model
Browser HTTP caching comes down to one decision: can the browser reuse a response without asking the server (a fresh response), or must it revalidate first? Cache-Control controls both. The single most useful pattern is to split your assets into two buckets.
Immutable, fingerprinted assets — anything with a content hash in the filename, like app.4f3a9c.js — can be cached effectively forever, because a new build produces a new filename:
# Hashed build artifacts: cache for a year, never revalidate.
Cache-Control: public, max-age=31536000, immutableHTML and anything whose URL is stable but whose content changes must always be revalidated, so users never get a stale shell pointing at deleted assets:
# HTML entry points: always check freshness before reuse.
Cache-Control: no-cache
# (no-cache means "revalidate every time", NOT "do not store")The most common caching bug I have debugged is someone settingno-cachethinking it disables caching. It does not — it forces revalidation. The directive that prevents storage entirely isno-store.
ETags and conditional requests
Revalidation does not have to mean re-downloading. With an ETag, the browser sends the token it has and the server answers 304 Not Modified with an empty body when nothing changed — a tiny round trip instead of a full payload:
# First response
HTTP/1.1 200 OK
ETag: "a1b2c3"
Cache-Control: no-cache
# Browser revalidates
GET /index.html
If-None-Match: "a1b2c3"
# Unchanged — no body sent
HTTP/1.1 304 Not ModifiedLayer 2: stale-while-revalidate for data
For API data, the user-facing win is showing something instantly while you refresh in the background. This is the stale-while-revalidate pattern, and it exists both as an HTTP directive and as a client strategy. On the client, libraries like SWR and TanStack Query implement it directly — serve the cached value, fire a revalidation, swap in the fresh result:
import useSWR from "swr";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
function Profile({ id }: { id: string }) {
// Returns cached data immediately, revalidates in the background.
const { data, isLoading } = useSWR(`/api/users/${id}`, fetcher, {
revalidateOnFocus: true,
dedupingInterval: 2000, // collapse duplicate requests
});
if (isLoading) return <Skeleton />;
return <UserCard user={data} />;
}The dedupingInterval is doing quiet but important work: it collapses concurrent requests for the same key into one network call, which matters when several components mount and ask for the same resource at once.
Layer 3: the service worker as a programmable cache
When you need offline support or full control, a service worker lets you write the cache policy as code. The key is to match the strategy to the resource: cache-first for static assets, network-first for data that must be fresh.
self.addEventListener("fetch", (event: FetchEvent) => {
const { request } = event;
// Cache-first for same-origin static assets.
if (request.destination === "script" || request.destination === "style") {
event.respondWith(
caches.match(request).then(
(cached) => cached ?? fetch(request).then((res) => {
const copy = res.clone();
caches.open("assets-v1").then((c) => c.put(request, copy));
return res;
}),
),
);
}
});Note the res.clone() — a Response body is a stream that can be consumed once. You clone it because you need to both return it to the page and store it in the cache. Forgetting this is a classic source of 'body already used' errors.
Layer 4: the CDN and cache keys
A CDN caches your responses at the edge, close to users. The subtlety here is the cache key — by default it is the URL, but if your responses vary by something else (language, auth state, encoding), you must declare it with Vary, or the CDN will serve one user's response to another:
# Tell the CDN: a gzipped response is not interchangeable with brotli.
Vary: Accept-Encoding
# For personalized HTML, do NOT cache shared — vary or bypass entirely.A hard-won rule: never let a CDN cache an authenticated, personalized response under a shared key. Either bypass the edge cache for those routes, split personalization out into a client-side fetch, or key the cache on the user. Leaking one user's dashboard to another is the kind of incident that ends up in a postmortem.
A practical checklist
- Fingerprint static assets and serve them
immutablewith a one-year max-age. - Serve HTML with
no-cacheplus anETagso users always get the current shell cheaply. - Use stale-while-revalidate on the client for data that can be briefly stale.
- Reach for a service worker only when you need offline or fine-grained control — it is real complexity.
- Audit
Varyand cache keys at the CDN; never cache personalized responses under a shared key.
Caching is unglamorous and it is where the largest, cheapest performance gains live. Spend a day mapping these four layers for your app and you will usually find a header that is either too aggressive or not aggressive enough — and fixing it is often a one-line, high-impact change.