Skip to content

Caching and Performance

The fastest request is the one you never make. HTTP caching is the highest-leverage performance tool on the web, and it is entirely controlled by response headers you choose.

Every request costs a DNS lookup (sometimes), a TCP handshake, a TLS handshake, a round trip, and server work. On a 100 ms link that is 300 ms before a byte of content moves — regardless of how fast your server is.

Caching removes that cost at one of several layers:

Browser memory cache ← nanoseconds, per-tab, non-persistent
Browser disk cache ← microseconds, survives restarts
Service worker cache ← programmable, works offline
CDN / edge cache ← ~10 ms, shared across all users
Reverse proxy cache ← in front of your origin
Origin server ← the slow path

A request answered by any layer above the origin skips everything below it.

The primary header. It appears on responses (telling caches what to do) and occasionally on requests (telling caches what the client will accept).

Cache-Control: public, max-age=31536000, immutable
Directive Meaning
max-age=N Fresh for N seconds from the response time.
s-maxage=N Same, but only for shared caches (CDNs, proxies). Overrides max-age there.
public Any cache may store it, including shared ones.
private Only the browser may store it. For personalised responses.
no-cache Store it, but revalidate with the origin before every use. Not “do not cache”.
no-store Do not write it to any cache at all. For genuinely sensitive responses.
must-revalidate Once stale, never serve it without successful revalidation.
immutable The content will never change while fresh — skip revalidation even on reload.
stale-while-revalidate=N Serve stale for up to N more seconds while refreshing in the background.
stale-if-error=N Serve stale for up to N seconds if the origin errors.
# Fingerprinted static asset — /app.4f2a91c.js
Cache-Control: public, max-age=31536000, immutable
# HTML that must always be current
Cache-Control: no-cache
# Personalised API response
Cache-Control: private, no-cache
# Sensitive: bank statement, auth response
Cache-Control: no-store
# Public API data, cheap to serve slightly stale
Cache-Control: public, max-age=60, stale-while-revalidate=300

max-age=31536000 is one year — the practical maximum. It is only safe with content hashing, below.

The HTTP/1.0 predecessor: an absolute date instead of a duration.

Expires: Wed, 09 Sep 2026 12:00:00 GMT

Cache-Control: max-age overrides Expires wherever both appear. Relative durations survive clock skew, so prefer Cache-Control and treat Expires as legacy.

When a cached response goes stale, the client does not re-download blindly. It asks: “has this changed?” A validator makes that question cheap.

An ETag is an opaque token identifying a version of a resource — usually a hash of the content.

Terminal window
curl -I https://example.com/style.css
HTTP/1.1 200 OK
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Cache-Control: max-age=3600
Content-Length: 42318

Later, the client revalidates:

Terminal window
curl -I https://example.com/style.css \
-H 'If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"'
HTTP/1.1 304 Not Modified
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Cache-Control: max-age=3600

A 304 has no body. A few hundred bytes replace 42 KB, and the browser resets the freshness timer on its stored copy.

Strong vs weak. "abc123" is strong — byte-for-byte identical. W/"abc123" is weak — semantically equivalent but possibly not byte-identical (e.g. a differently compressed variant). Only strong ETags can satisfy Range requests.

The older, coarser mechanism: a timestamp with one-second resolution.

Last-Modified: Sun, 09 Aug 2026 10:23:00 GMT
Terminal window
curl -I https://example.com/style.css \
-H 'If-Modified-Since: Sun, 09 Aug 2026 10:23:00 GMT'
# HTTP/1.1 304 Not Modified

Serve both when you can. ETag takes precedence when both validators are present, and one-second granularity means Last-Modified cannot detect two changes within the same second.

The same validators prevent lost updates. If-Match makes a write conditional on the resource not having changed:

Terminal window
curl -X PUT https://api.example.com/users/42 \
-H 'If-Match: "33a64df5"' \
-H 'Content-Type: application/json' \
-d '{"name":"Ada"}'
# HTTP/1.1 412 Precondition Failed ← someone else edited it first

This is optimistic concurrency control, built into HTTP.

A cache keys on the URL. If a URL can return different content depending on a request header, the cache must be told — otherwise one user’s response is served to another.

Vary: Accept-Encoding
Vary: Accept-Encoding, Accept-Language

Also add Vary: Origin whenever you echo an origin into Access-Control-Allow-Origin. See CORS.

Cache Scope Controlled by
Browser memory One tab, until closed Browser heuristics
Browser disk One user, persistent Cache-Control, validators
Service worker One origin, programmable Your JavaScript, via the Cache API
CDN / edge All users, geographically distributed s-maxage, Cache-Control, purge APIs
Reverse proxy All users, in your infrastructure Nginx/Varnish config + headers
Forward proxy An organisation’s users Corporate config

The private/shared split maps directly onto this: private restricts a response to the browser layers, public allows the shared ones. Getting that wrong on an authenticated response is a data-leak bug, not a performance bug.

Verify which layer answered:

Terminal window
curl -sI https://example.com/app.js | grep -iE 'cache-control|etag|age|x-cache|cf-cache-status'

Age is the standards-based signal — how many seconds a shared cache has held the response. X-Cache: HIT and CF-Cache-Status: HIT are vendor-specific and vary by CDN.

The hard problem: you want assets cached for a year, but you also want users to get new ones the moment you deploy. Purging a CDN and every browser cache is not possible.

The solution is to never change a URL’s content. Put a hash of the content in the filename:

/assets/app.4f2a91c.js
/assets/main.9be71d3.css
/assets/logo.a3f81e0.svg
  • Contents change → hash changes → filename changes → it is a different URL, cached separately, fetched immediately.
  • Contents unchanged → same filename → the year-long cached copy is reused.
# The hashed asset — cache forever
Cache-Control: public, max-age=31536000, immutable
# The HTML that references it — never cache
Cache-Control: no-cache

The HTML is the only thing that must be revalidated. It is small, its revalidation is usually a 304, and it points at whichever hashed assets are current. Every bundler (Vite, webpack, esbuild, Rollup) produces hashed filenames by default.

immutable matters here: without it, a browser reload revalidates every asset even when fresh. With it, the browser skips the check entirely.

Text compresses enormously — HTML, CSS, JS and JSON typically shrink by 70–90%.

The client advertises what it supports and the server picks:

Accept-Encoding: gzip, deflate, br, zstd
Content-Encoding: br
Vary: Accept-Encoding
Algorithm Notes
gzip Universally supported. The safe baseline.
brotli (br) ~15–20% smaller than gzip on text at comparable speed. Supported by every modern browser over HTTPS.
zstd Newer, very fast; browser support is growing but not universal.
Terminal window
# Uncompressed
curl -sI https://example.com/app.js | grep -i content-length
# Ask for brotli
curl -sI -H 'Accept-Encoding: br' https://example.com/app.js | grep -iE 'content-encoding|content-length'
# Compare actual transfer sizes
curl -s -o /dev/null -w 'plain: %{size_download}\n' https://example.com/
curl -s -o /dev/null -w 'gzip: %{size_download}\n' -H 'Accept-Encoding: gzip' https://example.com/

Do not compress already-compressed formats — JPEG, PNG, WebP, MP4, ZIP. You burn CPU for nothing, occasionally growing the file.

For static assets, pre-compress at build time (app.js.br, app.js.gz) and have the server serve the right variant; brotli at maximum quality is too slow to do per request but free when done once.

Strip comments, whitespace and long identifier names. Roughly 30–50% off JS before compression, and it composes with gzip/brotli rather than competing with it. Standard in every bundler.

Multiplexing removes the per-connection request limit, so old workarounds actively hurt now:

  • Domain sharding — splits requests across connections, which HTTP/2 does not need and which costs extra DNS + TLS handshakes.
  • Sprite sheets and inlined bundles — one large file means one changed byte invalidates everything. Smaller files cache better when requests are cheap.

See protocol versions.

<!-- Do DNS + TCP + TLS to a third-party origin now, before it's needed -->
<link rel="preconnect" href="https://fonts.example.com" crossorigin>
<!-- DNS only — cheaper, less benefit -->
<link rel="dns-prefetch" href="https://analytics.example.com">
<!-- Fetch now at high priority: this is needed for the current page -->
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/hero.webp" as="image">
<!-- Fetch at low priority: probably needed for the NEXT navigation -->
<link rel="prefetch" href="/dashboard.js">

as is required on preload — it sets the priority and the correct Accept header. Fonts always need crossorigin, even same-origin, because font requests are made in CORS mode.

Do not load what is not visible.

<img src="/photo.webp" loading="lazy" width="800" height="600" alt="">
<iframe src="/map" loading="lazy"></iframe>

loading="lazy" is native in all current browsers. Always set width and height (or aspect-ratio in CSS) so the browser reserves the space — otherwise content jumps when the image arrives, which is exactly what CLS measures.

Never lazy-load your above-the-fold hero image; that delays your LCP.

For JavaScript, split at the route or interaction boundary:

button.addEventListener('click', async () => {
const { openEditor } = await import('./editor.js'); // fetched on demand
openEditor();
});

Usually the largest thing on a page. In rough order of impact: use modern formats (WebP, AVIF), serve appropriately sized variants with srcset/sizes, and compress hard.

<img
src="/photo-800.webp"
srcset="/photo-400.webp 400w, /photo-800.webp 800w, /photo-1600.webp 1600w"
sizes="(max-width: 600px) 100vw, 800px"
width="800" height="600" loading="lazy" alt="A description">

Google’s three field metrics. They matter because they measure what a user actually experiences, and they are measurable in the wild rather than only in a lab.

Metric Measures Good Poor
LCP — Largest Contentful Paint Time until the largest visible element (usually the hero image or headline) renders. “Did it load?” ≤ 2.5 s > 4.0 s
INP — Interaction to Next Paint Overall responsiveness: latency from an interaction to the next visual update, across the whole visit. “Is it responsive?” ≤ 200 ms > 500 ms
CLS — Cumulative Layout Shift How much visible content moves unexpectedly. Unitless score. “Is it stable?” ≤ 0.1 > 0.25

INP replaced FID (First Input Delay) as a Core Web Vital in March 2024. FID only measured the delay before the first handler started; INP measures the full input-to-paint time for interactions throughout the visit, which is far closer to perceived responsiveness.

Supporting metrics: TTFB (Time to First Byte — server and network latency), FCP (First Contentful Paint — first pixel of content).

  • LCP — server response time, render-blocking CSS and JS, unoptimised hero images, late-discovered resources. Fix with caching, compression, preload on the hero, and smaller critical CSS.
  • INP — long tasks on the main thread. Fix by breaking up work, moving it to a Web Worker, and eliminating layout thrashing. See rendering.
  • CLS — images and iframes without dimensions, fonts that swap and reflow text, banners injected above existing content. Fix with explicit width/height, font-display: optional or swap with a matched fallback, and reserving space for anything injected.
  • Lighthouse (devtools → Lighthouse) — lab data. Reproducible, good for catching regressions, but a simulated device on a simulated network.
  • Chrome UX Report (CrUX) — field data from real Chrome users. What actually counts for search ranking.
  • The web-vitals library — measure real users in your own analytics using the same definitions the browser uses.
Terminal window
# The fastest server-side signal: where is the time actually going?
curl -s -o /dev/null -w 'dns:%{time_namelookup}s connect:%{time_connect}s tls:%{time_appconnect}s ttfb:%{time_starttransfer}s total:%{time_total}s\n' \
https://example.com/

If TTFB is high, no amount of front-end work will save you — fix the server or put a CDN in front of it. If TTFB is low and LCP is high, the problem is in the browser.

  • no-cache revalidates before use; no-store never writes to a cache at all.
  • Validators (ETag, Last-Modified) turn a stale-cache hit into a bodyless 304 instead of a full download.
  • Hash your asset filenames, cache them for a year with immutable, and serve the referencing HTML with no-cache.
  • public vs private decides whether a CDN may store a response — get it wrong on authenticated content and you leak user data.
  • Set Vary for anything that varies by request header, and never cache personalised responses in a shared cache.
  • Compress text with brotli or gzip; never compress already-compressed media.
  • LCP is loading, INP is responsiveness, CLS is stability — and TTFB tells you which side of the wire to fix.