On this page
- 1. Measure what users feel, not what your laptop sees
- 2. Find the LCP element and clear its path
- 3. Ship less JavaScript
- 4. Break long tasks before they break INP
- 5. Reserve space so the page stops jumping
- 6. Treat images as the main payload
- 7. Fonts and CSS: the render-blocking layer
- 8. Treat third parties as untrusted code
- 9. Cache and navigate faster
- 10. Make speed a build-time requirement
- 11. The checklist
Performance conversations usually start with a Lighthouse score. That score is useful, but it is not what your users experience. A page can score 100 on a fast laptop and still feel slow on a mid-range Android with a weak connection. The only number worth optimizing is the one real users get, on the devices and networks they actually use.
The last product site I shipped scored 100/100/100 on its homepage and product page, and I still do not treat that as proof. The score came from the same unglamorous work as everything below: a correctly sized hero image, self-hosted fonts, and less client JavaScript.
Chrome's own data says 90% of a user's time on a page is spent after it loads. That fact changes the job. Loading quickly is necessary, but it is only the beginning. The page also has to respond quickly while someone is typing, scrolling, tapping and opening menus.
The process below is the one I use: measure in the field, fix the loading path, protect the main thread, and make the gains difficult to lose.
1. Measure what users feel, not what your laptop sees
The three Core Web Vitals are still the right starting point: LCP for loading, INP for responsiveness, and CLS for visual stability. A good result at the 75th percentile means LCP at 2.5 seconds or less, INP at 200 milliseconds or less, and CLS at 0.1 or less. Those thresholds are measured over real page loads, split by mobile and desktop.
Lab tools such as Lighthouse are for diagnosis. They run on hardware you control, with a network you control, and they cannot see the interaction that took 900 milliseconds because a third-party tag woke up. Field data from CrUX or your own RUM is the source of truth. Use lab tools to explain a field problem, not to declare victory.
import { onINP } from "web-vitals/attribution";
onINP((metric) => {
const body = new Blob(
[
JSON.stringify({
id: metric.id,
value: metric.value,
rating: metric.rating,
target: metric.attribution.interactionTarget,
type: metric.attribution.interactionType,
}),
],
{ type: "application/json" },
);
navigator.sendBeacon("/api/vitals", body);
});Send the raw value and the attribution, not just a score. Aggregate at p75 and segment by device, route, connection and release. Averages hide the slow tail, and the slow tail is where the users who leave live. If the average LCP is 2.1 seconds but the p75 on mobile is 4.3 seconds, the average is lying to you.
2. Find the LCP element and clear its path
LCP is the moment the largest image or text block in the viewport finishes rendering. When it is slow, the cause is usually one of four things: a slow server, a resource that starts loading too late, a resource that takes too long to download, or a render that cannot happen until JavaScript finishes.
Start with TTFB. If the HTML takes a second to arrive, no amount of image tuning will save the page. Cache at the edge, stream the response, and remove work from the critical path. Then look at the LCP element itself.
- Do not lazy-load the hero image.
loading="lazy"on the LCP image is a self-inflicted delay. - Give every image explicit dimensions or an aspect ratio. The browser needs to reserve the box before the bytes arrive.
- Load the right size. A 2400px image displayed at 800px wastes bandwidth on every visit.
- Make the LCP image discoverable in the HTML, not in a CSS background or a client-rendered component.
- Use
fetchpriority="high"and a modern format such as AVIF or WebP.
import Image from "next/image";
export function Hero() {
return (
<Image
src="/hero.avif"
alt="Dashboard overview"
width={1600}
height={900}
sizes="(max-width: 768px) 100vw, 1200px"
fetchPriority="high"
/>
);
}In Next.js 16 the old priority prop is deprecated. The docs now recommend loading="eager" or fetchPriority="high" for the hero image in most cases, and reserve preload for cases where the image is genuinely the LCP element and the request must start in the head. Preload carefully. Preloading the wrong image competes with the right one.
3. Ship less JavaScript
JavaScript is the most expensive asset on most pages. An image is downloaded and decoded. A script is downloaded, parsed, compiled, executed, and often executed again during hydration. A 300KB bundle can cost far more than a 300KB image on a slow phone.
The biggest lever is not minification. It is not shipping code. Server rendering and React Server Components keep components on the server, but only if the use client boundary is placed at the leaf that needs interactivity. Wrapping a whole page in a client component ships the whole page.
- Keep
use clientas low in the tree as possible. - Load heavy widgets below the fold with a dynamic import.
- Replace large dependencies with smaller ones, or with the platform.
Intlusually beats a date library. - Check the bundle after every dependency addition, not once a year.
import dynamic from "next/dynamic";
// The editor is below the fold and not needed for first input.
const Editor = dynamic(() => import("./Editor"));A charting library imported for a chart nobody has scrolled to is not a feature. It is a tax on every visitor who never opens it. Load it when it is needed, and read the route sizes in the build output. If a route grows by 80KB, ask what was added and who approved it.
4. Break long tasks before they break INP
INP measures the time from a user interaction to the next frame the browser paints. It has three parts: input delay while the main thread is busy, the time your event handlers take, and the presentation delay while the browser styles, lays out and paints. A slow INP is almost always a main-thread problem.
Start by doing less in the handler. If a click only needs to update a button label, update the label. Send the analytics event later. Save the draft after the frame. The user needs feedback now, not a database write.
async function yieldToMain() {
if (globalThis.scheduler?.yield) {
await globalThis.scheduler.yield();
return;
}
await new Promise((resolve) => setTimeout(resolve, 0));
}
button.addEventListener("click", async () => {
button.dataset.state = "saving"; // paint this first
await yieldToMain();
await saveDocument();
await yieldToMain();
button.dataset.state = "saved";
});scheduler.yield() is the right tool when it is available. Chrome and Firefox support it. Safari does not, which is why the fallback stays. Yield after the visible update, not before it. If you yield too early, the user waits for feedback that was ready to paint.
Also watch the DOM. A huge page makes every style recalculation and layout more expensive, and that cost shows up in presentation delay. content-visibility: auto can skip rendering work for off-screen sections, but give those sections an intrinsic size so the scrollbar does not jump.
.article-section {
content-visibility: auto;
contain-intrinsic-size: auto 600px;
}If a handler reads offsetHeight and then writes style, it forces the browser to lay out synchronously. Batch reads, then writes. That single habit fixes a surprising number of interaction complaints.
5. Reserve space so the page stops jumping
CLS is the metric that is almost always a design decision disguised as a bug. Images without dimensions, ads injected above the fold, cookie banners that appear after first paint, and fonts that swap with different metrics all move content while someone is reading it.
- Reserve space with width and height, or with an aspect ratio.
- Use a font fallback with matching metrics, or let
next/fontgenerate one. - Reserve space for embeds, ads and banners before they load.
- Animate with
transformandopacity, nottop,left,widthorheight.
font-display: swap improves text visibility, but it can also cause a visible reflow when the fallback and the real font have different widths. next/font handles this by generating a fallback face with adjusted metrics, and its adjustFontFallback option is on by default. If you self-host fonts, do the same with size-adjust, ascent-override and descent-override.
6. Treat images as the main payload
Images are usually the largest bytes on a page, and often the easiest win. The goal is not to compress everything to mush. It is to send the smallest image that still looks right at the size it is displayed.
- Serve AVIF or WebP, with a fallback only if your audience needs one.
- Generate a responsive
srcsetand give the browser an honestsizesvalue. - Lazy-load images below the fold, and never the LCP image.
- Resize and compress at the CDN instead of storing one 4000px original for every slot.
- Use a quality setting around 60 to 75 for photographs, then check the result on a real screen.
A surprising number of slow pages are not shipping too many bytes. They are shipping the right number of bytes at the wrong time. An image that loads long before it enters the viewport is wasted work. An image that loads long after it enters the viewport is a visible blank box. loading="lazy" is a hint, not a guarantee, so keep the first viewport eager.
7. Fonts and CSS: the render-blocking layer
Fonts and CSS are render-blocking by default. A font stylesheet from a third-party origin adds a connection, a request and a render wait before the first paint. Self-host the font, subset it to the characters you use, and preload only the face that appears above the fold.
import { Inter } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
display: "swap",
adjustFontFallback: true,
});CSS has a similar problem, just less visible. A large stylesheet delays rendering, and unused selectors still cost parse time. Keep the critical path small. Avoid @import in CSS because it serializes requests. If you use a utility framework, make sure the production build is actually purging unused classes. Then check the built CSS, not the source.
8. Treat third parties as untrusted code
A tag manager is a deployment pipeline for JavaScript that skips code review. Analytics, chat widgets, heatmaps, A/B testing and embedded video all run on your main thread, and any one of them can ruin INP for an interaction it does not own.
- Load non-critical scripts after the page is interactive, not in the head.
- Replace heavy embeds with a facade that loads the real player on click.
- Audit tags quarterly and remove the ones nobody can justify.
- Self-host the scripts you can, and set a size budget for the ones you cannot.
- Use
next/scriptstrategies deliberately instead of adding every tag asbeforeInteractive.
beforeInteractive is for scripts that must run before hydration, such as a consent manager that blocks other tags. It is not a default. Most analytics belong in afterInteractive, and chat widgets belong in lazyOnload or behind a click.
9. Cache and navigate faster
Caching is the cheapest performance work you can do, and the easiest to get wrong. Hashed build assets can be cached forever. HTML cannot.
# Fingerprinted asset
Cache-Control: public, max-age=31536000, immutable
# HTML document
Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=300The exact numbers depend on how often your content changes, but the shape is always the same: long cache for content-addressed files, short cache with revalidation for documents. If you serve user-specific HTML, do not put it in a shared cache unless the cache key includes the user.
Back/forward cache is the other half of navigation performance. It makes back and forward clicks instant, and it is enabled in every major browser. Two things break it more than anything else: an unload listener and Cache-Control: no-store. Remove the first. Use the second only when the data is genuinely sensitive and cannot be revalidated.
Speculation rules go one step further and let the browser prerender a page the user is likely to open. It is a progressive enhancement, not a requirement: Chromium supports it, while Safari and Firefox do not ship it by default. Prerender only safe, likely destinations, and keep analytics from counting a page until it is actually activated.
<script type="speculationrules">
{
"prerender": [
{
"where": { "href_matches": "/blog/*" },
"eagerness": "moderate"
}
]
}
</script>The analytics caveat is real. A prerendered page runs JavaScript before the user sees it, so a page view can fire early. Wait for prerenderingchange or first visibility before initialising analytics, or you will report traffic that never happened.
10. Make speed a build-time requirement
The hardest part of performance is keeping it. A single dependency, a new hero image, or one more tag can undo months of work. The fix is to make speed a requirement of the build, not a project someone runs when the site feels slow.
- Set budgets per route: LCP, INP and CLS at p75, plus a JavaScript size limit.
- Fail the pull request when the lab budget regresses. Lighthouse CI or a bundle-size check is enough to start.
- Alert on field regressions, not just lab scores. A route that drops below p75 should have an owner.
- Record the budget in the repository, next to the code, so it is reviewable.
- Review bundle diffs the same way you review code diffs.
A useful starting point for a content site is 200KB of compressed JavaScript on first load, with tighter budgets for routes that should be mostly text. Applications with complex interaction need a different number, but they still need a number. A budget that nobody enforces is a wish.
This is also where a performance culture matters more than a performance checklist. If the person who adds the 90KB library is not the person who has to fix the p75 regression, the regression will stay. Make the cost visible in the pull request, and the conversation changes.
11. The checklist
- Can you name the LCP element on your three most important pages?
- Is LCP at 2.5 seconds or less at p75 on mobile, from field data?
- Is INP at 200 milliseconds or less at p75, and do you know which interaction is slowest?
- Is CLS at 0.1 or less, including the cookie banner and the late fonts?
- Does the initial route ship only the JavaScript it needs?
- Are long tasks broken up around visible updates?
- Is the LCP image eager, correctly sized, and in a modern format?
- Are third-party tags audited, deferred or replaced with facades?
- Are hashed assets cached forever and HTML revalidated?
- Does CI fail when a performance budget regresses?
None of this is exotic. The work is mostly unglamorous: remove a dependency, resize an image, move a script, yield before the save. That is also why it compounds. Each fix makes the next one easier to measure, and a page that stays fast is usually a page where someone kept the budget honest.
If you want the primary sources, start with the Core Web Vitals guide (opens in a new tab), the web-vitals library (opens in a new tab), and the Speculation Rules API (opens in a new tab). They are shorter and more current than most articles written about them.
- Filed under
- Performance
- Core Web Vitals
- JavaScript
- Caching