Why Your Next.js App Feels Slow Even When Lighthouse Looks Fine
A green Lighthouse score does not guarantee that your Next.js app feels fast.
Lighthouse measures a controlled page load. Your users are dealing with mid-range phones, crowded Wi-Fi, cached assets, long-running JavaScript, and the one interaction you never tested. When a page feels slow in production, the answer is usually not another round of bundle-size guessing. It is better measurement.
Start with the moment that feels slow
“Slow” is too vague to debug. Name the moment:
- The first content appears late.
- The page looks ready, but buttons do nothing for a second.
- A search result takes too long to update.
- Navigating between routes shows a blank or frozen screen.
- Scrolling becomes jerky after a component mounts.
Each symptom points to a different part of the browser’s work. A delayed first screen suggests network or server-rendering problems. A page that looks ready but ignores taps suggests main-thread JavaScript. A sluggish search interaction may be doing too much rendering or waiting for a request that should have been debounced.
This distinction saved me from “fixing” a page by compressing assets when the real problem was a 400 ms render after the user had already started interacting with it.
Measure field performance, not just a lab score
Lighthouse is useful for catching regressions in a repeatable environment. It is not a replacement for real-user monitoring.
For a Next.js app, track the user-facing events that matter:
- Largest Contentful Paint (LCP): when the main content becomes visible.
- Interaction to Next Paint (INP): how quickly the page responds to interactions.
- Cumulative Layout Shift (CLS): whether content jumps while the page loads.
- Route transition time: how long navigation takes from intent to usable content.
Break those numbers down by route, device, connection type, and release. An average can hide the exact group having a terrible experience. If mobile users on one route have a poor INP after a recent release, you have a much smaller investigation than “the site is slow.”
Find the JavaScript that blocks interaction
The browser can download your page quickly and still feel unresponsive if JavaScript monopolizes the main thread.
Use the Performance panel to record the interaction. Look for long tasks around the click or keypress, then connect them to the component that rendered. Common causes include:
- Rendering a large list without virtualization.
- Parsing or transforming a large response during render.
- Recreating expensive derived data on every keystroke.
- Hydrating a component that did not need to be interactive.
- Loading an editor, chart library, or animation package before it is needed.
The fix is usually to reduce work, move it later, or avoid doing it on the main thread. useMemo can help with a measured calculation, but it cannot make an unnecessarily large render cheap. Memoization is a tool, not a performance strategy.
Make server and client boundaries intentional
Next.js makes it easy to mix server and client components, which is powerful until every interactive feature starts pulling more code into the browser.
Keep components on the server by default. Add a client boundary where state, effects, or browser APIs are genuinely needed. Then check what crosses that boundary. Passing a large object into a client component can increase the serialized payload even if the component only needs two fields.
For heavy features, load them when the user needs them:
import dynamic from "next/dynamic";
const MarkdownEditor = dynamic(() => import("./MarkdownEditor"), {
loading: () => <p>Loading editor…</p>,
});This does not automatically make the page fast. It does keep an editor from competing with the initial route when most visitors never open it. That is the trade-off: a little waiting at the feature boundary in exchange for a lighter first interaction.
Do not forget the network after the page loads
A fast route can still create a slow experience with a waterfall:
- Render the page.
- Fetch the user.
- Fetch the project after the user arrives.
- Fetch the project’s items after the project arrives.
Some dependencies are real, but many are accidental. Start independent requests together, fetch only the fields needed for the first view, and put loading states near the content that is waiting. A single skeleton covering the entire page often makes the application feel more frozen, not less.
Caching also needs a clear owner. Decide whether data belongs in the browser cache, the Next.js data cache, or neither. If every navigation bypasses a cache, users pay the same network cost repeatedly. If everything is cached forever, users see stale data and you inherit a different class of bug.
What I check before reaching for React.memo
My order of operations is now fairly boring:
- Reproduce the slow moment on a realistic device or throttled profile.
- Record it in the Performance panel.
- Check field data by route and release.
- Inspect the network waterfall and response sizes.
- Find the largest avoidable task or request.
- Change one thing and measure again.
Only after that do I consider component memoization. React.memo, useCallback, and useMemo can reduce repeated work, but they also add comparison and maintenance costs. If the component is cheap or the props change every time, the optimization is mostly decoration.
The useful question is not “Can I make this component render less?” It is “What work is delaying the user’s next useful action?”
That question leads to smaller fixes, better measurements, and fewer performance rituals that make the code harder to understand without making anyone’s day better.
If your Next.js app feels slow, record the slow interaction before changing the code. The browser usually leaves enough evidence to tell you where the time went.