CDN and front-end caching: from edge headers to TanStack Query
A layer-by-layer guide to caching a modern front end: immutable hashed assets, HTML revalidation, stale-while-revalidate at the CDN edge, tag-based purging, and where an in-memory client cache like TanStack Query fits on top.
"Caching" on the front end is not one thing you turn on. It is four or five independent layers, each with its own rules, its own invalidation story, and its own way to bite you. Get the layering right and a deploy is instant and safe. Get it wrong and users stare at a white screen after every release, or worse, at last week's data.
This walks the layers from the browser down to the origin, then covers the one cache that does not fit the HTTP model at all: an in-memory client cache like TanStack Query.
The cache layers, top to bottom#
┌─────────────────────────────────────────────────────────┐
│ TanStack Query / SWR in-memory, per tab, keyed by │
│ query key (not HTTP) │
├─────────────────────────────────────────────────────────┤
│ Service Worker cache optional, per origin, offline │
├─────────────────────────────────────────────────────────┤
│ Browser memory cache this navigation only │
│ Browser disk (HTTP) Cache-Control / ETag │
├─────────────────────────────────────────────────────────┤
│ CDN edge (shared) s-maxage / stale-while-revalidate │
│ surrogate keys, purge API │
├─────────────────────────────────────────────────────────┤
│ Origin / app server the slow, expensive path │
└─────────────────────────────────────────────────────────┘The HTTP layers (browser disk cache and CDN edge) are driven by the same response headers. The catch is that different kinds of file want opposite settings, so "set a cache header" is never the whole answer.
Static assets: hash the filename, cache forever#
Vite (and webpack, Rollup, Parcel) writes bundled assets with a content hash in the name: index-a1b2c3d4.js, logo-9f8e7d.svg. The hash is the version. Change one byte of source and the filename changes, so a cached copy can never be wrong. It can only be unused. That lets you cache these as hard as the spec allows:
# for /assets/* : anything with a content hash in the name
Cache-Control: public, max-age=31536000, immutablemax-age=31536000is one year, the conventional "forever".immutabletells the browser not to revalidate even on a hard reload. Without it, Chrome and Safari still fire conditional requests for these files when the user hits refresh, which is pure wasted latency for a file that cannot change.publiclets shared caches (the CDN) store it too.
main.js with no hash, a one-year immutable header means users run stale JavaScript until the cache expires or they manually clear it. Either hash the filenames or do not long-cache them. There is no safe middle.HTML: revalidate the entry point every time#
index.html is the opposite case. It is the one file whose name never changes and whose contents change on every deploy, since it points at the new hashed bundles. If it is stale, the browser asks the CDN for index-OLD.js, gets a 404, and renders nothing.
# for index.html and other unhashed HTML entry points
Cache-Control: no-cache
# (equivalently: max-age=0, must-revalidate)no-cache is a misnomer: it means "store it, but revalidate with the origin before every use". Paired with an ETag or Last-Modified, the revalidation is a cheap conditional request that returns 304 Not Modified with no body when nothing changed, and a full 200 the moment you deploy.
| Directive | Meaning | Use for |
|---|---|---|
no-store | Never write to any cache at all | Truly per-request, sensitive responses |
no-cache | Store, but revalidate before every reuse | HTML entry points, app shell |
max-age=60 | Fresh for 60s, then revalidate | Semi-static JSON, feeds |
max-age=31536000, immutable | Fresh for a year, never revalidate | Hashed JS/CSS/img/font |
The CDN edge: s-maxage and stale-while-revalidate#
A CDN is a shared cache, and HTTP lets you address it separately from the browser:
Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=86400, stale-if-error=604800max-age=0makes browsers always revalidate, which is what you want for HTML and API responses that must look live.s-maxage=300lets the CDN serve this from the edge for 5 minutes without touching your origin. It overridesmax-agefor shared caches only.stale-while-revalidate=86400: for the next day after it goes stale, the edge may serve the stale copy immediately and refresh it in the background. The user never waits for your origin. This one directive removes the latency cliff that otherwise hits the instant a TTL expires.stale-if-error=604800: if your origin is down, keep serving the last good copy for a week instead of showing an error.
The Vary trap
Vary tells caches "this response depends on that request header", so a separate copy is stored per value. Vary: Accept-Encoding is fine (a handful of values). Vary: Cookie or Vary: User-Agent effectively disables edge caching. There are millions of distinct values, so the hit rate collapses to near zero. Strip cookies from the cache key for cacheable routes.
Invalidation: the hard part#
There are only two hard things in computer science, and one of them is cache invalidation.
Ranked from most to least reliable:
| Strategy | How | Trade-off |
|---|---|---|
| Immutable hashed paths | New deploy = new filenames; nothing to purge | Best. Requires a hashing build (you already have one) |
| Versioned deploy directory | Upload to /v/1234/…, flip a pointer | Atomic, instant rollback; needs a router/pointer |
| Tag / surrogate-key purge | Tag responses (Surrogate-Key: product-42), purge by tag on change | Precise; needs CDN support (Fastly, Cloudflare Enterprise, Akamai) |
| Path / URL purge | Call the CDN purge API for changed URLs | Fine for a few files; racy and slow at scale |
| Purge everything | Wipe the whole zone on deploy | Origin stampede + cold cache for every user. Last resort |
# origin response for a product API route
Surrogate-Control: max-age=3600
Surrogate-Key: product-42 catalog
# later, when product 42 changes, one call:
# PURGE key=product-42
# every edge response tagged product-42 is dropped, nothing elseService workers: an optional client-side edge#
A service worker can cache the app shell and API responses on the device, enabling offline use and instant repeat loads. Tools like Workbox make the common patterns declarative:
- Precache the hashed build output at install time, for instant subsequent loads.
- Runtime cache API calls with
StaleWhileRevalidateorNetworkFirst. - Update choreography: a new SW waits until all tabs close unless you call
skipWaiting()and prompt the user to reload.
The client data cache: TanStack Query#
TanStack Query (and SWR, and RTK Query) is not an HTTP cache. It is an async-state manager that happens to cache. It lives in memory, per tab, keyed by a query key you choose, and it does not read or write Cache-Control. It solves a different problem: keeping server data in the UI fresh, deduped, and consistent across components without a global store.
staleTime vs gcTime
The two timers that confuse everyone. They are unrelated:
| staleTime | gcTime (was cacheTime) | |
|---|---|---|
| Default | 0 (immediately stale) | 5 minutes |
| Controls | How long data is considered fresh; no refetch while fresh | How long an unused (no mounted observer) query stays in memory before garbage collection |
| Raise it when | Data changes rarely (config, profile, taxonomies) | You want back-navigation to show instant cached data |
const { data } = useQuery({
queryKey: ["invoice", id],
queryFn: () => fetchInvoice(id),
staleTime: 60_000, // trust this for a minute; no refetch on remount within 60s
gcTime: 15 * 60_000, // keep it around 15 min after the last component unmounts
})While data is fresh, mounting another component with the same key is a zero-cost cache read. Once stale, Query still returns the cached value instantly, then refetches in the background on the next trigger (remount, window focus, reconnect, interval) and swaps in the new data. Stale-while-revalidate, done for you.
Invalidation and updates
// after a mutation, mark matching queries stale so they refetch when active
queryClient.invalidateQueries({ queryKey: ["invoice"] })
// or write the new value straight into the cache (optimistic / from the response)
queryClient.setQueryData(["invoice", id], updated)
// warm the cache before the user navigates
queryClient.prefetchQuery({ queryKey: ["invoice", id], queryFn: () => fetchInvoice(id) })placeholderData/keepPreviousDatashows the last page's data while the next page loads, so pagination and filters do not flash a spinner.- Structural sharing: after a refetch, unchanged parts of the response keep their object identity, so
React.memoanduseMemodownstream do not re-run needlessly. - SSR:
dehydrate()the client on the server,HydrationBoundaryon the client, and the first render has data with no request.
useState, a context, or Zustand. Stuffing UI state into the query cache gets you surprise refetches and eviction of things that should never disappear.What lives at which layer#
| Resource | Layer | Setting |
|---|---|---|
| Hashed JS / CSS / fonts / images | Browser + CDN | max-age=31536000, immutable |
| index.html / app shell | Browser + CDN | no-cache + ETag (optionally s-maxage + SWR at edge) |
| Public marketing pages / SSG output | CDN | s-maxage + stale-while-revalidate, purge by tag on publish |
| Semi-static JSON (nav, config, catalogs) | CDN + TanStack Query | s-maxage 5-60 min at edge; staleTime minutes in Query |
| User-specific / live data | TanStack Query only | no-store on the wire; staleTime 0-30s; invalidate on mutation |
| Offline-critical shell | Service worker | Precache hashed build; SWR for data |
Common mistakes#
- Long-caching
index.html. One deploy later, users load dead bundle URLs and see a blank page until their cache expires. - Unhashed asset names plus "purge everything" on deploy. Every release gives every user a cold cache and hammers the origin.
Vary: User-AgentorVary: Cookieon cacheable routes. Edge hit rate drops to near zero silently.- No
stale-while-revalidate. Every TTL expiry sends a real user all the way to the origin and makes them wait. staleTime: Infinityon data that changes. The UI shows last hour's numbers and no one knows why.- Treating TanStack Query as an HTTP cache. It ignores
Cache-Control; a304from your API does not "refresh" a Query entry.staleTimedoes. - Cache stampede. A popular key expires, a thousand tabs refetch at once. Use
stale-while-revalidate, request coalescing at the origin, and jittered TTLs.
immutable. Serve HTML with no-cache and an ETag. At the CDN, lean on s-maxage and stale-while-revalidate, and invalidate with hashed URLs or surrogate keys, never a full purge. On top of all that, TanStack Query is a separate in-memory layer for server data: tune staleTime to how fast the data really changes, and invalidate on mutation.