Every performance conversation we join starts the same way: someone shows a Lighthouse score. It is usually a 62, in red, and the question is how to make it green. That is the wrong question, and chasing it produces sites that score well in a lab and still feel slow on a phone.
Lighthouse runs one page, once, on a simulated mid-tier device with throttled network, in a browser with no extensions and an empty cache. Your users are on real devices with real caches, real extensions and a network that varies by the minute. The lab number is a useful smoke test. It is not the target.
Field data is the target
The number that matters is the 75th percentile of real users, measured over 28 days, segmented by device class. That is what Google's Chrome User Experience Report collects and what actually feeds ranking signals — and, more importantly, it is the only number that reflects what people experienced.
The 75th percentile is deliberate. An average hides the tail, and the tail is where people give up. If three-quarters of your users get a good experience, the remaining quarter are usually a specific, fixable segment: one country, one device class, one slow third-party script.
The budget we hold
These are the numbers we write into a scope. They are tighter than the official "good" thresholds, because a target you only just hit in month one will fail by month six as content accumulates.
| Metric | Google "good" | Our budget |
|---|---|---|
| Largest Contentful Paint | ≤ 2.5s | ≤ 1.8s |
| Interaction to Next Paint | ≤ 200ms | ≤ 150ms |
| Cumulative Layout Shift | ≤ 0.1 | ≤ 0.05 |
| Time to First Byte | ≤ 800ms | ≤ 500ms |
| Total JS transferred | — | ≤ 150KB compressed |
| Total page weight | — | ≤ 1MB on first view |
The last two are not Core Web Vitals. We include them because they are the causes, and causes are easier to act on than symptoms. A team can argue about whether an LCP regression matters. Nobody can argue with "this pull request adds 90KB of JavaScript".
Largest Contentful Paint, in practice
LCP is almost always one of four things, and they are worth checking in this order because the effort increases down the list:
- The LCP element is lazy-loaded. Someone put
loading="lazy"on the hero image. This single line can cost a second. The hero image should beloading="eager"withfetchpriority="high". - It is waiting on a font. Text held invisible during a webfont load blocks LCP entirely.
font-display: swapplus a preconnect to the font host fixes most of it. - It is behind a render-blocking chain. CSS that imports CSS that imports a font is three round trips before anything paints.
- The server is slow. If TTFB is 900ms, no front-end work will save you. Fix the origin first.
The most common own-goal A carousel where the first slide is the LCP element and the carousel library loads before it renders. You are now waiting on JavaScript to paint your headline image. Render the first slide in HTML and enhance it afterwards.
INP is a main-thread problem
Interaction to Next Paint replaced First Input Delay because FID only measured the first interaction, and only the delay before the handler ran — not how long the handler took or when the screen actually updated. INP measures the whole thing, across the whole session, and it is considerably less forgiving.
Three patterns cause most INP failures:
- Doing work in the handler that should be deferred. If a click triggers a re-render of a large list, split it: paint the acknowledgement first, do the heavy work after the next frame.
- Long tasks from third-party scripts. An analytics or chat widget that occupies the main thread for 300ms will fail your INP even though it is not your code. Load it with
async, after interaction, or not at all. - Layout thrash in scroll or input handlers. Reading
getBoundingClientRect()and then writing a style, in a loop, forces synchronous layout every iteration. Batch reads, then batch writes, inside onerequestAnimationFrame.
That last one is worth being concrete about, because it is the one we fix most often. Every scroll-reactive effect on a page should share a single listener:
var jobs = [], ticking = false;
function onScroll(fn) { jobs.push(fn); }
window.addEventListener('scroll', function () {
if (ticking) return;
ticking = true;
requestAnimationFrame(function () {
var y = window.scrollY, vh = window.innerHeight; // read once
for (var i = 0; i < jobs.length; i++) jobs[i](y, vh);
ticking = false;
});
}, { passive: true });
Six effects, one listener, one layout read per frame. The passive: true
matters too — without it the browser must wait to see whether you will call
preventDefault() before it can scroll.
CLS is mostly missing dimensions
Cumulative Layout Shift is the easiest of the three to fix and the most commonly ignored, because it does not show up on a fast connection with a warm cache — the developer's machine, in other words.
- Every image and video needs
widthandheightattributes. Even with CSS sizing. The attributes give the browser an aspect ratio to reserve before the file arrives. - Reserve space for anything injected. Ad slots, embeds, cookie banners, "you have unsaved changes" bars. If it appears above existing content, it shifts everything.
- Never animate
top,heightormargin. Usetransform, which the compositor handles without a layout pass and which does not count toward CLS. - Load fonts with a matched fallback. A fallback with a very different x-height causes a visible reflow on swap.
size-adjustin the@font-facerule narrows the gap.
What gets cut when the budget slips
This is the part most performance advice leaves out. A budget is only real if you have agreed in advance what you sacrifice to stay inside it. Ours, in order:
- Third-party scripts. Every one of them, justified individually. Most marketing tags are added once and never removed, and each is a main-thread risk you do not control.
- Webfont weights. Two weights of two families is the ceiling. A third weight is 20–40KB for a distinction most readers never notice.
- Animation libraries. If CSS can do it, CSS does it. A 40KB library to fade something in is not a trade we make.
- Client-side routing. On a content site it buys very little and costs a JavaScript framework. It stays only where the app genuinely benefits.
- Hero video. Reluctantly, and last, because it is usually the thing the client is most attached to. A poster image with a play control gets most of the effect for a fraction of the bytes.
Agreeing that order during discovery — before anyone is emotionally invested in a hero video — is what makes it possible to hold the budget later.
Measure it continuously, not once
A performance pass that happens the week before launch will decay. Two things keep it honest:
- A bundle-size check in CI that fails the build when the budget is exceeded. Not a warning — warnings get ignored. A failed build gets a conversation.
- Real-user monitoring in production, reported monthly. The
web-vitalslibrary is about 2KB and reports the same metrics Google uses, from your actual users.
The second one matters most. A regression that only shows up on Android devices in one region is invisible in a lab test and obvious in field data. You want to find that in week three, not in a quarterly traffic review.