On this page 8
React is fast enough for most interfaces out of the box, and Next.js ships with sensible defaults for splitting code, optimizing images and rendering on the server. Slow apps usually get that way through a handful of specific causes: too much JavaScript, components that re-render far more than they need to, heavy images, and data fetched in the wrong place.
This guide covers how to find those causes and what to do about each one. It is a general checklist. For a first-hand account of tuning one site, read how the Lightrains website reached a Lighthouse score of 99/100.
Measure before you change anything
Performance work without measurement tends to add complexity in places that were never slow. Start with numbers, and check them again after each change.
Core Web Vitals are the user-facing metrics Google reports and uses in search:
- LCP (Largest Contentful Paint): how long until the main content, usually a hero image or heading, appears.
- INP (Interaction to Next Paint): how quickly the page responds to clicks, taps and key presses. It replaced First Input Delay in 2024 and is where slow React rendering shows up.
- CLS (Cumulative Layout Shift): how much the layout jumps while the page loads.
Lab tools run a test on demand. Lighthouse, in Chrome DevTools or PageSpeed Insights, gives a repeatable report with specific suggestions. The Chrome DevTools Performance panel shows exactly what the main thread was doing during a slow interaction.
Field data comes from real users on real devices. PageSpeed Insights shows Chrome User Experience Report data for sites with enough traffic. To collect your own, use the web-vitals library, or Next.js’s useReportWebVitals hook, and send the values to your analytics. Lab scores on a fast laptop can look fine while users on mid-range phones have a poor INP.
React Profiler answers the React-specific question: which components rendered, why, and how long they took. Record an interaction in the Profiler tab of React DevTools, then look for components that render often or take a long time. Turn on “Record why each component rendered” in its settings. For measurements in code, wrap a subtree in the <Profiler> component and log its onRender timings.
Always measure a production build. Development mode adds checks and warnings that make React noticeably slower, so profiling next dev gives misleading numbers. Run next build && next start (or the production build of your bundler) before you draw conclusions.
Understand what makes React render
A component re-renders when:
- its own state changes,
- its parent re-renders, or
- a context it reads changes.
Rendering is not the same as updating the DOM. React compares the new output with the previous one and only touches the DOM where something changed. A re-render is a problem only when the render function itself is expensive, or when it happens to a large tree on every keystroke.
Many re-render problems are fixed by restructuring, without any memoization.
Move state down. If only a search box needs query, keep the state in the search box instead of in the page component, so typing does not re-render the whole page.
Pass expensive children as props. A component that changes often can wrap content that does not:
function Collapsible({ children }) {
const [open, setOpen] = useState(false)
return (
<section>
<button onClick={() => setOpen(!open)}>Toggle</button>
{open && children}
</section>
)
}
// ReportTable is created here, not inside Collapsible,
// so toggling Collapsible does not re-render it
function ReportSection({ rows }) {
return (
<Collapsible>
<ReportTable rows={rows} />
</Collapsible>
)
}
Split contexts. A single context holding the user, theme and cart re-renders every consumer when any part changes. Separate contexts, or a state library with selectors, limit updates to the components that use the changed value.
Give list items stable keys. Use an ID from your data, not the array index, so React can reuse rows when the list is reordered or filtered.
Virtualize long lists. Rendering thousands of rows creates thousands of DOM nodes. Libraries such as TanStack Virtual or react-window render only the rows in view.
Memoization: when it helps and when it does not
React offers three memoization tools:
React.memo(Component)skips re-rendering a component when its props are shallowly equal to last time.useMemo(fn, deps)caches a computed value between renders.useCallback(fn, deps)caches a function, so its identity stays the same.
They help when:
- a component is measurably slow to render and often receives the same props;
- a calculation is expensive, such as filtering or sorting a large array, and its inputs rarely change;
- a value or function is passed to a memoized child, or used as an effect dependency, and a new identity on every render would defeat that.
They do not help when:
- the component is cheap to render. The comparison has its own cost, and the extra code has to be maintained;
- the props change every render anyway.
React.memoon a component that receives an inline object, such asstyle={{ margin: 0 }}, or an inline arrow function never skips a render; - you have not profiled. Memoizing everything by default spreads complexity across the codebase for little measured gain.
const ProductGrid = memo(function ProductGrid({ products, onSelect }) {
return products.map(p => (
<ProductCard
key={p.id}
product={p}
onSelect={onSelect}
/>
))
})
function Catalog({ products, filter }) {
const visible = useMemo(
() => products.filter(p => p.category === filter),
[products, filter]
)
const handleSelect = useCallback(id => trackSelection(id), [])
return (
<ProductGrid
products={visible}
onSelect={handleSelect}
/>
)
}
Here useMemo and useCallback exist so that ProductGrid actually skips re-rendering. Without the memo wrapper on ProductGrid, both hooks would do nothing useful.
The React Compiler applies this kind of memoization automatically at build time, and Next.js can enable it through a config option. If your project uses it, most hand-written useMemo and useCallback calls become unnecessary. Profile before and after enabling it, as with any change.
For class components, PureComponent and shouldComponentUpdate play the role of React.memo. New code is usually written with function components.
Keep interactions responsive
Some updates are expensive by nature, such as filtering a large table as the user types. useTransition and useDeferredValue let React treat the expensive part as low priority, so the input stays responsive and the result catches up a moment later:
function SearchPage({ items }) {
const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)
const results = useMemo(
() => items.filter(item => item.name.includes(deferredQuery)),
[items, deferredQuery]
)
return (
<>
<input
value={query}
onChange={e => setQuery(e.target.value)}
/>
<ResultList results={results} />
</>
)
}
This targets INP directly: the keystroke paints first, and the heavy render follows.
Ship less JavaScript
Every kilobyte of JavaScript has to be downloaded, parsed and executed, which hurts most on slower phones.
Split by route and by feature. Next.js already splits code per route. Within a page, load heavy components that are not needed at first paint, such as charts, editors, maps and modals, with next/dynamic, or React.lazy with Suspense in other React apps:
import dynamic from 'next/dynamic'
const RevenueChart = dynamic(() => import('./RevenueChart'), {
loading: () => <ChartSkeleton />
})
Check what is in the bundle. @next/bundle-analyzer shows which packages take up space. Common findings are a whole date or utility library imported for one function, icon packs imported in full, and code meant for the server ending up in the client bundle.
Load third-party scripts late. Analytics, chat widgets and tag managers often cost more than your own code. next/script with strategy="afterInteractive" or "lazyOnload" keeps them from blocking the page.
Images and fonts
Images are often the largest files on a page and the usual LCP element.
The next/image component resizes and converts images on request, serves modern formats such as WebP or AVIF where the browser supports them, and lazy-loads images below the fold. To get the most from it:
- Set
sizeson responsive images so the browser picks a suitably small file instead of the largest one. - Do not lazy-load the LCP image. Mark the hero image as a priority image so it starts downloading early. The prop for this has changed between Next.js versions, so check the
next/imagedocs for the version you run. - Always give images dimensions (
widthandheight, orfillinside a sized container) so the layout does not shift when they load.
next/font downloads Google Fonts or local font files at build time and serves them from your own domain, so there is no extra connection to a font host. It also generates a size-adjusted fallback font, which reduces layout shift when the web font swaps in. Load only the weights and subsets you use.
Choose where each page renders
How a page is rendered decides how much work happens on the server, how much JavaScript the browser runs, and how fresh the data is.
- Static rendering builds the HTML once and serves it from a CDN. It is the fastest option for pages that are the same for every visitor, such as marketing pages, docs and blog posts.
- Incremental Static Regeneration (
revalidate) keeps pages static but rebuilds them in the background after a set time, which suits catalogs and listings that change a few times a day. - Dynamic server rendering builds HTML on each request. Use it for personalized or fast-changing data. It costs a server round trip, so keep the data fetching behind it fast.
- Client-side fetching suits data that is private to the user and not needed for the first paint, such as notification counts.
With the App Router, components are Server Components by default. They render on the server and send no JavaScript of their own to the browser. Add 'use client' only to the components that need state, effects or browser APIs, and keep those boundaries as low in the tree as you can. A 'use client' at the top of a layout pulls everything below it into the client bundle.
Streaming with Suspense boundaries, or a loading.js file, sends the page shell immediately and fills in slow sections as their data arrives, so one slow query does not hold up the whole page.
Caching defaults in Next.js have changed between major versions. Check whether your data requests are cached in the version you run instead of assuming. On the Pages Router, getStaticProps and getServerSideProps make the same static-versus-dynamic choice; see server-side rendering in Next.js with getServerSideProps for how they differ.
A short checklist
- Profile a production build, and collect Web Vitals from real users.
- Fix re-renders by restructuring state and context first; memoize where the Profiler shows a cost.
- Use transitions or deferred values for expensive updates triggered by typing.
- Split heavy components and third-party scripts out of the initial bundle.
- Size images correctly, prioritize the LCP image and give every image dimensions.
- Self-host fonts with
next/font. - Render static where you can, keep client components small, and stream slow sections.
Measure again after each change. A fix that does not move a metric is extra code to maintain.
This article originally appeared on lightrains.com
Leave a comment
To make a comment, please send an e-mail using the button below. Your e-mail address won't be shared and will be deleted from our records after the comment is published. If you don't want your real name to be credited alongside your comment, please specify the name you would like to use. If you would like your name to link to a specific URL, please share that as well. Thank you.
Comment via email