Most Core Web Vitals work goes wrong in the same way: somebody runs Lighthouse, gets a number, and starts optimising everything. Six hours later the bundle is smaller, the score has moved four points, and the actual problem — one unoptimised hero image — is untouched.
The three metrics have narrow causes. LCP is nearly always a single element, usually an image. INP is nearly always a single handler doing too much. CLS is nearly always one thing appearing without reserved space. Finding *which* one is the entire job.
This is the sequence I actually use on Next.js projects, in the order that finds the largest wins first.
What are the three metrics measuring?#
Loading, responsiveness and stability — each expressed as a single number with a threshold that maps to something a person would notice.
| Metric | Measures | Good | Poor |
|---|---|---|---|
| LCP | When the main content appeared | Under 2.5s | Over 4s |
| INP | How fast interactions respond | Under 200ms | Over 500ms |
| CLS | How much the layout moved | Under 0.1 | Over 0.25 |
They are measured at the 75th percentile of real visits, which is the part people miss. Passing means three quarters of your visitors get a good experience — so a site that is fast on a desktop and slow on a mid-range Android fails, because the Android sessions are in the tail that decides the number.
It is also worth knowing that the three are largely independent. A page can have excellent LCP and terrible INP, because one is about how fast the first picture arrives and the other is about whether the thread is free afterwards. Treating them as one "speed" number is how a team optimises the metric that was already passing.
These are diagnostic, not the goal#
The goal is a site that feels quick. The metrics are proxies for that, chosen because they correlate with abandonment, and they are useful precisely because they are specific enough to act on. A page can pass all three and still feel sluggish, and that is worth fixing too.
How do you find the LCP element?#
The browser tells you. Do not guess.
new PerformanceObserver((list) => {
const e = list.getEntries().at(-1) as any;
console.log('LCP', Math.round(e.startTime), e.element);
}).observe({ type: 'largest-contentful-paint', buffered: true }); That logs the actual element and the time. In Chrome devtools the Performance panel marks it directly on the timeline, which is faster still. Either way you now know whether you are optimising an image, a heading or a video poster.
In my experience it is an image about seven times out of ten, a block of text most of the rest, and occasionally a video poster frame that nobody thought of as content.
If it is an image, four things fix it#
Serve it in a modern format, size it for the viewport it appears in, mark it priority so it is not lazy-loaded, and preload it. next/image handles the first two and the third with one prop; the fourth it does for you when priority is set.
<Image src={hero} alt="" priority sizes="100vw" /> The single most common LCP bug in Next.js is a hero image *without* priority. Images are lazy by default, so the most important image on the page waits until layout has established that it is in view — which is exactly the delay you are trying to remove.
If it is text, it is usually the font#
Text cannot paint until its font resolves or the fallback is accepted. font-display: swap paints immediately in a fallback; self-hosting removes the two connection setups a font CDN costs. That combination is most of the fix and it is not a Next.js concern at all.
The sizes attribute is the half of this that gets skipped. Without it the browser assumes the image occupies the full viewport width and picks the largest candidate, so a card thumbnail 300px wide downloads a 1600px file. On a page with a grid of them that single omission can be most of the transferred bytes, and it produces no visible defect at all — the image simply looks fine and costs five times what it should.
And sometimes it is the server#
If time-to-first-byte is 1.2 seconds, no amount of image work gets LCP under 2.5. Check TTFB first — it is the floor everything else sits on, and a slow one points at rendering strategy or a database call in the request path rather than at the front end.
What actually causes bad INP?#
A long task blocking the main thread between the input and the next paint. Usually one handler, occasionally hydration.
INP measures the worst interaction latency across a visit, so a single slow handler on a frequently-used control decides the number even if everything else is instant. It replaced FID specifically because FID only measured the *first* interaction, which flattered pages that got slow later.
Find it with the interaction breakdown#
The three phases are the whole diagnosis, and devtools gives them to you directly.
Devtools shows input delay, processing time and presentation delay separately. Long input delay means the main thread was already busy — often hydration or a third-party script. Long processing means your handler is slow. They need completely different fixes, which is why the split matters.
Break up the long task#
Yielding once before the expensive part is usually enough to change how the whole interaction feels to somebody using it.
A handler doing a lot of work can yield so the browser can paint. scheduler.yield() where available, or a setTimeout fallback, lets an interaction feel responsive while the work continues.
async function onFilter(value: string) {
setPending(value); // paint the response immediately
await new Promise((r) => setTimeout(r, 0));
applyExpensiveFilter(value); // then do the work
} Hydration is the Next.js-specific cause#
A page with many client components hydrates a large tree, and interactions during that window queue behind it. Server components are the real fix — the less that hydrates, the shorter the window — which is the same argument as islands arriving in a different framework.
There is a subtlety in what INP counts that changes how you read it. It measures from the input to the *next paint*, not to the end of your handler — so a handler that finishes quickly but triggers an expensive re-render still scores badly. That is why "my click handler is 3ms" and "INP is 400ms" are consistent statements, and why the presentation-delay column is often the interesting one.
Watch for the handler that runs on every scroll#
A non-passive scroll or wheel listener blocks scrolling itself. Marking listeners passive where they do not call preventDefault is one line and it removes an entire category of jank.
Where does CLS come from?#
Four sources, and images without dimensions is the biggest by a wide margin.
- Images with no width and height, so the browser reserves nothing until the file arrives.
- Ads, embeds and iframes injected into a space that was not reserved.
- Web fonts swapping and reflowing text with different metrics.
- Content inserted above the fold — a banner, a notification, a lazily-mounted component.
Next.js handles the first automatically when you use next/image with static imports, because it knows the dimensions at build time. It cannot help with a remote image whose size you did not declare, which is where the failures cluster.
This deserves its own treatment — getting CLS near zero is mostly about reserving space for things that do not exist yet, and it is more systematic than the other two metrics.
Which Next.js features actually matter?#
Four, and the rest are marginal by comparison.
| Feature | Fixes | Cost |
|---|---|---|
| next/image with priority | LCP on image-led pages | One prop |
| next/font | Font-driven LCP and CLS | An import |
| Server components | INP via less hydration | A mental model |
| next/script strategy | Third-party blocking the thread | One attribute |
next/font self-hosts and preloads for you#
It downloads the font at build time, serves it from your origin, generates the @font-face, and can calculate fallback metrics to reduce the swap shift. That is four separate manual steps collapsed into an import, and it is the highest-value single change on most Next.js sites.
One caution about next/image worth knowing before you adopt it wholesale: on a self-hosted deployment the default loader optimises images at request time, which means the first request for each size is slow and the cache has to be persistent across deploys or it warms from cold every release. On Vercel this is handled; elsewhere it is a decision, and pre-optimising at build time is often the calmer answer.
next/script strategy is a real decision#
afterInteractive is the sensible default for analytics. lazyOnload is right for anything that can wait — chat widgets, review badges. beforeInteractive should be almost nothing, because it blocks. Most third-party performance problems are a script loaded with the wrong strategy rather than a script that is inherently expensive.
What is the order of operations?#
Measure, find the responsible element, fix that one thing, measure again. Repeat rather than batching.
- Get field data first. Lab tools tell you what could be slow; field data tells you what is. Start from the Chrome UX Report or your own real-user monitoring.
- Identify the LCP element on the slowest real page, not the home page.
- Fix that element, deploy, and wait for the field data to move. This takes days, which is uncomfortable and unavoidable.
- Then look at INP, which is usually the second-largest gap and the hardest to reproduce in a lab.
- Then CLS, which is the most mechanical to fix once you know what is shifting.
The discipline that matters is one change at a time. Batching five optimisations means you cannot tell which one helped, and the usual outcome is that four were neutral and one mattered — but you now maintain all five.
What does not move the number?#
Most of what performance advice suggests, on most sites.
- Shaving a few KB off the bundle when LCP is a 900KB image. The image is 100 times the problem.
- Micro-optimising React renders when INP is fine and LCP is the failing metric.
- Adding a service worker before the first load is fast. It helps repeat visits and does nothing for the visitor who leaves.
- Switching frameworks because a benchmark said so. Framework choice matters at the margins; images and third-party scripts matter by multiples.
There is a fifth worth adding because it wastes the most time: reading a Lighthouse opportunity list top to bottom and working through it. Those items are ranked by estimated saving on that one simulated load, not by what is failing for your real visitors, and the estimates are frequently generous. Treat it as a list of candidates to check against field data rather than a work queue.
The pattern is that all four are satisfying, measurable engineering work that is unrelated to the specific thing making your specific page slow. The uncomfortable version of this job is that the fix is usually boring and belongs to someone who uploaded a photograph.
Does rendering strategy change the answer?#
It changes the floor, which is the part of LCP no front-end work can reach.
| Strategy | TTFB | Right when |
|---|---|---|
| Static, prerendered | Near zero from a CDN | Content that is the same for everyone |
| Incremental regeneration | Near zero, occasionally slow | Content that changes on a schedule |
| Server-rendered per request | Whatever your slowest query is | Content personalised per visitor |
| Client-fetched after load | Fast TTFB, late LCP | Almost never for main content |
The last row is the trap. Rendering a shell quickly and fetching the content afterwards produces an excellent-looking first byte and a terrible LCP, because the largest element does not exist until a request completes. It also gives crawlers an empty page — a cost that does not show up in any performance metric.
Personalisation is usually narrower than the page#
A page is often served dynamically because one element differs per visitor — a name in a header, a cart count. Rendering the whole page per request to personalise a badge means every visitor pays the query. Serve the page statically and fetch the personal fragment separately.
Check what is actually in the request path#
A server-rendered page waits for whatever the handler waits for. A CMS call without a timeout, an analytics write before the response, a session lookup that could have been cached — each adds directly to TTFB, and TTFB is a floor under LCP that no image optimisation can lift.
How do you keep it from regressing?#
Budgets in CI and field monitoring in production, because the two catch different failures.
A budget that fails the build#
Lighthouse CI with an asserted LCP and total byte budget catches the pull request that adds a 2MB image. It runs on a synthetic page load, which is a poor absolute measure and an excellent relative one — the number does not need to be true, it needs to be comparable.
Run it against a deployed preview rather than a local build, too. A local server has no network latency, no CDN and no compression settings, so the numbers bear little relation to production — useful for catching a doubled bundle, useless for anything expressed in seconds.
Field monitoring for what CI cannot see#
INP in particular only appears with real interaction on real devices. The web-vitals library reporting to your analytics gives you the distribution that actually decides your scores, and it costs a few kilobytes.
import { onLCP, onINP, onCLS } from 'web-vitals';
[onLCP, onINP, onCLS].forEach((fn) => fn((m) => report(m.name, m.value))); Alert on the 75th percentile, not the average#
An average hides the tail, and the tail is the metric. A page with a 1.2s median LCP and a 6s p75 fails, and the average looks fine — which is why an average-based dashboard reliably tells you everything is well while the score says otherwise.
What does this cost?#
A day for the first pass on a typical site, and most of it is measurement rather than code.
Finding the LCP element takes minutes. Fixing it — resizing an image, adding priority, moving a font — takes an hour. The rest of the day goes on setting up field monitoring, adding a CI budget, and waiting for real-user data to confirm the change actually landed.
The honest counterweight: Core Web Vitals are a proxy and treating them as the objective produces odd decisions. I have seen a team remove a genuinely useful interactive element because it hurt INP, when the right answer was to make it faster. Optimise the experience and use the metrics to find where it is worst — not the other way around.
LCP is one element. INP is one handler. CLS is one thing with no reserved space. The work is finding which, not optimising everything.
Conclusion#
Start by identifying the LCP element with a PerformanceObserver or the devtools timeline rather than guessing. It is an image roughly seven times in ten, and the most common Next.js cause is a hero image without priority — lazy by default, so the most important image on the page waits for layout.
For INP, split the interaction into input delay and processing time. Delay means the thread was already busy — usually hydration or a third-party script — and processing means your handler is slow. Yield inside long handlers so the browser can paint the response before the work finishes.
Use next/font for self-hosting, preloading and fallback metrics in one import; set next/script strategies deliberately, with beforeInteractive reserved for almost nothing; and reach for server components to shrink the hydration window rather than micro-optimising renders.
Work from field data, fix one thing at a time, and re-measure between changes. Batching five optimisations means four were probably neutral and you now maintain all of them.
Then hold it with a Lighthouse budget in CI for the pull request that adds a 2MB image, and real-user monitoring for INP, which only appears with real interaction on real devices. Alert on the 75th percentile — an average hides the tail, and the tail is the whole metric. If a site is failing its vitals and the cause is not obvious, finding it is usually a short piece of work.