Next.js Performance Optimization: What Actually Improves Real User Metrics
A client once asked us to "fix the performance" on a Next.js app that scored a perfect 100 in Lighthouse. Their real users were still complaining it felt slow. That gap is the whole story: Lighthouse runs against an empty cache, on a clean simulated connection, with none of your actual users' extensions, ad blockers, or three-year-old Android phones in the picture. It's a useful signal and a genuinely poor proxy for what real people experience.
After optimizing a number of production Next.js applications, we've found the same handful of patterns move the needle every time — and it's rarely the framework's fault when a site feels slow. It's almost always a decision made around it: a script nobody audited, a fetch that pulls more than the page needs, an image nobody bothered to resize.
Images Are Usually the Real Culprit
Images touch nearly every metric that determines whether a page feels fast: Largest Contentful Paint, since the hero image is often the largest content the browser has to paint; total bandwidth, which matters enormously more on a mobile connection than a developer's fiber line; and layout stability, since an image loading in without reserved space is exactly what causes that jarring shift as the page repositions around it. We've watched a single unoptimized hero image single-handedly wreck an otherwise well-built page's LCP score, dragging a genuinely fast application down to a "poor" rating on Google's own dashboard.
The fix isn't exotic: explicit width and height on every image so the browser reserves space before it loads, the `priority` flag reserved strictly for the actual above-the-fold hero — not sprinkled onto every image out of caution, which defeats its purpose — and format-aware compression that serves AVIF or WebP with sensible fallbacks instead of shipping the same oversized PNG to every device regardless of screen size.
JavaScript Costs More Than It Looks Like It Should
Every kilobyte of JavaScript shipped has to be downloaded, parsed, and executed before the page is genuinely interactive, and that cost isn't evenly distributed across your users — it hits hardest on exactly the devices that can least absorb it, older Android phones with weaker processors, where the same bundle that parses instantly on a new iPhone can add a full second of blocking time. This is why we're ruthless about dependencies during code review, pulling out anything that's providing marginal convenience for real bundle-size cost. We split aggressively with dynamic imports for anything not needed on first paint, avoid the kind of global state architecture that forces huge client bundles just to render a page, and turn off hydration entirely for sections of the page that never needed to be interactive in the first place. The pattern that holds up again and again: less JavaScript beats cleverer JavaScript, almost every time you're forced to choose.
Overfetching Is a Quiet, Compounding Problem
Data fetching is where a lot of "why does this feel slow" mysteries actually live, and it's rarely dramatic — it's a component pulling a full object when it renders three fields from it, or three separate fetches happening in sequence when they had no dependency on each other and could have run in parallel. We fetch per screen rather than per page, so a component only pulls what it actually renders, parallelize anything independent instead of letting requests waterfall one after another, and cache with an explicit, considered revalidation strategy instead of leaving the defaults unexamined and hoping they're right. None of this shows up clearly in a synthetic lab score — it shows up when you test on a real mid-tier device over a throttled connection and watch what a real user actually experiences waiting for the page to become useful.
Server Components Shift Where the Cost Lives
The App Router's Server Components change this calculus in a genuinely useful way: a lot of what used to be "client-side performance work" — data fetching, rendering non-interactive UI — can move server-side entirely, where it never touches the client bundle at all. We keep data-fetching and static UI in Server Components by default, and push `'use client'` boundaries as far down the component tree as they'll go, rather than marking an entire layout as a client component because one button somewhere inside it needs an onClick handler. Done deliberately, this alone often cuts client bundle size meaningfully — before any other optimization work even starts.
Third-Party Scripts Are the Culprit Nobody Audits
The single most common cause of a "why is this suddenly slow" ticket we get isn't application code at all — it's a third-party script someone added six months ago and nobody's looked at since. Analytics tags, chat widgets, ad pixels, A/B testing tools, session-replay scripts: each one seems harmless in isolation, and each one blocks the main thread, makes its own network request, and often pulls in dependencies of its own that never show up in your own bundle analysis because they load from someone else's server at runtime.
We treat every third-party script as a cost that has to justify itself, not a default to include. That means loading anything non-critical with Next.js's `strategy="lazyOnload"` or `"worker"` so it doesn't compete with the actual page for the main thread during initial load, auditing the script list quarterly rather than assuming last year's decisions are still correct, and asking, honestly, whether a given tool's value justifies the render-blocking cost it's imposing on every single visitor. It's common to find three or four scripts stacked on a page where one would do — a session-replay tool and an analytics platform that both track largely the same events, left running side by side because removing either one felt risky.
Fonts Are a Layout Shift Waiting to Happen
Custom fonts are one of the quieter causes of a poor Cumulative Layout Shift score: the page renders with a fallback font, the custom font loads a moment later, and the text reflows — sometimes dramatically, if the two fonts have different metrics — while a user is already reading. `next/font` solves the core of this by self-hosting fonts and computing fallback metrics automatically, which eliminates the external network request to a font CDN and minimizes the visual jump when the real font arrives.
Where teams still get this wrong is loading too many font weights and styles "just in case" — every additional weight is another file the browser has to fetch, and most pages genuinely need two or three, not the seven a design file happened to include. We treat the font list the same way we treat dependencies: include only the weights actually used on the page, and let `next/font`'s automatic fallback matching handle the rest.
Final Thoughts
Performance work isn't a checklist of clever tricks to apply once and forget. It's a discipline of measuring first, on real devices and real networks, and then removing whatever the users never actually needed — rather than adding cleverness on top of a page that was doing too much to begin with. Build less. Ship less JavaScript. Measure on hardware that isn't the newest thing in the room. Repeat.