A dashboard we were asked to look at scored 34 on mobile. The team had already done the obvious things: images were optimised, fonts were subset, the bundle had been through an analyser. The problem was a single line in the root layout — a client component wrapping the whole tree to provide a theme context. That one wrapper turned every page beneath it into a client component, shipped 340KB of JavaScript that the server could have rendered, and delayed interactivity on every route in the application.
That is the shape of most performance work in the App Router. It is rarely a thousand small inefficiencies. It is usually two or three structural decisions — where the client boundary sits, what blocks the initial response, how data is fetched — each costing hundreds of kilobytes or hundreds of milliseconds. Find those and the rest is noise.
Here is the checklist we actually work through, in the order the wins tend to appear.
1. Audit the client boundary first
'use client' is not a per-file annotation. It marks an entry point into the client bundle, and everything that file imports — plus everything those files import — goes with it. A single misplaced directive near the root of the tree can pull most of your application into the browser.
Start by finding every occurrence and asking what it is there for. The legitimate reasons are state, effects, event handlers, browser APIs and hook-based libraries. Everything else belongs on the server.
The recurring mistake is the provider wrapper. A theme, an analytics context or a state store wrapped around {children} in the root layout does not have to make the children client components — but it will if you write it carelessly. The fix is to keep the provider itself as a thin client component and pass server-rendered children through it:
// app/layout.tsx — stays a Server Component.
import Providers from './providers';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{/* Providers is a client component, but children was already
rendered on the server and passes through as an opaque payload. */}
<Providers>{children}</Providers>
</body>
</html>
);
}
// app/providers.tsx
'use client';
export default function Providers({ children }: { children: React.ReactNode }) {
return <ThemeProvider>{children}</ThemeProvider>;
}
Because children is passed as a prop rather than imported, the server components inside it are never pulled into the client graph. This distinction is responsible for more wasted bundle weight than any other single thing in the App Router.
Then push boundaries down. A page with one interactive element should not be a client component; the element should be. We routinely see a 400-line page marked 'use client' for the sake of one dropdown.
2. Fix waterfalls, not query speed
Sequential awaits are the most common latency bug in server components, and they hide well because each individual query looks fast.
Three awaits at 120ms each is 360ms of server time before a single byte is sent. Run them concurrently and it is 120ms. Nothing got faster; the dependency was imaginary.
// Waterfall: 360ms. Each await blocks the next.
const user = await getUser(id);
const orders = await getOrders(id);
const offers = await getOffers(id);
// Concurrent: ~120ms. Only genuinely dependent calls should be sequential.
const [user, orders, offers] = await Promise.all([
getUser(id),
getOrders(id),
getOffers(id),
]);
Where a dependency is real — you need the user before you can fetch their organisation — move the dependent part into its own component and wrap it in <Suspense> so the rest of the page streams immediately rather than waiting.
Two related points. Requests are deduplicated within a single render pass, so calling the same fetch in two components is not the problem people assume it is. And a slow third-party call in a layout is the worst case in the entire framework: layouts render on every navigation within their segment, so one 800ms call there taxes every page below it.
3. Know which rendering mode each route is in
Routes that could be static frequently are not, because something in them opted into dynamic rendering by accident. Reading cookies(), headers() or searchParams, or setting cache: 'no-store', makes the whole route dynamic.
The cost is significant: a static route is served from the edge in tens of milliseconds, while a dynamic one runs your server on every request. Check the build output — it reports which routes are static and which are dynamic — and for anything unexpectedly dynamic, find the cause.
For content that changes occasionally, time-based revalidation is usually the right answer rather than full dynamic rendering. For pages with a static shell and a small personalised region, render the shell statically and stream the personalised part inside <Suspense>. Most "this page must be dynamic" requirements turn out to be about one component, not the whole page.
4. Treat LCP as a layout problem
Largest Contentful Paint is usually decided by the hero area, and the mistakes are consistent.
- The LCP image must not be lazy. Set
priorityon it. Lazy-loading the hero image is self-inflicted and common, because lazy is the sensible default everywhere else. - Always give images explicit dimensions. Width and height, or
fillwith a sized container. Missing dimensions cause layout shift, and CLS is the easiest of the three core metrics to get to zero. - Set
sizeswhen usingfillor responsive images. Without it the browser assumes full viewport width and downloads a far larger file than the layout needs — often the difference between a 40KB and a 300KB image on mobile. - Serve modern formats at sane quality. AVIF and WebP at quality 75–80 are visually indistinguishable from quality 95 at a fraction of the bytes.
Fonts deserve their own note because they block text rendering. Use next/font so files are self-hosted and the CSS is generated with the correct preload — this removes a connection to a third-party font host from the critical path. Set display: 'swap', subset to the character ranges you need, and be honest about weights: four weights of two families is eight files, and most designs use three.
If your hero is a heavy 3D scene or a video, that is an LCP decision more than a design one. A CSS-rendered hero with a small amount of motion is dramatically cheaper than a canvas that needs a large library parsed and executed before anything appears — which is exactly why this site's hero is CSS.
5. Find the three largest things in your bundle
Run the bundle analyser and look at the biggest modules rather than the long tail. The pattern is almost always the same handful of causes.
A date library imported wholesale for one format call. An icon set imported as a namespace so tree-shaking cannot help. A charting library loaded on a page where the chart is below the fold. A markdown or syntax-highlighting bundle pulled into the client when the rendering could have happened on the server. A utility library where three functions are used and the whole package ships.
Three fixes cover most of it. Import only what you use, with named imports rather than namespace imports. Move formatting and transformation to the server, where the library is free. And for genuinely heavy components that are not immediately visible — editors, charts, maps — use next/dynamic so the code loads on interaction or when scrolled into view.
One warning on dynamic imports: they are frequently applied to components that are above the fold, which replaces a bundle cost with a visible loading delay and a layout shift. Dynamic import is for things the user might never need, not for things they will need immediately.
6. Measure on real devices and real networks
A local production build on a development machine over localhost is not a measurement. It is a sanity check.
The gap between a development-machine score and a mid-range Android on a throttled connection is not marginal — parsing and executing JavaScript is several times slower on that hardware, which is precisely why bundle size matters more than it appears to on a laptop. Test with CPU throttling on, and prefer field data over lab data where you have it, because your actual users are the distribution that counts.
| Symptom | Most likely cause | First thing to check |
|---|---|---|
| High TTFB | Sequential data fetching or a dynamic route that could be static | Awaits in the page and its layouts; build output rendering mode |
| Slow LCP, fast TTFB | Hero image not prioritised, or oversized download | priority and sizes on the hero image |
| Poor INP | Too much client JavaScript hydrating | Where the 'use client' boundary sits |
| Layout shift | Unsized images, or a font swap moving text | Explicit image dimensions; font loading strategy |
| Fine on desktop, poor on mobile | Main-thread execution cost | Total client bundle for that route |
| Regressed after a feature | A new client component or a heavy import | Bundle diff against the previous release |
7. Keep it from regressing
Performance work that is not defended in CI decays within two quarters. Someone adds a provider, someone imports a chart library, and the score drifts back down with no single commit to blame.
Two cheap guards catch nearly everything. A bundle-size budget per route that fails the build on a material increase, which is the check that catches accidental client boundaries and heavy imports at the moment they are introduced. And a Lighthouse run in CI against a production build, treated as a regression signal rather than a target — a route dropping fifteen points on one pull request is actionable information, whereas an absolute score in a CI container is not worth arguing about.
Bundle size is the metric to defend automatically, because it is deterministic, it is measurable per route, and almost every serious client-side performance problem shows up there first.
What this means in practice
Work top-down. Audit the client boundary, then the data-fetching waterfalls, then the rendering mode of each route. Those three account for the large majority of available improvement in a typical App Router codebase, and all three are structural — you fix them once rather than continuously.
Only then spend time on images, fonts and bundle trimming, which are real but smaller and more evenly distributed. Measure on a throttled mid-range device throughout, because the desktop numbers will tell you everything is fine well past the point where it is not.
Then defend it with a per-route size budget in CI, because the alternative is doing this work again next year. If you are looking at a slow application and cannot tell whether the problem is structural or a thousand small things, send us the route and the numbers — untangling that is standard web development work for us, and the answer is usually narrower than it looks.