Making a page fast once is the easy part. The Lead skill is keeping it fast forever — beat the two enemies of speed: repeated work (fix with caching) and silent regression & decay (fix with budgets and by not leaking memory).
Lessons 01–07 made the page fast. But two forces erode that:
“Making it fast once is easy; keeping it fast across 20 teams and a six-month session is the real job. So I cache repeated work, budget regressions in CI, and watch real-user metrics — performance as a discipline, not a heroic one-off.”
“Cache” isn't one thing; it's a stack, and a Lead names the layer. The same request can be served from any of these, closest-and-cheapest first: [web.dev]
| Layer | Caches | Controlled by |
|---|---|---|
| Browser HTTP cache | static assets (JS/CSS/img/fonts) on the user's disk | Cache-Control response header |
| CDN / edge cache | assets and cacheable HTML/API responses, near the user | Cache-Control: s-maxage, edge config |
| App-level data cache | server state (JSON) in memory, in the JS app | React Query / SWR (§3) |
(A fourth, the Service Worker + Cache API, gives you a programmable cache for offline — that's Lesson 20, with the security headers.)
Cache-Control is the control surface. The handful you must know cold:
| Directive | Meaning |
|---|---|
max-age=N | fresh for N seconds — serve from cache, no network at all. |
no-cache | Store it, but revalidate every time before using (via ETag → maybe a cheap 304). Not “don't cache.” |
no-store | Never write to cache at all (sensitive/personalized responses). |
immutable | Don't even revalidate on reload — the bytes will never change. |
s-maxage=N | Freshness for shared caches (CDN), overriding max-age there. |
private / public | private = browser only (user-specific); public = CDN may cache too. |
The pattern that powers every modern build (Lesson 05's content hashes pay off here):
// Hashed static assets — the hash IS the cache key. Cache forever.
app.a8f3c2.js → Cache-Control: max-age=31536000, immutable
// HTML — short-lived, so a new deploy is picked up promptly.
index.html → Cache-Control: no-cache // revalidate every load
Because the filename changes when the content changes (app.a8f3c2.js → app.b1d9e0.js), you can cache assets for a year and still ship instantly: the new HTML simply references new filenames, and old ones are never requested. That's how you get cache-bust on deploy without ever serving stale code. [web.dev]
ETag / 304When something might have changed, the browser revalidates: it sends If-None-Match: "<etag>"; if the server's ETag matches, it replies 304 Not Modified with no body. You still pay the round trip, but save the download. [MDN]
“Content-hash the assets and cache them immutably for a year; keep the HTML on no-cache so deploys land instantly. The hash is the cache key, so I get aggressive caching and instant busting at once — the only thing that stays hard is invalidating HTML and API responses.”
stale-while-revalidate is the “best of both” caching move: serve the stale cached copy instantly, and refresh it in the background for next time. The user never waits, and the data converges to fresh. It exists both as an HTTP directive and as the core idea behind modern data libraries: [web.dev]
Cache-Control: max-age=600, stale-while-revalidate=3600
// 0–10 min: fresh. 10–70 min: serve stale instantly + refetch in background.
The third cache layer lives in your app. Server state — data you fetch from an API — is fundamentally different from client/UI state: it's shared, it goes stale, and many components want it. Libraries like TanStack Query (React Query) and SWR cache it in memory and give you, by default: request deduplication, stale-while-revalidate refetching, background updates, and cache invalidation on mutation. [TanStack]
Caches whole HTTP responses on disk/edge by URL. Great for static assets; coarse for dynamic, per-user data.
Caches logical queries (['hotel', id]), dedupes in-flight requests, refetches in the background, invalidates on mutation. The right tool for the search/results data.
“Server state isn't UI state — it's shared and it goes stale, so I cache it with React Query: stale-while-revalidate, dedupe, background refetch. The user sees instant data that converges to fresh — except checkout, where I force an authoritative read.”
Caching makes it fast; budgets keep it fast. A performance budget is a hard limit on a metric that fails the build if exceeded — so a regression is caught in review, not in production. [web.dev] Three kinds:
| Budget type | Example limit | Enforced by |
|---|---|---|
| Quantity | main bundle ≤ 170 KB gzipped; ≤ 10 requests | size-limit / bundlesize in CI |
| Milestone (timing) | LCP ≤ 2.5s, TBT ≤ 200ms on a throttled run | Lighthouse CI assertions |
| Rule-based | Lighthouse Performance score ≥ 90 | Lighthouse CI |
The two halves of the guardrail — one before merge, one after deploy:
size-limit) and Lighthouse CI (lhci) run on every PR and fail the build over budget. The regression literally can't merge. [Lighthouse CI]web-vitals library (Lesson 04), watch p75 LCP/INP/CLS on a dashboard, and alert when a release moves the curve. [web.dev]“I make regressions un-mergeable: a bundle-size gate and Lighthouse CI fail the PR over budget, and RUM with p75 web-vitals catches what the lab can't, with alerts on release. That turns performance from one person's crusade into a team guardrail.”
An SPA doesn't reload between routes, so memory that's allocated and never freed accumulates over a long session — and a travel user keeps that tab open for hours across many searches. The page gets slower, then the tab crashes. The usual culprits in a SPA: [MDN]
setInterval that's never clearInterval'd keeps its closure (and everything it references) alive.IntersectionObserver, WebSocket handlers not torn down.In React these almost always reduce to a missing cleanup: every useEffect that subscribes, times, or listens must return a cleanup function that undoes it.
useEffect(() => {
const id = setInterval(poll, 5000);
window.addEventListener('resize', onResize);
return () => { // ← cleanup: the leak fix
clearInterval(id);
window.removeEventListener('resize', onResize);
};
}, []);
Finding them: Chrome DevTools Memory panel → take a heap snapshot, exercise the flow (navigate away and back a few times), take another, and compare — objects/nodes that keep growing are leaking. Filter by “Detached” to spot orphaned DOM. The Performance monitor shows the JS heap and DOM-node count climbing in real time. [Chrome DevTools]
“SPAs don't reload, so a per-navigation leak compounds over a long session. Most leaks are a missing teardown — a listener or timer with no cleanup — so every effect that subscribes must unsubscribe. I confirm with comparison heap snapshots and the detached-nodes filter.”
Concept: keep it fast forever — cache repeated work (browser/CDN/app-data), guard regressions in CI + RUM, and don't leak memory. Trade-off: every cache risks staleness and every budget adds friction, so I tune TTLs per data criticality and set budgets from a real target with headroom. Anchor: “We content-hashed assets for year-long immutable caching, moved search data to React Query with stale-while-revalidate, added a size-limit + Lighthouse-CI gate, and a RUM p75 dashboard with release alerts — perf stopped regressing release-over-release.” Impact: it's the difference between one fast launch and a site that stays fast across many teams and long sessions — directly tied to conversion and CWV. Invite: “If we were a small team I'd lean on CrUX and a single budget; at our scale I'd invest in the full guardrail so no single PR can quietly regress the whole site.”
Pick an answer; instant feedback. Push-back style, like the round.
1. You ship content-hashed JS like app.a8f3c2.js. What Cache-Control do you set, and why is it safe?
2. A teammate says Cache-Control: no-cache means “don't cache this.” Correct them.
3. What does an ETag + 304 Not Modified actually save you?
4. What problem does stale-while-revalidate solve?
5. Why use React Query / SWR for API data instead of just relying on the browser HTTP cache?
6. Where would you not want stale-while-revalidate, and what do you do instead?
scn: the field is a final checkout price / remaining seats.
7. Most Lead answer to “how do you stop performance regressing as 20 teams ship daily?”
8. Why is a pre-merge Lighthouse CI gate not enough on its own?
9. A travel user keeps a tab open all afternoon and it slowly grinds to a halt. Likeliest cause & the fix?
10. How do you confirm a suspected leak in DevTools?
0 / 10 answered