React Performance: Where the Time Actually Goes

When a React app feels sluggish, the usual suspects — reconciliation, the virtual DOM, diffing — are rarely the real problem. Ivan Akulov, a performance consultant who has audited dozens of large React codebases, says the biggest wins come from understanding what the browser is actually doing at each stage of a screen update. The work happens in layers, and most teams start optimizing at the wrong one.

The Three Layers of a Render

React's job is to determine what the UI should look like. But turning that into pixels on screen is the browser's job, and it happens in three distinct phases. Akulov breaks them down like this:

  1. React calculation. React works through your component tree and figures out which parts of the UI need to change.
  2. DOM mutation. React applies those changes to the actual DOM nodes.
  3. Browser rendering. The browser takes the new DOM and does style calculation, layout, and paints the pixels you see.

Each layer feeds the next. If React does a lot of work, the DOM changes will be big, and the browser will have to do heavy lifting. But even a tiny React change can force the browser into expensive layout and paint work if the change touches a sensitive part of the page. Fixing performance means figuring out which of these three layers is actually the bottleneck, then addressing it directly.

Layer One: React's Own Component Work

React's calculation phase is the one most people try to optimize first, and the React.memo API is the standard tool. But memoization only helps when you also control how props are passed.

Defining a function or object inline — like onClick={() => ...} — creates a new reference on every render. A memoized child component compares props with a shallow equality check, sees a new function reference, assumes something changed, and re-renders anyway. Memoization also costs something: comparing props is not free, so on small, cheap-to-render trees it can actually slow things down rather than speed them up.

Memoization works, but only under the right conditions: you have a big component tree, props are changing frequently, and you've stabilized the references to props that change often. Akulov calls this a "nano-optimization" in many cases — useful, but rarely the source of severe jank.

Layer Two: DOM Mutations and Layout Thrash

The bigger problems usually live in the browser's rendering pipeline. Even a single DOM change can trigger style recalculation and layout, and these operations can cascade across the entire page.

One scenario Akulov highlights is animating CSS properties that sit high in the layout hierarchy. If a component at the top of your body changes its width or height, the browser has to re-layout the entire document underneath it. He also points to "layout thrash," where JavaScript reads a layout property and then writes a DOM change in a loop. Each read forces the browser to flush its layout queue, then the write invalidates it, and the cycle repeats — usually many times per second.

<code>getBoundingClientRect()</code>, offsetHeight and similar reads force synchronous layout when the DOM is dirty. Batching these reads and writes separately is a well-established fix; combining a React-specific solution like useLayoutEffect strategically can help, but the safest approach is to never trigger layout reads right after a DOM write that changes layout.

Blocked Placeholder Reference

Smashing Editorial

Layer Three: The Browser's Rendering Pipeline

Beyond layout, the browser must also draw pixels. Two related ideas are key.

The classic mistake is mutating the DOM and letting the browser cascade style and layout from scratch every time. Batch your DOM changes in a single frame if you can — requestAnimationFrame is one way to ensure you're not splitting work across multiple frame cycles.

The Right Measurement Mindset

Akulov emphasizes that console.time is your friend here, but only for isolating phases. To measure which layer — React or browser — is the bottleneck, you can:

  1. Time a full render loop with and without your React component tree mounted.
  2. Check whether the expensive work is in React's own execution (measured in the top-level API calls) or in the browser developer tool's rendering timeline.

The goal is not to pre-optimize. Akulov recommends a reactive approach: write clean, straightforward component code first, verify it against real performance budgets, and only then reach for memo, useMemo, or exotic layout tricks when your profiler shows a concrete culprit. Optimization within the wrong layer is wasted effort; the fastest way to speed up a React app is to first prove exactly which layer is holding you back.

Profiling React Apps: Where the Time Goes

When Ivan Akulov, a Google developer expert and performance consultant, is asked why a React app is slow, his first move is always the same: reproduce the problem locally. From there, he records two traces — a Chrome DevTools performance profile and a React DevTools performance profile. The React DevTools profiler tab is the primary lens for understanding render behavior: how often the app renders, which components take the most time within each render, and where the bottlenecks live.

Akulov says the common performance problems he encounters tend to fall into two buckets. The first is a single component doing something expensive during render. He cites a client running a static site rendered through React that fetched markdown from a server and parsed it into HTML on the client — a large article could take a few hundred milliseconds just for that conversion. The second is cascading renders, where one user action schedules several renders in sequence. Both are what he calls low-hanging fruit when diagnosing a slow app.

Lists, Virtualization, and the Browser's Own Answer

For apps that render large lists — say, a table with thousands of rows — the standard fix is virtualization. Libraries like react-virtualized use the Intersection Observer API to detect which items are on-screen and only render those into the DOM. That gives you a smaller DOM tree, cheaper layout calculations, and a smaller React component tree for reconciliation. With a list of 1,000 items, only the 10 or so visible rows exist at any moment.

There's also a newer, library-free CSS approach: the content-visibility property, shipped in Chrome around a year ago, along with the related intrinsic sizing properties. The browser skips rendering off-screen content entirely. This cuts browser rendering costs significantly, though it doesn't help React itself — React still reconciles the full tree of a thousand components. If the expensive part is browser layout and paint rather than React's render phase, content-visibility is a viable option.

One caveat applies to both virtualization and content-visibility: they're hard to use when list items have dynamic height or width. If the browser can't know an element's size ahead of time, the scrollbar will jump around as items render and the page height adjusts.

Loading Performance vs. Runtime Performance

Whether to optimize for load time or runtime speed depends entirely on what you're building. For a content site where SEO ranking and ad cost matter, loading performance dominates — that's what search ranking and ad pricing are based on. For a complex single-page app like a graphics editor, runtime performance is more important because it directly shapes user satisfaction. Users may accept a slow initial load for an app they'll use for a while — as with a heavyweight desktop tool — but they won't stick around if interactions feel sluggish.

Akulov's view is that measuring loading performance is far easier and more uniform across applications: whatever the stack, page loads follow similar patterns. Runtime performance is the harder problem. "The specifics of the slowdowns and specific optimizing challenges are super different with every app," he says. "With every app, they're different with every single app." That's what his Smashing workshop, the React Performance Masterclass, aims to address: giving developers a methodology they can apply to their own specific bottlenecks.

If the Whole App Renders, Rethink the Approach

When you type into a text field and the letters lag behind your keystrokes, the usual cause is too much JavaScript running in that event handler. A common pattern is a controlled input that saves every keystroke to a Redux store, invalidating a large slice of the store and re-rendering the entire app. Other times, several renders are scheduled back-to-back, or a third-party library recomputes something expensive. There's rarely a single silver-bullet answer for input latency.

React's long-promised concurrent mode aims to fix some of this by letting you prioritize urgent on-screen work over background updates. But Akulov notes it's not stable enough yet to teach or apply. Until it lands, the practical approaches are throttling and debouncing expensive computations, moving heavy work to web workers, or — if the cost is style and layout recalculation — optimizing those too. He also suggests a more radical option: if one isolated component is performance-critical and can't be made fast within React, "making it work without React at all" — direct DOM manipulation in plain JavaScript. React gives excellent maintainability, but it also makes it easy to accidentally render the whole app or create a frequently-rendering component doing expensive work. Plain JavaScript is far less prone to those accidents.

The Case for Shipping Less React

Akulov points to his own agency site, 3perf.com, as a case study. He built it in Gatsby, which rendered fast but scored poorly on Lighthouse because of time-to-interactive. The site was loading a huge JavaScript bundle that took too long to execute — after the page itself was already visible. His fix was drastic: he cut nearly all the client-side JavaScript and replaced the few interactive elements with inline scripts, partly with a Gatsby plugin. The Lighthouse score jumped from the 60s to the 90s from that single change.

"The top tip for the React, where you should get rid of React," he says. He agrees with the position of Andrey Sitnik, a Russian front-end engineer who frequently argues that static sites don't need client-side React at all. React is convenient for development, but that doesn't mean it must be shipped to the user. "You could use React on the server — use it as a template engine, basically — but don't serve it to the client." For loading performance, that's one of the most impactful decisions you can make.

A "Pit of Despair" for the Modern Stack

Choosing React for raw speed is rarely the reason developers make the switch — it's chosen for maintainability, developer experience, and ecosystem size. But that convenience introduces what Akulov calls a "pit of despair" (borrowing a term from a Jeff Atwood essay on programming language design): making an app slow becomes too easy. Installing one NPM dependency can silently add 100KB to your bundle, and creating a component that re-renders too often is trivial. You have to actively prevent yourself from falling into these traps.

One debugging aid that disappeared in React 17 was the user timing API integration. Previous React versions would mark the start and end of each component render and lifecycle hook in a Chrome DevTools performance trace, making it easy to match a specific layout thrash to a specific component instance. React 17 removed this due to maintenance burden. Now the only option is the React DevTools profiler, which shows you that a component takes 300ms to render but not why. The React profiler has its own React 16-era feature — a powerful built-in perf object — that was also removed.

React Server Components and the Hydration Problem

Akulov is most excited about the recently announced React server components. The reason connects to what he calls the single most expensive phase of page load for a server-side-rendered app: hydration. After the HTML arrives, the browser downloads React, reconstructs the virtual DOM, and reattaches the relationship between the virtual and actual DOM nodes. That process can take hundreds of milliseconds — or a full second on slower devices — during which the page doesn't respond to input.

Today, partial hydration is possible but painful, usually requiring custom code to bail out subtrees from rehydration. Server components are designed to make this mainstream. Returning to the markdown-parsing static site: convert that markdown-parsing component into a server component, do the conversion on the server, and serve the resulting HTML. You've just saved those few hundred milliseconds of client-side work, plus the cost of hydrating that component.