Why Performance Matters
Performance is a feature. Studies consistently show that a 100 ms delay in load time can result in a 1% decrease in conversions. Every millisecond counts — especially on mobile devices and slower connections.
1. Use React.memo for Component Memoisation
Wrap components that receive the same props repeatedly with React.memo to prevent unnecessary re-renders:
const ExpensiveComponent = React.memo(({ data }: { data: Item[] }) => {
return <div>{/* complex rendering */}</div>;
});
2. useMemo and useCallback
Use useMemo to memoize expensive computations and useCallback to stabilise function references passed as props:
// Memoize expensive computation
const sortedItems = useMemo(() => {
return [...items].sort((a, b) => a.name.localeCompare(b.name));
}, [items]);
// Stabilise callback reference
const handleClick = useCallback(
(id: string) => {
onItemClick(id);
},
[onItemClick],
);
Rule of thumb: Only add
useMemo/useCallbackwhen you have a measured performance problem. Premature memoisation adds complexity without benefit.
3. Code Splitting with React.lazy
Split your bundle into smaller chunks that load on demand:
const Dashboard = React.lazy(() => import("./Dashboard"));
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<Dashboard />
</Suspense>
);
}
This is automatically done for you per-route in Next.js — you get route-based code splitting for free.
4. Virtualise Long Lists
For lists with hundreds or thousands of items, render only the visible rows using virtualisation:
npm install @tanstack/react-virtual
import { useVirtualizer } from "@tanstack/react-virtual";
function VirtualList({ items }: { items: string[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 40,
});
return (
<div ref={parentRef} style={{ height: 400, overflow: "auto" }}>
<div style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((item) => (
<div
key={item.key}
style={{ transform: `translateY(${item.start}px)` }}
>
{items[item.index]}
</div>
))}
</div>
</div>
);
}
5. Avoid Anonymous Functions in JSX
Anonymous functions create new references on every render, causing child components wrapped in React.memo to re-render unnecessarily:
// ❌ Avoid — new function reference on every render
<Button onClick={() => handleClick(item.id)} />;
// ✅ Prefer — stable reference
const handleItemClick = useCallback(() => handleClick(item.id), [item.id]);
<Button onClick={handleItemClick} />;
6. Use the Production Build for Measurements
Always measure performance in production mode. The development build includes extra warnings and checks that inflate render times by 2–5×:
npm run build && npm start
7. Optimise Images
Images are typically the largest assets on a page. In Next.js, use the built-in Image component for automatic optimisation:
import Image from "next/image";
<Image
src="/hero.jpg"
alt="Hero image"
width={1200}
height={630}
priority // preload above-the-fold images
/>;
This automatically serves WebP/AVIF, applies lazy loading, and eliminates layout shift.
8. Debounce Expensive Operations
For operations triggered by user input (search, filtering), use debouncing to reduce how often expensive work runs:
import { useMemo } from "react";
import { debounce } from "lodash-es";
const debouncedSearch = useMemo(
() =>
debounce((query: string) => {
setResults(expensiveSearch(query));
}, 300),
[],
);
9. Web Workers for Heavy Computation
Move CPU-intensive work off the main thread to keep the UI responsive:
// worker.ts
self.onmessage = ({ data }) => {
const result = heavyComputation(data);
self.postMessage(result);
};
// component
const worker = new Worker(new URL("./worker.ts", import.meta.url));
worker.postMessage(inputData);
worker.onmessage = ({ data }) => setResult(data);
10. Profile with React DevTools
Use the React DevTools Profiler to identify actual bottlenecks. It produces a flame graph showing:
- Which components re-rendered
- How long each render took
- What triggered the re-render
Don't guess — measure first, optimise second.
Conclusion
Performance optimisation is an ongoing process, not a one-time task. Start by measuring, identify the highest-impact bottleneck, fix it, then measure again. This cycle, combined with the techniques above, will keep your React apps fast as they grow.