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/useCallback when 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.