
Optimising Client Performance in Next.js
I’ve been working on the 3 Sided Cube website, one of the big focuses I’ve had is improve the performance of the site.
During this time I’ve learned about how to improve the performance of a Next.js site, but also a few tricks I found hard to find in other places. I thought I’d share them here.
Profiling
The most important part of optimising any application is profiling. It’s easy to think a change is going to improve things, but measuring it can show the actual effect.
Make sure you run all browser profiling in an incognito window, this means your browser extensions would be included in the test.
Incognito Mode
When profiling browsers, it’s a goode idea to run them in incognito mode. This ensures your results are isolated from other tabs and also usually turns of your browser extensions.
Google Lighthouse
Google Lighthouse is a good tool for diagnosing your overall page performance, while it’s not perfrect it’s a good starting point.

To run it, open up the devtools in Chrome and look for the lighthouse tab.
Make sure you run it in mobile mode, 99% your application will look amazing in desktop but mobile has a much higher bar in terms of performance.
React Profiler
The first tool you might be tempted to break out is the react browser extension’s profiler. However, be warned! There are some things you need to do in order to get usable results.
In order to properly benchmark next, you must run next build --profile
and then next start
.
Using the dev build of isn’t a perfect indicator of performance, so try to use the build function.
Also for profiling make sure your using Chrome. As of March 2025, the Firefox version doesn’t have the ability to measure from page reload.
Firefox Profiler
I’ve found the firefox profiler to be much powerful than it’s chrome equivelent, letting you profile not only your JS code but also the browser code running underneath.
This can help you understand what specific browser APIs are running slowly, but also specifically why.

For example, in this image I knew that the canvas drawImage
was slow but I wasn’t sure why.
Firefox devtools show me it was because of a memcpy call,
which lead me to realising I had to use the WorkerTransferAPI in
order to completely remove this cost.
So back to the code
Now that we know how to see where we’re running slow, let’s see what we can do about it.
Be careful about Use Client
Anytime a component has “use client” it means it has to be hydrated. This is fine in smaller chunks.
"use client"
import Link from "next/link";
const Page = () => {
return (
<div>
<Link href="/" onClick={() => triggerLinkClick("/")}>Example Link</Link>
...
</div>
)
}
However for this example, the problem is that now the entire page has to be hydrated, this can be expensive as every other sub component also has to be re-rendered on the client.
Instead take the part that needs to run on the client and split it off into it’s own component.
import AnalyticsLink from "./AnalyticsLink";
const Page = () => {
return (
<div>
<AnalyticsLink href="/">Example Link</AnalyticsLink>
...
</div>
)
}
// AnalyticsLink.ts
"use client"
import Link from "next/link"
const AnalyticsLink = (props) => {
return (
<Link
onClick={() => triggerLinkClick(props.href)}
{...props}
/>
)
}
Now the client only has to rehydrate the specific section instead of the whole component. This also reduces the bundle size, as the Page component is no longer shipped to the client. It’s overall good practive to reduce the amount of code that’s marked as “use client” as much as possible.
Disabling Hooks
It’s important to note that just because you don’t render any JSX, doesn’t mean the hooks running.
const Animation = ({hidden}) => {
const progress = useScrollProgress()
if (hidden) return null;
return <ScrollAnimation progress={progress}/>
}
For example take this animation component, just because you’ve
not returning anything doesn’t mean the useScrollProgress
hook stop running.
Every time the scroll changes, this render function re-runs, which if your doing
it lot’s can be quite expensive. Additionally, if you have any children those also have to be re-rendered
To avoid this, you can again split off the hook usage into a seeprate component.
const Animation = ({hidden}) => {
if (hidden) return null;
return <RunningAnimation/>
}
const RunningAnimation = () => {
const progress = useScrollProgress()
return <ScrollAnimation progress={progress}/>
}
This means that the useScrollProgress hook is only run when actually used instead of all the time.