Lesson 6 of 9 · 24m
Caching and Delivery: Making the Second Visit Free
Cache-Control strategy for hashed assets versus HTML, CDN behaviour, connection setup, and the back/forward cache — the fastest request is the one never made.
I once watched a site's repeat-visit LCP get worse after a CDN migration. The new CDN config respected the origin's Cache-Control: no-store on everything — including fingerprinted JS and fonts — so every navigation re-downloaded 600 KB that hadn't changed in weeks. Caching isn't glamorous, but it's the cheapest performance win you will ever ship, and the easiest to quietly break.
The two-tier strategy
Almost every modern site should cache in exactly two ways:
- Fingerprinted assets (
index-3f9a1c.js,inter-var-8b2e.woff2): the filename changes whenever the content does, so the URL can be cached forever. - HTML and unversioned URLs: must revalidate, so a deploy is visible immediately.
# /etc/nginx/conf.d/shop.conf
location ~* ^/assets/.+-[0-9a-f]{6,}[.](js|css|woff2|avif|webp|svg)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
location / {
# cache it, but check with the server before every use (cheap 304 via ETag)
add_header Cache-Control "no-cache";
etag on;
try_files $uri /index.html;
}immutable tells the browser not to even revalidate on reload. no-cache is badly named: it means "store, but revalidate," not "don't cache." no-store is the one that means never store — reserve it for genuinely sensitive responses.
Let the CDN absorb TTFB
TTFB is the first LCP sub-part, and for users far from your origin it's dominated by round trips. Caching HTML at the edge — even briefly — collapses it:
Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=600Browsers always revalidate (max-age=0), the CDN serves its copy for 60 s (s-maxage), and for ten minutes after that it can serve the stale copy instantly while fetching a fresh one in the background. For product and article pages this is often the difference between an 1,800 ms and a 150 ms TTFB in distant regions. Personalized HTML needs a different design — cache the shell, fetch the personal bits.
Connection setup is hidden latency
Each new origin costs DNS + TCP + TLS — easily 300–600 ms on mobile before a single byte. Reduce origins on the critical path (self-host fonts, serve images from your main domain or CDN), and warm up the unavoidable ones:
<link rel="preconnect" href="https://img.northwindgear-cdn.com" crossorigin>
<link rel="dns-prefetch" href="https://analytics.northwindgear.com">Preconnect only to two or three origins you will definitely use early; each one costs a socket and CPU. Servers and CDNs that support 103 Early Hints can send these hints (and preloads) while the HTML is still being generated, reclaiming server think time.
The back/forward cache
The bfcache keeps a full page snapshot in memory, so Back and Forward restore it instantly — effectively zero LCP. Pages become ineligible if they register unload handlers, hold open connections in some cases, or (in many browsers) are served with Cache-Control: no-store. DevTools > Application > Back/forward cache has a "Test" button that lists exactly what blocked you.
Field notes
- Verify with real headers:
curl -sI https://shop.northwindgear.com/assets/index-3f9a1c.js | grep -i -E 'cache-control|content-encoding|age'. The config you think is deployed is not always the config that's deployed. - A CDN
ageheader climbing andx-cache: HITmeans edge caching works; constantMISSusually means aSet-CookieorVary: Cookieis busting it. - Service workers can make repeat visits instant — and can also pin users to a broken build for weeks. Treat SW caching as a deployment system with a rollback plan.
- Segment RUM by
navigationType:back-forward-cacheloads should be near-zero LCP; if they're not showing up, something is blocking eligibility.