Performance Basics
13 examples to get you started with React Performance -- 9 basic and 4 intermediate.
Search across all documentation pages
13 examples to get you started with React Performance -- 9 basic and 4 intermediate.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
All examples assume a Next.js 15+ App Router project with React 19 and TypeScript. A few examples use extra tooling:
@next/bundle-analyzer for bundle inspection: npm install --save-dev @next/bundle-analyzer.web-vitals for Core Web Vitals monitoring: npm install web-vitals.zustand for the state-performance example: npm install zustand.Two guiding rules for every example below:
memo, useMemo, useCallback only after profiling proves the win.Looking for a systematic review? See the Performance Checklist -- a 30-point audit you can run in CI.
Use React DevTools to record a real interaction and see which components actually re-render.
import { Profiler, type ProfilerOnRenderCallback } from "react";
const onRender: ProfilerOnRenderCallback = (id, phase, actualDuration) => {
console.log(`[${id}] ${phase} took ${actualDuration.toFixed(1)}ms`);
};
export default function Page() {
return (
<Profiler id="Dashboard" onRender={onRender}>
<Dashboard />
</Profiler>
);
}
function Dashboard() {
return <p>Content</p>;
}<Profiler> component logs render timings for its subtree -- great for spot measurements.actualDuration vs baseDuration -- large deltas between them highlight wasted re-renders you can memoize away.<Profiler> to production -- it has measurable overhead. Wrap its use in a dev-only check if it lives in the tree.Related: React DevTools Profiler -- flame charts, commit inspection, interactions | Performance Checklist -- what to measure in CI
A parent's re-render does not have to cascade -- split state and keep stable references so children skip work.
"use client";
import { memo, useState } from "react";
const Row = memo(function Row({ label }: { label: string }) {
console.log("Render", label);
return <li>{label}</li>;
});
export default function List({ items }: { items: string[] }) {
const [count, setCount] = useState(0);
return (
<>
<button onClick={() => setCount((c) => c + 1)}>{count}</button>
<ul>
{items.map((i) => (
<Row key={i} label={i} />
))}
</ul>
</>
);
}memo(Component) skips re-renders when props are referentially equal to the last render.List, but Row props (label) have not changed, so every row is skipped.style={{ ... }}) and inline functions break memo -- their reference changes every render.key on lists to let React reuse DOM nodes when items move around -- a key={index} on a reordered list is a frequent bug source.Related: Preventing Unnecessary Re-renders -- split state, lift down, keys | Memoization -- the primitives behind this pattern
Stabilize expensive values and function references so memoized children actually benefit.
"use client";
import { memo, useCallback, useMemo, useState } from "react";
const ExpensiveChart = memo(function ExpensiveChart({
points,
onPick,
}: {
points: number[];
onPick: (n: number) => void;
}) {
return <p>{points.length} points</p>;
});
export default function Dashboard({ raw }: { raw: number[] }) {
const [picked, setPicked] = useState<number | null>(null);
const points = useMemo(() => raw.filter((n) => n > 0), [raw]);
const onPick = useCallback((n: number) => setPicked(n), []);
return (
<>
<p>Picked: {picked ?? "none"}</p>
<ExpensiveChart points={points} onPick={onPick} />
</>
);
}useMemo(fn, deps) caches the value; useCallback(fn, deps) caches the function reference -- both keep prop identity stable for memoized children.raw.filter(...) and (n) => setPicked(n) would be new references every render, invalidating memo.Related: Memoization -- when memoization actually helps | useMemo / useCallback -- the hook APIs
Turn on the React Compiler and let it insert memoization for you -- delete most manual memo/useMemo/useCallback.
// next.config.ts
import type { NextConfig } from "next";
const config: NextConfig = {
experimental: {
reactCompiler: true,
},
};
export default config;// After the compiler is on, write plain components:
function ProductList({ products }: { products: Product[] }) {
const total = products.reduce((sum, p) => sum + p.price, 0);
const handleClick = (id: string) => console.log(id);
return <p>{total} ({products.length} items)</p>;
}useMemo/useCallback stay working.npx react-compiler-healthcheck validates your codebase.Related: React Compiler -- setup, bailouts, debugging | React Compiler (React 19) -- the React 19 feature page
Defer loading a heavy component until the user actually needs it.
"use client";
import dynamic from "next/dynamic";
import { useState } from "react";
const Chart = dynamic(() => import("./Chart"), {
loading: () => <p>Loading chart...</p>,
ssr: false,
});
export default function Dashboard() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>Show chart</button>
{open && <Chart />}
</>
);
}next/dynamic returns a component that code-splits into a separate chunk; it downloads only when rendered.open && <Chart />) so the chunk is fetched on demand -- ideal for modals, charts, and rich text editors.ssr: false opts out of server rendering when the component depends on browser-only APIs (window, document).Related: Bundle Size Optimization -- analyzers, tree-shaking, package cost | Image & Font Performance -- other ways to shave bytes
Default to Server Components; drop "use client" only where interactivity starts.
// app/dashboard/page.tsx -- a Server Component
import { ClientChart } from "./chart";
interface Product {
id: number;
name: string;
price: number;
}
export default async function DashboardPage() {
const res = await fetch("https://api.example.com/products");
const products: Product[] = await res.json();
const total = products.reduce((sum, p) => sum + p.price, 0);
return (
<>
<h1>Total: ${total}</h1>
<ul>
{products.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
<ClientChart data={products} />
</>
);
}<ClientChart> is the only JS shipped.Related: Server Component Performance -- boundaries, patterns, measurements | Server Components (React 19) -- the primitive
Use Next.js's <Image> so the browser ships the right size, lazy-loads below the fold, and reserves space to avoid layout shift.
import Image from "next/image";
export default function Hero() {
return (
<Image
src="/hero.jpg"
alt="Landing hero"
width={1200}
height={630}
priority
sizes="(max-width: 768px) 100vw, 1200px"
/>
);
}next/image serves modern formats (AVIF/WebP), generates multiple sizes, and lazy-loads automatically.width and height reserve space -- zero layout shift even before the image arrives.priority disables lazy-loading for above-the-fold images so they start loading immediately (big LCP win).sizes tells the browser how wide the image will render at each breakpoint -- needed to pick the right source.Related: Image & Font Performance -- font loading, preload, OG images | next/image -- full
<Image>API
Send real-user LCP, INP, and CLS metrics to analytics so you know what the real world sees.
// app/web-vitals.tsx
"use client";
import { useReportWebVitals } from "next/web-vitals";
export function WebVitals() {
useReportWebVitals((metric) => {
console.log(metric.name, metric.value);
// fetch("/api/metrics", { method: "POST", body: JSON.stringify(metric) });
});
return null;
}
// app/layout.tsx
// <WebVitals />useReportWebVitals fires once per metric per page -- forward them to your analytics, Sentry, or Vercel Analytics.Related: Core Web Vitals Optimization -- per-metric fixes and measurement | Performance Checklist -- CI gates and budgets
Break a route into streaming boundaries so fast panels render while slow ones are still fetching.
// app/dashboard/page.tsx
import { Suspense } from "react";
import { FastStats } from "./fast-stats";
import { SlowChart } from "./slow-chart";
export default function Dashboard() {
return (
<div>
<Suspense fallback={<p>Loading stats...</p>}>
<FastStats />
</Suspense>
<Suspense fallback={<p>Loading chart...</p>}>
<SlowChart />
</Suspense>
</div>
);
}<Suspense> streams independently -- the user sees the fast panel immediately instead of waiting for the whole route.loading.tsx for route-level streaming, and nested <Suspense> for granular per-panel streaming.Promise.all) so each panel is fully loaded the moment its JS streams in.Related: Suspense & Streaming Performance -- boundary strategy | Suspense (patterns) -- the primitive | Streaming (Next.js Data) -- route-level streaming
Subscribe to a slice of the store so only components that read that slice re-render.
"use client";
import { create } from "zustand";
interface CartStore {
items: { id: string; qty: number }[];
count: number;
addItem: (id: string) => void;
}
const useCart = create<CartStore>((set) => ({
items: [],
count: 0,
addItem: (id) =>
set((s) => {
const items = [...s.items, { id, qty: 1 }];
return { items, count: items.length };
}),
}));
// Only re-renders when `count` changes, not when any other field does
function CartBadge() {
const count = useCart((s) => s.count);
return <span>{count}</span>;
}
// Only re-renders when `addItem` reference changes (never, unless store recreates)
function AddButton({ id }: { id: string }) {
const addItem = useCart((s) => s.addItem);
return <button onClick={() => addItem(id)}>Add</button>;
}shallow equality or return primitives.Related: State Management Performance -- context splitting, derived state | Zustand Selectors -- deeper selector patterns
Kick off independent fetches in parallel so total wait time is the slowest, not the sum.
// app/dashboard/page.tsx
interface User { name: string; }
interface Stats { totalSales: number; }
interface Activity { events: string[]; }
async function getUser(): Promise<User> {
return (await fetch("https://api.example.com/me")).json();
}
async function getStats(): Promise<Stats> {
return (await fetch("https://api.example.com/stats")).json();
}
async function getActivity(): Promise<Activity> {
return (await fetch("https://api.example.com/activity")).json();
}
export default async function Dashboard() {
const [user, stats, activity] = await Promise.all([
getUser(),
getStats(),
getActivity(),
]);
return (
<p>
{user.name} - ${stats.totalSales} - {activity.events.length} events
</p>
);
}awaits create a waterfall -- each request waits for the one before to resolve.Promise.all fires all requests at once; total time equals the slowest request.Promise.allSettled when one failure should not reject the whole set (e.g., optional sidebar data).Related: Data Fetching Performance -- waterfalls, parallelism, caching | Parallel Promises -- Promise.all, allSettled patterns
Always tear down subscriptions, timers, and listeners in the useEffect cleanup.
"use client";
import { useEffect, useState } from "react";
export default function LiveCounter() {
const [n, setN] = useState(0);
useEffect(() => {
const controller = new AbortController();
const id = setInterval(() => setN((x) => x + 1), 1000);
window.addEventListener("resize", () => console.log("resize"), {
signal: controller.signal,
});
return () => {
clearInterval(id);
controller.abort(); // removes the listener
};
}, []);
return <p>Ticks: {n}</p>;
}setInterval, setTimeout, addEventListener, and subscription must have a matching cleanup, or the browser holds onto the component forever.AbortController is the modern way to remove listeners -- one abort() tears down every listener registered with the signal.fetch calls with the same signal avoids "setState after unmount" warnings.Related: Memory Leaks -- detection, WeakMap patterns, long-lived refs | useEffect -- cleanup rules and timing
Cache expensive fetches across requests and invalidate them surgically when data changes.
// app/lib/data.ts - server-only helper
import { unstable_cache } from "next/cache";
interface Post {
id: number;
title: string;
}
export const getPosts = unstable_cache(
async (): Promise<Post[]> => {
const res = await fetch("https://api.example.com/posts");
return res.json();
},
["posts"],
{ tags: ["posts"], revalidate: 300 },
);
// app/posts/actions.ts
"use server";
import { revalidateTag } from "next/cache";
export async function createPost(title: string) {
await fetch("https://api.example.com/posts", {
method: "POST",
body: JSON.stringify({ title }),
});
revalidateTag("posts");
}unstable_cache(fn, keys, options) memoizes the result across requests -- subsequent callers get a cache hit.tags: ["posts"] lets you invalidate every cached entry with that tag in one call via revalidateTag("posts").revalidate: 300 adds a 5-minute time-based ceiling in case you forget to tag-invalidate somewhere.fetch already memoizes -- reserve unstable_cache for cross-request sharing.Related: Next.js Caching Deep Dive -- four-layer model, cache lifecycles | Caching (Next.js Data) -- fetch-level cache options | Revalidation -- revalidatePath vs revalidateTag vs ISR
Reviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥