InsightsWeb Performance
Why your Next.js app is slow, and the four things that usually fix it
Four causes account for most slow Next.js apps. Measure before you touch any of them, because fixing the wrong one costs a week.
- Published
- Reading time
- 9 min read
- Topic
- Web Performance
- Written by
- The Apoliums Team

Four causes account for most slow Next.js App Router applications: request waterfalls inside server components, a client boundary that has crept most of the way up the tree, images and fonts that block the first paint, and third-party scripts running on the main thread. Apoliums works through them in that order, and measures first, because fixing the wrong one costs a week and moves nothing.
How do you find out what is actually slow?
Separate the two numbers before touching any code. Time to first byte is a server problem; everything after it is a client problem. They have completely different fixes, and teams routinely optimise the wrong one — a week spent on bundle size moves nothing when the page is waiting four hundred milliseconds on a database call.
Get the server number from the response itself:
curl -o /dev/null -s -w "ttfb: %{time_starttransfer}s total: %{time_total}s\n" \
https://example.com/products
If TTFB is high, the page is waiting on data and the fix is in the first section below. If TTFB is fine and the page still feels slow, the cost is in what you shipped to the browser, and the fix is in the sections after it. The Chrome DevTools performance panel tells you which of the two you are looking at in one recording.
For the client side, run Lighthouse in a private window with extensions disabled, then look at the actual field metrics if you have them. The two that matter are Largest Contentful Paint, which should land under 2.5 seconds, and Interaction to Next Paint, which should land under 200 milliseconds — both at the 75th percentile of real mobile sessions, not on your laptop. Lab numbers on a developer laptop with a fast connection are a debugging tool, not a measurement of what users get.
Cause one: sequential awaits in server components
This is the most common and the least visible. Server components make data fetching look like ordinary code, and ordinary code runs in order. Two awaits that do not depend on each other still run one after the other:
export default async function Page() {
const user = await getUser();
const orders = await getOrders();
const offers = await getOffers();
return <Dashboard user={user} orders={orders} offers={offers} />;
}
Three round trips of 120ms each is 360ms of TTFB for work that could have taken 120ms. The fix is to start all three before awaiting any:
export default async function Page() {
const [user, orders, offers] = await Promise.all([
getUser(),
getOrders(),
getOffers(),
]);
return <Dashboard user={user} orders={orders} offers={offers} />;
}
The harder version of the same problem is a waterfall across component boundaries: a parent fetches, renders a child, and the child fetches something it could have started earlier. Promise.all does not help there because the calls live in different components. Two things do.
Start the promise in the parent and pass it down unawaited. The child awaits it with use(), so the request begins at the top of the tree while rendering continues.
Stream the slow part. Wrap the component that owns the slow fetch in Suspense with a real skeleton. The rest of the page reaches the browser immediately and the slow region fills in. This does not make the fetch faster; it removes it from the critical path, which is usually what the user actually notices.
A related trap: an await in the root layout blocks every route in the application. A layout that fetches navigation data on every request sets a floor under the TTFB of every page. Cache it or move it.
Cause two: the client boundary has crept upward
Every "use client" marks the top of a subtree that ships to the browser. The directive is easy to add and nothing warns you when it lands too high, so it drifts up the tree over a few months of feature work until most of the page is a client bundle.
The usual path: a page needs one interactive filter, so the file gets "use client". The page imports a date formatter, a chart library and an icon set. None of those needed to be interactive, and all of them are now in the browser bundle.
Two moves fix it, and the second matters more.
Push the boundary down. Extract only the interactive part into its own client component. The page stays a server component and imports the small client island.
Pass server components as children rather than importing them into client components. A client component can render server-rendered content it receives as props:
export default function Page() {
return (
<Collapsible>
<ExpensiveServerRenderedTable />
</Collapsible>
);
}
Collapsible owns the open/closed state in the browser. The table never enters the client bundle, because it arrives as already-rendered output rather than as a module the client component imports.
To find where the boundary actually sits, run the bundle analyser rather than reasoning about it. The results are reliably surprising — a locale file, a full icon package imported by name, or a markdown renderer pulled in by a component that renders three lines of static text.
Cause three: images and fonts on the critical path
Images are usually the largest bytes on the page and fonts are usually the reason text appears late. Both have specific fixes in Next.js and both are frequently half-applied.
For images, the next/image component is not optional if you care about this number. It emits width and height so the layout does not shift, serves modern formats, and lazy-loads by default. Three details decide whether it helps:
sizesmust be accurate. Without it, a responsive image downloads at a width chosen for the largest breakpoint regardless of how it renders. This single attribute is often the difference between a 40KB and a 400KB download on mobile.prioritybelongs on exactly one image. The largest element visible without scrolling. Marking several defeats the purpose, because preloading everything preloads nothing preferentially.- Give every non-priority image an explicit aspect ratio. Reserved space is what keeps the layout from shifting when the image arrives.
For fonts, next/font self-hosts the files and generates the @font-face rules at build time, which removes a DNS lookup and a connection to a third-party font host from the critical path. Load only the weights actually used — four weights of a variable font is four files nobody asked for — and keep the fallback stack close in metrics to the real face, because the swap is what users perceive as the page moving.
Cause four: third-party scripts on the main thread
Analytics, chat widgets, tag managers and session recorders execute on the same thread as your application. A synchronous third-party script blocks parsing; an asynchronous one still competes for the main thread during the exact window when the page is becoming interactive.
Use next/script with a deliberate strategy rather than a default one:
beforeInteractive— only for something that must run before hydration. Almost nothing qualifies. Consent management is the usual legitimate case.afterInteractive— the default, and correct for analytics.lazyOnload— for chat widgets, support tools and anything a user does not need in the first few seconds.
The measurement that settles the argument: load the page with third-party scripts blocked, then without. If the difference in interaction latency is large, that is a business conversation about which vendors are worth their cost, and it is much easier to have with a number attached.
What is the order of operations?
- Measure TTFB and client metrics separately, so you know which half of the problem you have.
- If TTFB is high, look for sequential awaits and for fetches in the root layout, and stream the slow regions.
- If the bundle is large, run the analyser and push client boundaries down. Do not guess.
- Fix images and fonts, which are almost always worth the hour they take.
- Audit third-party scripts last, because that fix is a negotiation rather than a code change.
Then measure again. A performance change that was not measured before and after is a belief, not a result.



