This article is published in English.
What React Renders Actually Cost on the Main Thread
Render versus commit in React: why invisible render work still competes with input on the main thread, and what memoization really saves.
A React “render” does not, by itself, change what appears in the browser. A component that runs once and the same component that runs a hundred times can look identical to the person using the app. They look identical because the browser updates only when the commit phase writes to the DOM, and a render alone never promises that write. So why does so much React performance guidance centre on avoiding renders — useCallback (keep a function identity across renders), useMemo (keep a computed value across renders), React.memo (skip a child’s render when props are unchanged), and shrinking state updates?
That tension is real: it can feel like optimising something the user would never have noticed.
If a render never touches the browser, what is it actually spending?
Two Phases Hiding Inside One Render
People often say “render” for an entire update cycle. React splits that cycle into two phases with different jobs. One phase builds a description of the next UI. The other phase decides whether that description should become real DOM changes.
The same four triggers start both phases: a state update, a prop change, a parent re-render, or a context value change (a Context API value read without being threaded through props). What each phase does afterward — and what each costs — is where they diverge.
What happens inside the first phase, the one that always runs when React schedules an update?
Breaking Down The Render Phase
When React decides an update is needed, it invokes the component function again from the top. Every statement in that body runs: calculations, loops, object creation written inline.
Then React evaluates the JSX after return. JSX (<div>...</div> syntax) is not HTML and never reaches the browser by itself. At build time Babel or the TypeScript compiler turns it into function calls — historically React.createElement, more often the modern jsx() helper. Those calls are what construct the description.
The result is usually called the virtual DOM; React’s internal name is the element tree. It is an ordinary JavaScript object that describes the intended UI — not real HTML, not a live DOM node.
Consider a tiny component:
function Greeting({ name }) {
const message = `Hello, ${name}`;
return (
<div>
<h3>{message}</h3>
</div>
);
}
A change to name causes React to invoke Greeting once more. The template string for message runs again. Compiled JSX calls then produce an object tree similar to:
{
"type": "div",
"props": {
"children": {
"type": "h3",
"props": { "children": "Hello, Akshat" }
}
}
}
The object is Greeting's updated virtual DOM. Construction stayed in memory — no DOM mutation, paint, or layout. What consumes that tree next?
Breaking Down The Commit Phase
React compares the new element tree with the previous one — reconciliation, the diff that finds what actually changed. Only differences from that comparison are written to the real DOM. If nothing differs, nothing is written.
Return to Greeting and suppose name moves from one string to another. The previous tree held the old greeting text in an h3; the new tree holds the updated text. Structure stays the same — a div wrapping an h3 — so reconciliation records a single difference: that text node. The commit phase updates only that text; the rest of the tree is left alone.
This is the only phase that involves the real browser, which is also why it is the only phase that can force layout recalculation (recomputing geometry) or repaint. Change enough content and the browser must redo that work. Change nothing and it does not.
Here is the pivotal case for the rest of the argument. If name did not change on a re-trigger, the new tree matches the old one, reconciliation finds zero differences, and commit is a no-op. No DOM write, no layout, no paint — nothing the user’s eyes register. Yet the render phase — the function call, the recomputed message, the freshly allocated object tree — still ran completely a moment earlier.
If render can finish and leave no visible trace, what did that work cost?
The Render Phase Can Run And Still Change Nothing
This is where the debate becomes coherent. A render that produces an identical output still burns real resources: a real function call, real allocations for every node in the tree, real reconciliation work that walks the tree before concluding nothing changed. None of that appears on screen. All of it still happened.
That gap — genuine work that stays invisible — is why “renders do not matter” and “renders matter a lot” can both be right about different layers. Renders truly do not decide what the user sees; commit owns that. Renders do matter to something else that has nothing to do with pixels.
What is that something else?
The Main Thread Doesn’t Care Whether The Work Is Visible
That something is the JavaScript main thread: one shared queue that runs tasks one at a time. The same thread executes your JS, computes layout, and dispatches input events — clicks, scrolls, keystrokes.
Browsers target a new frame about every 16.7 milliseconds to hold 60 frames per second. Everything for a given frame — render-phase logic, reconciliation, commit DOM writes, layout, paint — must fit in that budget, and the render phase does not get a discounted lane just because its output might be discarded. It shares the exact queue the user feels through interaction.
Be precise: not every browser step for a frame lives on that thread. Rasterization (turning paint commands into pixels) and compositing (assembling layers into the final frame) often run elsewhere, which is why a pre-rasterized layer can keep scrolling while the main thread is busy. The render phase itself, and the event that triggered it, still sit on the main thread. Separate compositor threads explain some smoothness under load; they do not erase render-phase cost.
A single wasted render may cost a fraction of a millisecond. When does that become something a user feels?
Why The Render Phase Still Adds Up
Because it rarely runs once. When a parent re-renders, React runs the render phase for every child by default, even when that child’s props did not change, unless the child is wrapped in React.memo.
function Dashboard() {
const [searchTerm, setSearchTerm] = useState('');
const products = useProducts(); // 200 items
return (
<div>
<input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<ProductList products={products} />
</div>
);
}
function ProductList({ products }) {
return (
<div>
{products.map((product) => (
<ProductRow key={product.id} product={product} />
))}
</div>
);
}
Type one character into the search box and searchTerm updates. Dashboard running again is expected — its output changed. ProductList and, under it, all 200 ProductRow components also run again, not because their props changed (the keystroke is unrelated to product data) but because children re-render when parents do unless something stops the cascade.
Each of those 200 calls is still a real function execution, a real per-row element tree, and real reconciliation that concludes none of the rows need a DOM write. One keystroke is cheap. A live search field at normal typing speed fires that burst several times per second on the same thread that must handle the next keystroke.
This is the scenario useMemo, useCallback, and React.memo target — so what do they actually protect?
What useMemo, useCallback, And React.memo Actually Protect
React.memo wraps a component and skips its render phase when new props are shallow-equal to the previous ones (=== per prop). Wrap ProductRow and a Dashboard keystroke no longer forces 200 render calls; React compares props once per row and stops when the product reference is unchanged.
useMemo caches a calculation between renders so expensive work inside the component body reruns only when listed dependencies change. useCallback does the same for function identity. It usually exists less to save allocating a function and more to protect a memoized child: a new function each render is a new reference, and a new reference breaks React.memo’s shallow check on whatever receives that prop.
None of the three alter the commit phase. Commit was already gated by reconciliation finding a real difference. If nothing would have reached the screen anyway, these tools do not change whether a DOM write occurs. What they save is render-phase computation — main-thread time spent even when the output is identical.
They are not free either. React.memo’s comparison and useMemo’s cache lookup cost bookkeeping on every pass, so wrapping a cheap, infrequent component can be a net loss: paying overhead to guard work that was never expensive.
When does any of this matter to someone using the product?
Renders Cost The Thread The User Actually Feels
Nothing inside the render phase changes a pixel by itself — that half of the original tension was always true. A component that runs once or a hundred times can look the same because commit, not render, owns what reaches the screen.
Render is still not free because it is invisible. It is real work on the same single-threaded queue as layout, commit DOM writes, and every click, scroll, and keystroke. Keeping needless render-phase work off that queue — particularly when a parent update cascades through a deep child list, or when typing and scrolling fire updates rapidly — is how you reclaim the milliseconds an interaction needs.
People seldom say the quiet part. Fewer renders were never the end goal. The goal is leaving enough of a shared ~16.7 ms frame budget for the commit work and input handling the user notices.