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.
Why caching matters
Section titled “Why caching matters”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-persistentBrowser disk cache ← microseconds, survives restartsService worker cache ← programmable, works offlineCDN / edge cache ← ~10 ms, shared across all usersReverse proxy cache ← in front of your originOrigin server ← the slow pathA request answered by any layer above the origin skips everything below it.
Cache-Control
Section titled “Cache-Control”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. |
Typical policies
Section titled “Typical policies”# Fingerprinted static asset — /app.4f2a91c.jsCache-Control: public, max-age=31536000, immutable
# HTML that must always be currentCache-Control: no-cache
# Personalised API responseCache-Control: private, no-cache
# Sensitive: bank statement, auth responseCache-Control: no-store
# Public API data, cheap to serve slightly staleCache-Control: public, max-age=60, stale-while-revalidate=300max-age=31536000 is one year — the practical maximum. It is only safe with content hashing, below.
Expires
Section titled “Expires”The HTTP/1.0 predecessor: an absolute date instead of a duration.
Expires: Wed, 09 Sep 2026 12:00:00 GMTCache-Control: max-age overrides Expires wherever both appear. Relative durations survive clock skew, so prefer Cache-Control and treat Expires as legacy.
Validation and 304
Section titled “Validation and 304”When a cached response goes stale, the client does not re-download blindly. It asks: “has this changed?” A validator makes that question cheap.
ETag / If-None-Match
Section titled “ETag / If-None-Match”An ETag is an opaque token identifying a version of a resource — usually a hash of the content.
curl -I https://example.com/style.cssHTTP/1.1 200 OKETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"Cache-Control: max-age=3600Content-Length: 42318Later, the client revalidates:
curl -I https://example.com/style.css \ -H 'If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"'HTTP/1.1 304 Not ModifiedETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"Cache-Control: max-age=3600A 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.
Last-Modified / If-Modified-Since
Section titled “Last-Modified / If-Modified-Since”The older, coarser mechanism: a timestamp with one-second resolution.
Last-Modified: Sun, 09 Aug 2026 10:23:00 GMTcurl -I https://example.com/style.css \ -H 'If-Modified-Since: Sun, 09 Aug 2026 10:23:00 GMT'# HTTP/1.1 304 Not ModifiedServe 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.
Conditional writes
Section titled “Conditional writes”The same validators prevent lost updates. If-Match makes a write conditional on the resource not having changed:
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 firstThis 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-EncodingVary: Accept-Encoding, Accept-LanguageAlso add Vary: Origin whenever you echo an origin into Access-Control-Allow-Origin. See CORS.
Where caches live
Section titled “Where caches live”| 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:
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.
Invalidation via content hashing
Section titled “Invalidation via content hashing”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 foreverCache-Control: public, max-age=31536000, immutable
# The HTML that references it — never cacheCache-Control: no-cacheThe 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.
Compression
Section titled “Compression”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, zstdContent-Encoding: brVary: 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. |
# Uncompressedcurl -sI https://example.com/app.js | grep -i content-length
# Ask for brotlicurl -sI -H 'Accept-Encoding: br' https://example.com/app.js | grep -iE 'content-encoding|content-length'
# Compare actual transfer sizescurl -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.
Loading performance
Section titled “Loading performance”Minification
Section titled “Minification”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.
HTTP/2 and HTTP/3
Section titled “HTTP/2 and HTTP/3”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.
Resource hints
Section titled “Resource hints”<!-- 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.
Lazy loading
Section titled “Lazy loading”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();});Images
Section titled “Images”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">Measuring: Core Web Vitals
Section titled “Measuring: Core Web Vitals”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).
What moves each one
Section titled “What moves each one”- LCP — server response time, render-blocking CSS and JS, unoptimised hero images, late-discovered resources. Fix with caching, compression,
preloadon 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: optionalorswapwith a matched fallback, and reserving space for anything injected.
Measuring in practice
Section titled “Measuring in practice”- 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-vitalslibrary — measure real users in your own analytics using the same definitions the browser uses.
# 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.
Key points
Section titled “Key points”no-cacherevalidates before use;no-storenever writes to a cache at all.- Validators (
ETag,Last-Modified) turn a stale-cache hit into a bodyless304instead of a full download. - Hash your asset filenames, cache them for a year with
immutable, and serve the referencing HTML withno-cache. publicvsprivatedecides whether a CDN may store a response — get it wrong on authenticated content and you leak user data.- Set
Varyfor 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.