Portfolio/Writing/CDN and front-end caching: from edge headers to TanStack Query

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#

text
┌─────────────────────────────────────────────────────────┐
│ 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         │
└─────────────────────────────────────────────────────────┘
A request for data can be answered at any of these layers. The higher it is answered, the faster and cheaper it is.

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:

http
# for /assets/* : anything with a content hash in the name
Cache-Control: public, max-age=31536000, immutable
  • max-age=31536000 is one year, the conventional "forever".
  • immutable tells 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.
  • public lets shared caches (the CDN) store it too.
This only works if the filenames are actually hashed
If your build emits 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.

http
# 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.

DirectiveMeaningUse for
no-storeNever write to any cache at allTruly per-request, sensitive responses
no-cacheStore, but revalidate before every reuseHTML entry points, app shell
max-age=60Fresh for 60s, then revalidateSemi-static JSON, feeds
max-age=31536000, immutableFresh for a year, never revalidateHashed 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:

http
Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=86400, stale-if-error=604800
  • max-age=0 makes browsers always revalidate, which is what you want for HTML and API responses that must look live.
  • s-maxage=300 lets the CDN serve this from the edge for 5 minutes without touching your origin. It overrides max-age for 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:

StrategyHowTrade-off
Immutable hashed pathsNew deploy = new filenames; nothing to purgeBest. Requires a hashing build (you already have one)
Versioned deploy directoryUpload to /v/1234/…, flip a pointerAtomic, instant rollback; needs a router/pointer
Tag / surrogate-key purgeTag responses (Surrogate-Key: product-42), purge by tag on changePrecise; needs CDN support (Fastly, Cloudflare Enterprise, Akamai)
Path / URL purgeCall the CDN purge API for changed URLsFine for a few files; racy and slow at scale
Purge everythingWipe the whole zone on deployOrigin stampede + cold cache for every user. Last resort
http
# 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 else
Design for content-hashed URLs first
Every other invalidation strategy is damage control for URLs that stayed the same while their content changed. If a resource can have a hashed or versioned URL, give it one, and invalidation stops being a problem you operate.

Service 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 StaleWhileRevalidate or NetworkFirst.
  • Update choreography: a new SW waits until all tabs close unless you call skipWaiting() and prompt the user to reload.
Only add one if you need offline
A service worker is a second deployment with its own lifecycle. The most common bug reports for PWAs are "I deployed but users still see the old version". If you do not need offline or near-instant repeat loads, the HTTP and CDN layers already give you most of the speed with none of the update ceremony.

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:

staleTimegcTime (was cacheTime)
Default0 (immediately stale)5 minutes
ControlsHow long data is considered fresh; no refetch while freshHow long an unused (no mounted observer) query stays in memory before garbage collection
Raise it whenData changes rarely (config, profile, taxonomies)You want back-navigation to show instant cached data
ts
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

ts
// 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 / keepPreviousData shows 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.memo and useMemo downstream do not re-run needlessly.
  • SSR: dehydrate() the client on the server, HydrationBoundary on the client, and the first render has data with no request.
Do not use it as global UI state
Query keys are for server data. Modal open/closed, the current wizard step, an unsaved form: that is client state. Use 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#

ResourceLayerSetting
Hashed JS / CSS / fonts / imagesBrowser + CDNmax-age=31536000, immutable
index.html / app shellBrowser + CDNno-cache + ETag (optionally s-maxage + SWR at edge)
Public marketing pages / SSG outputCDNs-maxage + stale-while-revalidate, purge by tag on publish
Semi-static JSON (nav, config, catalogs)CDN + TanStack Querys-maxage 5-60 min at edge; staleTime minutes in Query
User-specific / live dataTanStack Query onlyno-store on the wire; staleTime 0-30s; invalidate on mutation
Offline-critical shellService workerPrecache hashed build; SWR for data

Common mistakes#

  1. Long-caching index.html. One deploy later, users load dead bundle URLs and see a blank page until their cache expires.
  2. Unhashed asset names plus "purge everything" on deploy. Every release gives every user a cold cache and hammers the origin.
  3. Vary: User-Agent or Vary: Cookie on cacheable routes. Edge hit rate drops to near zero silently.
  4. No stale-while-revalidate. Every TTL expiry sends a real user all the way to the origin and makes them wait.
  5. staleTime: Infinity on data that changes. The UI shows last hour's numbers and no one knows why.
  6. Treating TanStack Query as an HTTP cache. It ignores Cache-Control; a 304 from your API does not "refresh" a Query entry. staleTime does.
  7. Cache stampede. A popular key expires, a thousand tabs refetch at once. Use stale-while-revalidate, request coalescing at the origin, and jittered TTLs.
Summary
Hash your asset filenames and cache them for a year with 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.

Next up
SPA SEO without SSR: per-route metadata, canonicals and JSON-LD

A client-rendered Vite/React SPA can rank and share well without adopting Next.js. What Googlebot actually does with JavaScript, why social crawlers do not, and a pragmatic stack: per-route meta, canonical tags, structured data and pre-rendering.

Read next →