Engineering decisions

Why your Next.js site is slow, and which fixes are worth doing

EdgeBrain Studios12 min read

Three causes account for nearly every slow Next.js site, and none of them is the one the checklists lead with. LCP fails because of a network waterfall or a render-blocking decision, rather than an unoptimised image. INP fails because too much JavaScript hydrates on a route that never needed to be interactive, which in the App Router means a "use client" boundary drawn one component too high. And whatever you fix gets quietly undone six weeks later by a third-party tag nobody reviewed.

So the order matters more than the list. Find out which metric is failing and for whom, from field data at the 75th percentile. Fix the cause, not the symptom. Then put a budget in CI, because a one-off optimisation sprint has a half-life measured in deploys.

Find out what is actually failing before you touch anything

A Lighthouse run on your laptop measures your laptop, warm cache and fast CPU, and hands you one number with no percentile attached. Real users are a distribution, and Core Web Vitals get assessed against the slow end of it. All three thresholds sit at the 75th percentile of page loads over a rolling 28-day window, with LCP at or under 2.5 seconds, INP at or under 200 milliseconds, and CLS at or under 0.1. Passing means the slowest quarter of your traffic is the only part still failing. Your median user was probably never the problem.

If the Chrome UX Report has nothing for you

CrUXonly publishes data for origins and URLs with enough qualifying traffic, so small sites open the dashboard, see nothing, and conclude they’re fine. No data is not the same condition as passing.

Instrument your own with useReportWebVitals from next/web-vitals, in a small client component mounted in the root layout, and use the attribution build. Plain web-vitals says INP was 480 ms. Attribution names the interaction target, the phase that burned the time, and the script that was running. One is a metric, the other is a lead.

Segment by route and device class before concluding anything. A p75 averaged across static marketing pages and a client-rendered dashboard describes neither, and the fix it points at is wrong for both.

Why a dashboard that was green in 2023 went red without a deploy

In March 2024, INP replaced FID as a Core Web Vital, and a lot of teams found a failing report on a codebase they hadn’t touched. FID measured only the delay before the first event handler could begin running, ignoring how long it took and every interaction after it. INP measures the whole thing, meaning input delay, processing duration and presentation delay, and reports close to the worst interaction of the visit.

The gap between those rulers is enormous. Google’s published field data around the switchover put origin-level FID pass rates near 97 percent and INP pass rates near 65 percent. Nothing got slower. The ruler started counting the part that was always slow: your own JavaScript, after the click.

Next.js LCP optimization is a waterfall problem, not an image problem

Split LCP into its four sub-parts before optimising anything: time to first byte, resource load delay, resource load duration, element render delay. Google’s budgeting guidance is roughly 40 percent TTFB, 10 percent load delay, 40 percent load duration, 10 percent render delay. Each has a different fix, and three of the four have nothing to do with image compression.

Time to first byte

The most common Next.js cause is a route that went dynamic by accident. One call to cookies(), one uncached fetch, one header read inside a shared layout, and the whole segment opts out of static rendering. Read the route table next build prints and confirm the pages you believe are static actually are. The other cause is geography, which no framework fixes: streaming moves the shell earlier and helps perceived speed, not a 900 ms query.

Resource load delay, the sub-part everybody skips

The gap between the browser finishing the HTML and starting the LCP resource is where Next.js sites lose the most avoidable time. The image is fine. The browser heard about it too late. Three causes: the hero is a CSS background, invisible to the preload scanner; the hero sits inside a client component that must hydrate first; or lazy loading got applied globally by a lint rule. Put priority on the LCP image, never lazy-load it, and if you use fill, set sizes honestly. A missing sizes makes the browser assume full viewport width and fetch a much larger file than the slot displays.

Element render delay

If your LCP element is text rather than an image, which on a content site it usually is, image work is irrelevant and the question is what blocks paint. Usually a font, a client-side fetch the text waits on, or an animation that starts the element at zero opacity.

That last one is close to universal on marketing sites and damages more than LCP. A reveal implemented as opacity: 0 in the server HTML means the first paint carries no readable text. This site is a working counter-example. Its hidden states live in CSS behind a class the client adds after boot, so the server HTML carries visible copy. View source and check.

INP is usually a client boundary drawn one component too high

In the App Router, "use client" is not a label on a file. It is the top of a subtree: everything imported below it becomes client code, ships, and hydrates.

The failure mode is mundane. Someone needs an onClickon one button, adds the directive at the top of the page file because that’s where the state lives, and the whole route becomes a hydration payload. Server components versus client components stops being stylistic there. It’s 40 KB versus 340 KB for the same visible page.

Find the frame, not the file

Two tools do the real work. The attribution build of web-vitals gives you the interaction target as a CSS selector plus the phase split. The Long Animation Frames API, a PerformanceObserver on long-animation-frame entries and in Chromium since Chrome 123, names the scripts in a slow frame. The dominant phase decides the fix.

Dominant phaseWhat it meansWhere to look
Input delayThe main thread was busy before your handler ranHydration, or a third-party script
Processing durationYour handler and the re-render it triggeredState shape, context width, list size
Presentation delayLayout and paint after the state changeLarge DOM, expensive CSS, unvirtualised lists

Move the boundary down, do not defer it sideways

The position most of this SERP won’t take: reaching for next/dynamicfirst is the wrong instinct. Dynamically importing a component that should never have been a client component doesn’t remove the cost, it reschedules it, usually to the moment the user scrolls, which is when they’re trying to interact.

Push the directive down to the leaf that needs it. Where a client component must wrap server-rendered content, pass that content in as childrenrather than importing it inside the client file: children handed down from a server parent stay on the server. That one pattern removes more client JavaScript than any bundler config we’ve ever changed.

When next/dynamic genuinely earns it

It has to be genuinely interactive, genuinely below the fold and genuinely heavy, all three at once. A rich text editor, a charting library, a map. Deferring a 6 KB accordion is a commit message rather than a strategy.

Two caveats from the docs. ssr: falseworks only inside Client Components and removes that component from the server HTML entirely, which is correct for a map and wrong for anything a reader or crawler needs. And when a Server Component dynamically imports a Client Component, automatic code splitting isn’t currently applied, so the deferral you think you bought may not exist.

Reduce Next.js bundle size in the order that pays

Watch First Load JS per route in the next build table, not total bundle size. Users load routes, not applications. Each step below shrinks the next:

  1. Delete. Dead components, an abandoned experiment, a polyfill for a browser you stopped supporting.
  2. Move it to the server. Formatting, filtering, sorting, markdown rendering, date maths. No event, no browser state, no need to ship it.
  3. Swap the dependency. moment.js for date-fns; a full icon set down to the icons in use.
  4. Then split. next/dynamic for whatever survives the first three.

Barrel files: one named import from a package re-exporting a thousand modules can pull far more than you asked for. Next.js applies optimizePackageImports to a default list including lucide-react, date-fns and recharts, but not your own index.ts barrels. Add them, run @next/bundle-analyzer before forming a theory, and accept the limit: shaving 30 KB off a route that already passes is a day spent on a number nobody experiences.

The single tag that undoes all of it

The claim we’ll defend against any checklist article on this subject: most Next.js performance work is eventually reversed by one third-party script added months later by someone who has never opened the repository. A campaign needs a pixel, support needs a chat widget, and neither goes through engineering.

Tag managers are the sharpest version, because the container is a runtime. Its payload changes without a deploy, so your performance profile changes without a deploy, and CI never sees the change.

Mechanically, next/script offers four strategies: beforeInteractive (root layout only, and almost nothing qualifies), afterInteractive (the default), lazyOnload (idle time), and the experimental worker. Widgets, analytics and pixels belong on lazyOnload. But the strategy prop is the small half of the fix. The large half is an allowlist, a CSP that enforces it, and a named approver.

The durable fix is a performance budget in CI

An optimisation sprint produces a graph that looks excellent for a quarter. A budget produces one that stays flat for years. Three checks, failing differently on purpose.

  • Bundle size per route, asserted against the next build output or with size-limit. Deterministic, so it fails the build.
  • Lab metrics via Lighthouse CI on a pinned runner, three or four representative routes. Noisy, so it comments on the pull request rather than blocking it.
  • Field p75 from your RUM, alerting weekly. The only check describing reality, and the slowest to react, which is why the other two exist.

Set the threshold at today’s number plus a small margin, not at your target. A budget set to an aspiration fails on day one, gets labelled flaky, and is switched off inside a month. Ratchet it down as improvements land.

What this approach costs, and when to hire nobody

The budget has a real failure mode, and we’d rather name it than let you find it. A hard threshold blocks legitimate work. Someone lands a feature that genuinely needs a date picker, the check fails, and at six on a Thursday the cheapest path is to raise the number by 20 KB and merge. Do that four times and the budget is a comment. The only defence we know is procedural, in that raising the threshold takes a second reviewer and a reason in the commit, and that’s a social control rather than a technical one.

There’s also a category of reader who should hire nobody. If your Next.js site is a few dozen static marketing pages and your LCP is four seconds, the cause is almost certainly images skipping the optimiser, a hosting region wrong for your audience, or an analytics stack on beforeInteractive. All three show up in a free PageSpeed Insights report and are an afternoon of work for whoever built the site. Our minimum for a build is $7,500. An afternoon shouldn’t cost that. The figures we publish, that one included, are estimates in US dollars, not quotations; the quote that follows scoping is fixed in writing before kickoff.

Same answer when the bottleneck isn’t the framework. A 900 ms query behind every render, an n+1 in a server component, an upstream API with no cache: hydration discipline touches none of those, and a proposal promising Core Web Vitals gains without opening the data layer is selling you the visible half of the problem. Sorting out which half you have is most of what scoping a web application build or rebuild is for.

What actually justifies the spend

You’ll find a great many posts quoting a conversion lift per 100 milliseconds. Almost all trace back to a handful of retail studies, run on sites nothing like yours, when the web looked different. We’re not relaying those as though they predict your funnel. Two mechanics do the arguing instead, and both are checkable in your own analytics.

Abandonment is mechanical. LCP is the moment the page becomes useful. Every millisecond before it is one in which someone can leave, and when they do it lands in your reporting as a bounce rate problem rather than a performance problem. That misattribution is why slow sites stay slow.

Crawl efficiency is arithmetic.A crawler spends a budget of requests per visit. Slower responses mean fewer URLs fetched, so new pages are found later. On a site with thousands of URLs that genuinely constrains how fast content earns anything. On a twenty-page site it’s irrelevant, and anyone saying otherwise is padding a proposal.

A third, quieter argument: content that exists only after hydration is at the mercy of a rendering queue you don’t control. Server-rendered text isn’t. That’s why this site ships no opacity: 0 in its server HTML, and the same reasoning drives how we approach a WordPress to Next.js migration, where the rendering model is the deliverable rather than the redesign. The build notes on our work page record the same decisions.

The compressed version

  1. Get field data at p75, per route. If CrUX is empty, instrument with useReportWebVitals and the attribution build.
  2. Split LCP into its four sub-parts first. TTFB and resource load delay are usually the guilty pair.
  3. For INP, find the client boundary drawn one component too high and move it down. Reach for next/dynamic fourth, not first.
  4. Cut bundle size by deleting, then moving work to the server, then swapping dependencies, then splitting.
  5. Audit the third-party scripts, then govern them.
  6. Put a budget in CI the same week, at today’s number, with a named owner and a rule about raising it.

If you want it run as scoped work, the shape of the engagement matters more than the day rate. See fixed price versus time and materials and what actually moves a build estimate. Or send the URL and the failing metric, and we’ll name which of the six applies in a two-paragraph email, not a proposal.

Got a version of this problem?

Send a paragraph describing what you’re trying to build and what’s currently in the way. We reply inside two working days with a scope, a price and a start date. If we’re not the right people for it, we say that instead.