Most performance advice on the internet is a fossil. Minify your JavaScript, compress your images, add loading="lazy", chase a green Lighthouse score — all of it was written for a web where the hard problem was getting bytes down the wire. That problem is largely solved: HTTP/3 is everywhere, Brotli is table stakes, CDNs are cheap, every bundler tree-shakes. Yet sites still feel slow, in ways the old checklist never touches: a tap that does nothing for half a second, a navigation that white-flashes and rebuilds the page, a back button that re-fetches everything you just looked at.
At Luminary we build and rehabilitate client sites for a living, and the pattern we see over and over is teams optimizing metrics nobody feels while ignoring the ones everybody does. This is the checklist we actually work from now: what Core Web Vitals measure today, why your lab scores lie, the browser APIs that make navigation genuinely instant, and a prioritized playbook for a typical slow site.

Core Web Vitals today: LCP, CLS, and the metric that changed everything
The three Core Web Vitals and their thresholds, assessed at the 75th percentile of real page loads:
| Metric | Good | Needs improvement | Poor | What it measures |
|---|---|---|---|---|
| LCP (Largest Contentful Paint) | ≤ 2.5 s | 2.5–4 s | > 4 s | When the main content renders |
| CLS (Cumulative Layout Shift) | ≤ 0.1 | 0.1–0.25 | > 0.25 | How much the page jumps around |
| INP (Interaction to Next Paint) | ≤ 200 ms | 200–500 ms | > 500 ms | How fast the page responds to input |
LCP and CLS are mostly well understood by now. The interesting one is INP, which replaced First Input Delay in March 2024 and quietly reshuffled which sites count as "fast."
What INP actually measures, and why it's harder than FID
FID was a softball. It measured only the input delay of the first interaction — how long the browser took to start running your event handler, once, during load. You could ship a megabyte of janky JavaScript and still pass, as long as the main thread happened to be free at the moment of the first click. Most sites passed. The metric told you almost nothing.
INP measures the full latency of an interaction — input delay, plus your event handlers running, plus the time until the browser paints the next frame — for every click, tap, and keypress across the entire page lifetime, then reports roughly the worst one (a high percentile on interaction-heavy pages, ignoring a few outliers). The difference is brutal in practice:
- You can't hide behind load. A page that loads fast but chokes when someone opens a filter panel fails INP.
- Rendering counts. FID stopped the clock when your handler started; INP runs until the next paint. A handler that finishes in 10 ms but triggers a 400 ms re-render of a huge component tree is a 400 ms+ interaction.
- The worst interaction wins. One slow autocomplete on an otherwise snappy page drags the whole page's INP down.
When INP took over, plenty of sites that had passed Core Web Vitals for years suddenly didn't. They didn't get slower; the measurement finally got honest.
Field data vs lab data: why your Lighthouse score misleads
There are two kinds of performance data, and confusing them is the most common mistake we see in client audits.
Lab data is a synthetic run: Lighthouse, WebPageTest, a DevTools trace — one simulated device, one simulated network, one cold load, no interaction. Field data is what real users experienced: the Chrome UX Report (CrUX), a 28-day rolling aggregate from opted-in Chrome users, or your own RUM (real user monitoring).
Lighthouse is a diagnostic tool, not a verdict, and it misleads in predictable ways:
- It cannot measure INP at all. It's a page load tool; it never interacts with your page. Total Blocking Time is its proxy, and TBT correlates with INP only loosely — we've audited pages with excellent TBT and terrible field INP, because the expensive work only ran when a user typed into search.
- One device, one network. Your users are a distribution, and Core Web Vitals grade you on its 75th percentile — which often looks nothing like a Lighthouse run, even a throttled one.
- Cold loads only. Real users navigate around, hit the bfcache, come back with warm caches. Lab tools see none of it.
The rule we work by: field data tells you whether you have a problem; lab data helps you find why. Never celebrate a Lighthouse 100 while CrUX says your p75 INP is 380 ms, and never ship an "optimization" you can't see reflected in field data a few weeks later.
The instant-navigation toolkit
The biggest perceived-performance wins right now aren't about making pages load faster — they're about making navigation not feel like loading at all. Three browser features do most of the work.
Speculation Rules: prefetch and prerender, declaratively
The Speculation Rules API tells the browser to prefetch — or fully prerender, in a hidden background renderer — the pages a user is likely to visit next. A prerendered page has already fetched, parsed, executed, and painted by the time the user clicks; the navigation is effectively zero milliseconds. It's a JSON script block, and the modern form uses document rules so you don't enumerate URLs:
<script type="speculationrules">
{
"prerender": [
{
"where": {
"and": [
{ "href_matches": "/*" },
{ "not": { "href_matches": ["/logout", "/api/*", "/cart/*"] } },
{ "not": { "selector_matches": ".no-prerender" } }
]
},
"eagerness": "moderate"
}
],
"prefetch": [
{
"where": { "href_matches": "/*" },
"eagerness": "conservative"
}
]
}
</script>
The eagerness field is the throttle: immediate speculates as soon as the rule matches, moderate waits for a signal like the pointer hovering a link, conservative waits until pointer-down — which still buys back 100–200 ms versus waiting for the click. For a content or marketing site, moderate prerendering of primary nav targets makes navigation feel native. Chrome caps concurrent speculations and backs off under memory pressure, so it degrades sensibly.
Browser support is the caveat to design around: this is currently a Chromium feature. Chrome and Edge have shipped it for years (prerender rules since 105); Safari has an implementation behind a flag in 26.2 but nothing on by default; Firefox has signaled interest in the prefetch half but hasn't shipped. Unsupported browsers silently ignore the script block, making it a perfect progressive enhancement — Chromium is the majority of traffic on most sites we work on, so "instant for most users, normal for the rest" is a trade we take every time.
Two operational warnings. Prerendering runs your page for real — analytics, ads, and A/B assignments will fire unless you check document.prerendering and defer side effects to the prerenderingchange event. And never speculate on URLs with side effects (/logout above is not decoration).
bfcache: the fastest navigation is the one you don't do
The back/forward cache keeps a full snapshot of a page — DOM, JS heap, scroll position — in memory when the user navigates away, and restores it instantly on back. Every browser ships it. It's the cheapest "instant" you can get, and an astonishing number of sites break it by accident. The common eligibility killers:
unloadevent handlers (often from an old analytics snippet). Usepagehideinstead.Cache-Control: no-storeon the document response. Useno-cacheif you need revalidation; reserveno-storefor genuinely sensitive pages.- Open connections — WebSockets, WebRTC, sometimes pending fetches — at navigation time.
Auditing takes minutes: DevTools → Application → Back/forward cache tells you exactly why a page was rejected, and in RUM, restores show up as pageshow events with event.persisted === true. If your site is bfcache-ineligible today, fixing that is likely the best ratio of perceived speed to engineering effort on your entire backlog.
View Transitions: continuity instead of the white flash
View Transitions don't make anything faster, but they change what slowness feels like: instead of a hard cut — white flash, layout rebuild — the browser snapshots old and new states and animates between them. Same-document transitions (SPA route changes, UI state) are supported across the major engines now. Cross-document transitions — real MPA navigations animating smoothly, in pure CSS — shipped in Chrome/Edge 126 and Safari 18.2, with Firefox still working toward parity, so treat them as progressive enhancement too.
They pair beautifully with speculation rules: prerender makes the next page ready instantly; the transition makes the swap feel intentional. That combination is the closest the multi-page web has come to native-app navigation, with roughly zero JavaScript. We use the same-document variant for the theme-change wipe on our own site.
The JavaScript diet
None of the above saves you if you ship too much JavaScript, because JavaScript costs you twice: bytes on the network, then CPU on the main thread. On the mid-range Android phones that dominate the real-world 75th percentile, the CPU cost dwarfs the network cost.
Hydration is the tax for rendering twice. Classic SSR frameworks render HTML on the server, then re-run the component tree in the browser to attach event handlers. Users see content quickly, then can't interact with it — hydration's long tasks are a classic source of terrible early-load INP. The architectural answers have matured:
- Islands. Most of a marketing or content page is static. Islands architecture (Astro popularized it) ships zero JS for static regions and hydrates only interactive widgets, ideally on visibility or interaction. The mental model applies without Astro: on our Next.js work, sections are server components and each interactive widget is a small
"use client"island. The discipline that matters is refusing to make a whole section a client component because one button needs state. - Server components. React Server Components keep component code and its dependencies off the client bundle entirely — the data-heavy parts of a page are simply never shipped.
- Code splitting that matters. Route-level splitting (free in every meta-framework) plus lazy-loading the two or three genuinely heavy things — the chart library, the rich-text editor, the map — behind interaction. That's 90% of the win; shaving 3 KB off a utility import is a rounding error. And audit third-party scripts ruthlessly: the tag manager with twelve forgotten pixels is frequently the single largest main-thread consumer on the page, and deleting it outperforms any optimization you could write.
Debugging INP in practice
When field data says INP is bad, the workflow looks like this.
Find the guilty interactions. RUM first: the web-vitals attribution build tells you which element, which event type, and how the time split between input delay, processing, and presentation. Then reproduce in the DevTools Performance panel with 4–6× CPU throttling — interactions get flagged with their INP breakdown, and long tasks (over 50 ms) show up striped in red.
Diagnose by phase. Long input delay means the main thread was already busy when the user interacted — typically hydration, a third-party script, or a timer. Long processing is your handler doing too much synchronously. Long presentation delay is rendering: too many DOM nodes invalidated, expensive style recalculation, layout thrash.
Break up the work. The core technique is yielding so the browser can paint between chunks of work: do the minimum needed for visual feedback, yield, then do the rest.
async function onFilterChange(value) {
// 1. Cheap, user-visible feedback first.
spinner.show();
// 2. Yield so the browser can paint that feedback.
await yieldToMain();
// 3. The expensive part happens after the frame.
const results = filterTenThousandRows(value);
renderResults(results);
}
function yieldToMain() {
if (globalThis.scheduler?.yield) {
return scheduler.yield(); // continuation is prioritized, not sent to the back of the queue
}
return new Promise((resolve) => setTimeout(resolve, 0));
}
scheduler.yield() is the purpose-built primitive: unlike a bare setTimeout, its continuation goes to the front of the task queue, so you yield without losing your place to whatever else is pending. It's in Chrome, Edge, and (since mid-2025) Firefox; Safari hasn't shipped the Scheduler API, hence the fallback above.
React-specific offenders, since that's most of what we're handed: state updates on every keystroke that re-render a large tree (fix with useDeferredValue or startTransition, or move the state down so less of the tree subscribes); a thousand-row list with no virtualization; context providers high in the tree whose value changes on input; effects doing synchronous layout reads after every render. React's profiler tells you which components rendered; the browser's performance panel tells you what it cost. You need both.
Don't skip the cheap wins either: debounce work that doesn't need to run per-keystroke, prefer CSS (content-visibility, transform/opacity animation) over JS, and move genuinely CPU-bound work to a Web Worker.
Images and fonts, current edition
Images. AVIF is supported everywhere that matters and typically beats WebP by 20–30% at equivalent quality; serve it via <picture> or an image CDN that negotiates formats. Three attributes decide your LCP: fetchpriority="high" on the LCP image (and never loading="lazy" on it — lazy-loading the hero is the most common LCP self-inflicted wound we find), honest srcset/sizes so a phone isn't decoding a 2400-pixel-wide hero, and explicit dimensions so images reserve space instead of causing CLS.
Fonts. Subset aggressively — a Latin subset of a variable font is often under 30 KB where the full family was several hundred — and self-host as WOFF2 with a preload for the one or two files above the fold. Then kill the layout shift font-display: swap causes by metric-matching your fallback with size-adjust and the override descriptors:
@font-face {
font-family: "Inter-fallback";
src: local("Arial");
size-adjust: 107%;
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}
body {
font-family: Inter, "Inter-fallback", sans-serif;
}
When the web font arrives, the swap happens with near-zero reflow because the fallback occupied the same space. Tools like Fontaine and Capsize compute the override numbers, and next/font does the whole dance automatically — which is why we default to it.
TTFB and the edge
Nothing downstream can be faster than your Time to First Byte; it's the floor under LCP. Google's guidance: aim for ≤ 800 ms at p75, with > 1800 ms firmly poor. The fixes, in order of typical impact: cache HTML at the CDN whenever you can (static generation or ISR — a cached page's TTFB is one CDN hop); if you must render per-request, hunt the serial database and API calls inside the render, which are the usual culprit, not the framework; and stream the shell so the browser starts on <head> — preloads, fonts, CSS — while the server finishes the body.
Edge rendering — SSR in a lightweight runtime at CDN locations near the user — is real but oversold. It shaves round-trips to the runtime; but if your data lives in one region, you've moved the render close to the user and far from the database, and every serial query now crosses an ocean. Put compute near your data, cache near your users.
Measuring what users feel, on a budget
You don't need an expensive RUM vendor to know how your site performs.
Free tier: CrUX, via PageSpeed Insights or the CrUX API, gives you field p75 for all Core Web Vitals — if your site has enough Chrome traffic to be in the dataset. Good for the verdict, useless for the diagnosis.
Nearly-free tier: the web-vitals library plus your own endpoint.
import { onLCP, onCLS, onINP } from "web-vitals/attribution";
function send(metric) {
navigator.sendBeacon("/api/vitals", JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
target: metric.attribution?.interactionTarget,
url: location.pathname,
}));
}
onLCP(send); onCLS(send); onINP(send);
That's a few kilobytes of client code and a table you can query. The attribution build is what pays rent: knowing your INP is 350 ms is a grade; knowing it's 350 ms on the mobile nav toggle, mostly input delay, on Android is a work item. Whatever you use, look at percentiles, never averages — an average smears your fastest desktop user over your slowest phone user and describes neither.
The playbook: what to fix first on a typical slow site
When we take on a slow site, this is the order of operations — roughly sorted by user impact per engineering hour:
- Get field data flowing (CrUX check +
web-vitalsRUM). Everything else depends on knowing which metric is failing and where. (Hours.) - Fix bfcache eligibility — remove
unloadhandlers and document-levelno-store. Instant back/forward for free. (Hours.) - Fix the LCP image:
fetchpriority="high", no lazy-loading above the fold, right-sizedsrcset, AVIF. (Hours.) - Kill or defer third-party scripts. Audit the tag manager, delete dead pixels, load chat widgets on interaction. Usually the largest single main-thread win. (Days, mostly political.)
- Fonts: subset, self-host, preload, metric-matched fallback. Fixes CLS and text-paint delay together. (Hours.)
- Fix TTFB via caching — static generation or ISR for everything that doesn't truly need per-request rendering. (Days.)
- Attack the worst INP interactions from RUM attribution: yield in long handlers, fix heavy renders, virtualize big lists. (Days, ongoing.)
- Add speculation rules + view transitions — but only once the page is healthy, or you're prerendering a slow page. (Hours.)
- Reduce shipped JavaScript structurally — server components, islands, lazy heavy widgets. Highest ceiling, biggest effort; last because of cost, not because it matters least. (Weeks.)
Performance budgets in CI: keeping it fixed
Performance regresses one innocent PR at a time, so the last step is making the budget mechanical. Two layers, both cheap.
Bundle budgets catch the most common regression — someone imports a 90 KB library to format a date — before it ever runs: size-limit or bundle-analysis diffing in CI, with hard limits per route.
Lighthouse CI runs lab audits against preview deployments and asserts on budgets:
{
"ci": {
"assert": {
"assertions": {
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"total-blocking-time": ["error", { "maxNumericValue": 200 }],
"resource-summary:script:size": ["error", { "maxNumericValue": 250000 }]
}
}
}
}
Assert on concrete metrics and resource sizes, not the composite score — the score is a weighted abstraction that moves for reasons unrelated to your diff, and it can't see INP anyway. Keep the loop honest: CI budgets prevent regressions in the lab; RUM confirms improvements in the field. If a "win" ships and p75 doesn't move within a CrUX window, it wasn't a win.
Takeaways
- INP replaced FID and is a far harsher judge: every interaction, end to end, through the next paint. Page-load tools can't see it; Lighthouse can't measure it at all.
- Field data (CrUX, RUM) is the verdict; lab data is the diagnosis. Never optimize a Lighthouse score your users can't feel.
- The instant-navigation stack — Speculation Rules prerendering, bfcache eligibility, View Transitions — is today's biggest perceived-speed win, mostly progressive enhancement measured in hours of work.
- bfcache is free speed: kill
unloadhandlers and document-levelCache-Control: no-store. - JavaScript costs twice, network and CPU. Islands, server components, and route-level splitting beat micro-optimizations by orders of magnitude; deleting a third-party script beats both.
- Debug INP by phase (input delay, processing, presentation), yield with a feature-detected
scheduler.yield(), and fix heavy renders on input, not just slow handlers. - Images:
fetchpriority="high"on the LCP element, never lazy-load it, AVIF plus honestsrcset. Fonts: subset, self-host, metric-match fallbacks withsize-adjust. - TTFB is the floor under everything: cache HTML where possible, keep compute near your data, treat edge rendering as a data-topology decision.
- Measure with the
web-vitalsattribution build and percentiles, never averages; enforce CI budgets on metrics and byte sizes, not the composite score.