React Performance — 3-Second Freeze from No Memoisation
A 20+ chart dashboard froze for 2-3 seconds on each keystroke because no memoisation.
20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.
- React re-renders children by default when a parent updates, even if their props didn't change
- React.memo stops re-renders by shallow-comparing props; useCallback and useMemo stabilise references for those comparisons
- Lazy loading with React.lazy + Suspense splits the bundle, reducing initial download size
- Virtualisation keeps DOM count constant for large lists, regardless of dataset size
- Biggest mistake: wrapping everything in useMemo/useCallback without profiling first — the overhead often exceeds the gain
Imagine a restaurant kitchen where every time one customer changes their order, the chef throws away every dish on the pass and starts cooking the entire menu again from scratch. That's what an un-optimised React app does — it re-renders every component even if nothing about it changed. React performance optimisation is the set of techniques that tell the chef: 'Table 4 only changed their dessert — leave the starters alone.' The goal isn't to make your code faster in theory; it's to stop React doing work it doesn't need to do.
React's declarative model is a gift — you describe what the UI should look like and React figures out how to get there. But that abstraction has a cost. In a large production app with hundreds of components, deeply nested state, and real-time data flowing in from WebSockets, React can easily end up doing tens of thousands of unnecessary renders per second. Users notice this as janky animations, sluggish inputs and frames dropping below 60fps. That's when 'React is fast enough' stops being true.
The root cause is almost always the same: developers treat re-renders as free. They're not. Every re-render means React has to call your component function, build a new virtual DOM tree, diff it against the previous one (reconciliation), and then commit any changes to the real DOM. Most of the time, the diff shows nothing changed — yet you still paid the cost of the function call and the tree construction. The optimisation techniques in this article all share a single goal: give React the information it needs to skip that work entirely.
By the end of this article you'll understand exactly how the React reconciler decides what to re-render and why, when to reach for React.memo, useMemo and useCallback (and critically, when NOT to), how to use code splitting and virtualisation to handle scale, and the production gotchas that bite even experienced engineers. Every example is pulled from patterns we've seen in real codebases.
Why React Re-Renders Are Not Free
React performance optimisation is the practice of preventing unnecessary re-renders that cause the UI to freeze or drop frames. The core mechanic: every time a component's state or props change, React re-runs the entire component function and reconciles the virtual DOM. Without memoisation, a parent re-render cascades to all children, even if their props haven't changed — this is O(n) per render where n is the subtree size, and for a deep tree of 500+ components, that can block the main thread for 3 seconds or more.
In practice, the key property is referential equality. React uses Object.is to compare props; if you pass a new object or inline function on every render, memoisation fails. useMemo and useCallback stabilise references, while React.memo skips re-rendering when props are shallow-equal. The real cost isn't the re-render itself — it's the layout thrashing and DOM diffing that follows, especially in lists, charts, or form-heavy UIs.
Use memoisation when a component renders often (e.g., on every keystroke in a search input) or when its subtree is expensive (e.g., a data grid with 1000 rows). In production, the most common mistake is over-memoising: wrapping everything in useMemo adds memory overhead and comparison cost. The rule: measure first with React DevTools profiler, then memoise only the hot paths that show up as red bars.
How the React reconciler actually decides what to re-render
Before you optimise anything, you need a mental model of what React is actually doing. When state or props change, React re-renders the component that owns that state — and by default, every child of that component re-renders too, regardless of whether their own props changed. This is called a cascading re-render and it's the single biggest source of preventable work in a React app.
React's reconciler (the Fiber architecture since React 16) works in two phases. The render phase is pure and interruptible — React calls your component functions and builds a Fiber tree representing the new UI. The commit phase is synchronous and side-effectful — React applies DOM mutations, runs layout effects, then passive effects. Only DOM nodes that actually changed get touched in the commit phase. But you still paid the full render phase cost for every component in the subtree.
The key insight is this: React uses referential equality (===) to decide whether props changed. A plain object literal {} created inside a parent component is a new reference every render, even if its contents are identical. That's why memoisation isn't just about expensive calculations — it's primarily about reference stability. Unstable references are the root cause of most unnecessary re-renders.
React.memo, useMemo and useCallback — what each one actually does
These three APIs are frequently used interchangeably by developers who've half-read the docs. They solve different problems and conflating them leads to both under-optimised and over-engineered code.
React.memo is a higher-order component that wraps a component and tells React: 'Only re-render this component if its props have changed.' It does a shallow equality check on the props object. If every prop passes ===, React reuses the last rendered output entirely — it doesn't even call your component function.
useCallback memoises a function reference. It returns the same function object across renders as long as its dependency array hasn't changed. Its primary job is to create stable references to pass as props to memoised children — without it, React.memo is nearly useless because a new function reference counts as a changed prop.
useMemo memoises the return value of a computation. Use it for expensive calculations that shouldn't run on every render, or to stabilise object and array references that get passed as props. The dependency array works identically to useCallback — the memoised value is only recomputed when a dependency changes.
The rule of thumb: if you're passing a callback to a memoised child, use useCallback. If you're passing a derived object or doing expensive maths, use useMemo. Don't use either just because you can.
Code splitting with React.lazy, Suspense and dynamic imports
Memoisation fights unnecessary re-renders. Code splitting fights the other performance killer: loading too much JavaScript upfront. In a typical React SPA, bundlers like Webpack or Vite produce a single JavaScript bundle. A user visiting your landing page downloads code for your admin dashboard, your analytics charts, and every other route — most of which they'll never touch. This bloats the initial bundle, delays Time to Interactive, and kills Lighthouse scores.
Code splitting lets you split your bundle into smaller chunks that load on demand. React.lazy and Suspense are the built-in APIs for this. React.lazy takes a function that calls a dynamic import() — a browser-native API that returns a Promise resolving to a module. React defers rendering that component until the module has loaded, and Suspense defines what to show in the meantime.
The sweet spot for lazy loading is route-level splitting — each page becomes its own chunk. But it's also valuable for heavy third-party components like rich text editors, PDF viewers, or chart libraries that aren't needed on initial load. The rule: if a user's critical path doesn't need it within the first three seconds, consider lazy loading it.
A critical gotcha: React.lazy only works with default exports. Named exports require a small wrapper. Also, always wrap lazy components high enough in the tree that the Suspense fallback doesn't cause layout shift.
Virtualisation for long lists — only render what the user can see
No amount of memoisation saves you if you're trying to render 10,000 DOM nodes at once. The browser has to create, style, and layout each one. A list with 5,000 items might take 2–3 seconds just to mount — and that's before any interaction.
Virtualisation (also called windowing) solves this by only rendering the DOM nodes currently visible in the viewport, plus a small overscan buffer above and below. As the user scrolls, nodes are recycled — elements that scroll off the top are repositioned and reused for content coming in from the bottom. The DOM stays small (typically 20–50 nodes) regardless of how many items are in the data set.
@tanstack/react-virtual is the modern, framework-agnostic choice. It's headless — it gives you the calculations, you control the markup. react-window and react-virtualized are older but still widely used in production and worth knowing for legacy codebases.
Critical consideration: virtualisation breaks native browser 'find in page' (Ctrl+F) because non-rendered items aren't in the DOM. For accessibility and search-critical content, consider server-side pagination instead. Also, fixed-height rows are significantly simpler to implement than variable-height rows — @tanstack/react-virtual supports both, but dynamic measurement has its own complexity.
Profiling React Performance: The One Tool You Must Know
All optimisation without profiling is guesswork. The React DevTools Profiler is your single source of truth. It records each render commit, shows which components re-rendered and why, and gives you the flamegraph of where time is spent. Without it, you're flying blind.
To use it: record an interaction (e.g., typing in a search input, clicking a button). The profiler shows each commit as a bar at the top. Click a commit to see a flamegraph. Components that re-rendered are highlighted; their color intensity corresponds to time spent. Hover over a component to see 'why did this render?' — often it's 'Parent re-rendered' or 'Props changed' with the specific prop.
Also enable the 'Highlight updates when components render' option in the React DevTools settings. It overlays colored borders on components that re-render. If you see a border flashing on a component that shouldn't re-render, you've found the leak.
A production-safe pattern: add a debug-only render counter using React.useRef in components. Log out render counts to the console in development. This catches cascading re-renders early without relying on the profiler every time.
When NOT to optimise: the hidden cost of premature optimisation
The React team's own documentation warns against premature memoisation. Here's why: every useMemo and useCallback call allocates memory for a cache entry, runs dependency comparison on every render, and increases the cognitive load of the code. For a simple calculation (e.g., const fullName = ${first} ${last}``), wrapping it in useMemo adds more overhead than the calculation itself.
Similarly, React.memo on a component that re-renders frequently with actually different props is wasted — the comparison runs every render and never skips the re-render anyway. It's pure overhead.
The rule: measure before optimising. Use the profiler to find components that re-render more than expected or take >1ms in the render phase. Apply memoisation only to those. Also, consider restructuring your component tree before reaching for optimisation APIs. Lifting state up or pushing it down often solves the problem without adding any memo calls.
Finally, remember that React 19's compiler (React Forget) aims to auto-memoise components. But that doesn't mean you can ignore these fundamentals today. The compiler understands reference stability and dependencies; you'll still need to write clean component trees.
How to Measure First (Before You Touch a Line of Code)
You wouldn't fix a car engine by staring at it and guessing. Same goes for React. Before you memo anything, before you reach for useCallback, you need hard data. Without measurements, you're optimising ghosts. The React DevTools Profiler is your first stop. Fire it up, record a session, and actually look for long render times or components that re-render when nothing changed. Chrome's Performance tab gives you the bigger picture — long tasks, layout thrashing, jank. A 2ms render is not your problem. A 200ms layout recalculation is. Write a quick render counter to confirm your hunches. See which components are actually expensive. The golden rule: measure once, optimise the bottleneck, measure again. If you can't measure improvement, you didn't fix anything — you just added complexity.
Optimize Context Usage (Or Watch Your Entire App Re-Render)
React Context is not a state management library, no matter how many tutorials say otherwise. It's a dependency injection tool with a nasty side effect: when the context value changes, every single consumer re-renders. Not the ones that use the changed piece of state — all of them. If you put a frequently-updated value like a timer or a mouse position into a single context, your whole tree re-renders on every tick. The fix is brutal but effective: split your contexts by responsibility. One context for auth data, one for theme, one for UI state. For values that change multiple times per second, skip context entirely — use a ref or a lightweight external store like Zustand. Also, memoize your context value object. If you pass a new object reference every render, you're causing re-renders even when the data didn't change. Your consumers don't care about your object identity crisis.
Debounce Rapid Updates (Stop Chasing Every Keystroke)
Real-time search, slider drags, and scroll handlers fire updates faster than your UI can handle. Each update triggers a re-render, potentially a network request, and maybe a full reconciliation. The result: a janky mess. The fix is throttling your input. Debouncing is ideal for inputs where you want the final value — wait until the user stops typing for 300ms, then fire the update. For animations or scrolls, use requestAnimationFrame or a throttle to drop intermediate updates. Don't debounce inside the component that's consuming the state; debounce the handler that's producing it. Otherwise you're still rendering the intermediate values. Libraries like lodash.debounce or use-debounce are fine, but a 5-line custom hook with setTimeout does the same thing without the dependency.
Web Workers: Offload Heavy Math So Your UI Doesn't Choke
React's single-threaded nature means one expensive computation freezes every tab, every animation, every button click. You've felt it — a complex filter, a data transform, a chart calculation that steals the frame budget. Don't optimize React logic; move the work off the thread entirely.
Web Workers run JavaScript in a separate OS-level thread. They have zero access to the DOM, zero access to React state, and that's exactly the point. The main thread stays at 60fps while your worker crunches numbers. You communicate via postMessage — fire an input in, get results back asynchronously.
Production teams use workers for CSV parsing, image processing, financial calculations, anything above ~10ms synchronous work. The cost: serialization overhead when passing data. Keep payloads small, transfer ArrayBuffers by reference when possible. Don't reach for a library — the native Worker API is tiny and battle-tested.
Optimizing Images: The Asset That Silently Kills Your Lighthouse Score
Images account for 70%+ of page weight on most React apps. Your users aren't waiting for JSON — they're waiting for that 3MB hero image you downscaled with CSS. CSS width: 300px doesn't shrink the bytes; the browser still decodes the full resolution. This is the single cheapest win in performance you'll ever get.
Prefer next-gen formats: WebP covers 96% of browsers, AVIF covers ~85% with better compression. Use the <picture> element with multiple sources — let the browser pick the smallest supported format. Apply responsive images with srcSet: serve 400w, 800w, 1200w versions and let the viewport decide. This alone drops image weight 40-70%.
Lazy-load below-the-fold images with loading="lazy" — it's native, zero dependencies, works in all modern browsers. Give explicit width + height to prevent layout shift (CLS). For icons, inline SVGs avoid an HTTP request altogether. If you use a CDN (Cloudinary, Imgix, or a custom thumbnailer), append transformation parameters in the URL instead of resizing in React.
squoosh-cli in CI. One command converts everything to WebP AVIF with lossy settings. Never commit raw JPEGs again.SSR: When Your Spinny Spinner Needs 2s to Disappear
Client-side rendering means the user stares at a blank or loading state while your bundle downloads, parses, and runs. For content-heavy apps, that first paint can take 3-5 seconds on slow networks. Server-side rendering flips the script: send HTML that's already rendered. The user sees real content on the first paint.
React's renderToString turns your component tree into a string of HTML on the server. The browser paints it immediately. Then the JS bundle hydrates — attaches event listeners, makes the page interactive. The catch: the server has to do work per request, which costs CPU and increases Time to First Byte (TTFB).
Static generation (SSG) solves this for pages that don't change per user — build once, serve from CDN. Next.js and Remix handle this natively. For dynamic content (user dashboards), SSR is worth it if your content-to-code ratio is high. If 90% of your page is interactive UI, SSR adds complexity for little gain. Measure the LCP improvement before you refactor.
suppressHydrationWarning sparingly.The Dashboard That Froze for 3 Seconds on Every Keystroke
- Always profile before optimising — React DevTools Profiler shows exactly which components re-render and why.
- Stabilise references of props passed to memoised children: useMemo for objects/arrays, useCallback for functions.
- Move state as close as possible to where it's used to limit re-render scope.
React DevTools Profiler -> Record -> Trigger the interaction -> Inspect 'Commits' > 'What caused this update'Add console.log('%c[RENDER] ComponentName', 'color:blue') in every component to see which ones re-render on each action.Key takeaways
import() reduces initial bundle size at the route or feature levelCommon mistakes to avoid
5 patternsDefining objects or arrays inline inside JSX props on a memoised component
Using useCallback with a stale closure by omitting state variables from the dependency array
Wrapping everything in useMemo and useCallback 'just in case'
Forgetting to add a key or using index as key when rendering lists
Lazy loading components that are needed above the fold
Interview Questions on This Topic
If React.memo does a shallow comparison of props, how would you handle a component that receives a deeply nested object as a prop where only a nested field changes — and why is the standard advice 'flatten your state' rather than 'use a custom comparator'?
name, email, role as separate props. That way, React.memo compares each primitive directly, and only the display component for the changed field re-renders.
In practice, if flattening isn't feasible, consider using useMemo on the parent to return a stable reference to the deeply nested object, and only update that reference when the relevant nested field changes. But that often requires manual dependency tracking and is error-prone. The cleaner path is refactoring the component hierarchy.Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.
That's React.js. Mark it forged?
12 min read · try the examples if you haven't