Here’s a question I sat with for longer than I should have: if React’s Virtual DOM is supposed to make things faster, why does a complex React app sometimes feel slower than a simple jQuery one?
The answer is that the Virtual DOM was never really about raw speed. It was about something more valuable — and understanding what that actually is changes how you think about React, why Signals exist, and what the future of UI frameworks might look like.
Let’s start from the beginning.
The Problem Before React
Before React, building a dynamic UI meant writing imperative code. You found a DOM node, changed it, found another, changed that. Something like:
document.getElementById('username').textContent = user.namedocument.getElementById('avatar').src = user.avatardocument.getElementById('bio').textContent = user.bioFor a small app, this is fine. As the app grows, you’re manually tracking which DOM nodes need updating for every possible state transition.
You miss one — the UI goes stale.
Two developers touch the same node — inconsistent state.
The business logic and the DOM update logic become inseparable spaghetti.
The actual problem wasn’t performance. It was correctness and maintainability at scale.
React’s proposal in 2013 was radical for the time:
what if you just re-described the entire UI whenever anything changed, and let the framework figure out what to actually update? The Virtual DOM was the mechanism that made “re-describe everything” cheap enough to do on every state change.
The honest framing: VDOM was sold as a performance innovation. It’s more accurately a developer experience innovation with acceptable performance characteristics. The distinction matters when you’re evaluating alternatives like Signals.
What the Virtual DOM Actually Is
The Virtual DOM is a plain JavaScript object tree that mirrors the structure of your UI.
When you write JSX:
<div className="card"> <h1>{title}</h1> <p>{description}</p></div>React doesn’t immediately touch the browser’s DOM. It creates a lightweight JS object:
{ type: 'div', props: { className: 'card' }, children: [ { type: 'h1', props: {}, children: [title] }, { type: 'p', props: {}, children: [description] } ]}VDOM is a lightweight in-memory representation of the DOM
Creating and comparing these objects is cheap — it’s just JavaScript. Touching the real DOM is expensive — it triggers layout calculations, style recalculations, and repaints.
When state changes, React produces a new VDOM tree, compares it against the previous one, and applies only the differences to the real DOM. This comparison process is called reconciliation.
Old way: data changes → you manually find and update the right DOM nodesReact: data changes → re-render entire component (cheap, it's just JS objects) → diff new VDOM against old VDOM → apply only the diff to the real DOMNot OC
How Reconciliation Works — and Why It’s O(n) Not O(n³)
Comparing two arbitrary trees is an O(n³) problem in computer science. For a tree with 1000 nodes, that’s a billion operations. Obviously not viable.
React reduces this to O(n) with two deliberate assumptions:
Assumption 1: Different component types produce different trees.
If a <div> changes to a <span>, React doesn't attempt to reconcile the children. It tears the entire subtree down and builds fresh. React assumes a type change signals a fundamentally different structure — comparing children would be wasted work.
This lets React skip entire branches of the tree without inspecting them.
Assumption 2: The key prop identifies stable elements across renders.
Without keys, React compares list items by position — index 0 to index 0, index 1 to index 1. With keys, React tracks item identity across renders regardless of where in the list that item appears.
This collapses list reconciliation from “try every possible mapping” to “match by identity.”
The key Trap — Why Index Breaks Things
Indexes are stable when the array itself is stable. The problem only appears when the array mutates.
Three cases that break silently:
Reordering: A user sorts the list. The item that was at index 0 is now at index 3. React sees index 0 still exists and assumes it’s the same component instance. Any local state in that component — an expanded accordion, a checked checkbox, a half-typed input — belongs to the position, not the item. It stays at index 0. Your newly sorted item inherits the wrong state.
Inserting at the front or middle: You prepend an item. Everything shifts down by one index. React diffs index 0 changed, index 1 changed, index 2 changed — and patches every node even though only one new item was added.
Deleting from anywhere except the end: Same problem mirrored.
// Breaks silently when list mutates{items.map((item, index) => ( <TodoItem key={index} text={item.text} />))}// Correct — key travels with the data, not the position{items.map((item) => ( <TodoItem key={item.id} text={item.text} />))}The dangerous part isn’t that it throws an error — it doesn’t. It produces subtly wrong UI that’s hard to reproduce and harder to trace.
Re-renders — The JS Tax React Doesn’t Talk About Enough
This is the distinction that confuses most React developers, including experienced ones.
Reconciliation operates at two levels:
The Render phase (JavaScript): When a component’s state changes, React calls that component’s function. If the function returns child components, React calls those too — recursively, all the way down the tree. It’s re-executing JavaScript to produce a new VDOM.
The Commit phase (DOM): React compares the new VDOM against the old one. Only nodes that actually changed get DOM updates.
The commit phase is efficient. The render phase — by default — is not:
Parent state changes→ Parent function re-executes ✓ (expected)→ Child A function re-executes (even if it doesn't use the changed state)→ Child B function re-executes (even if it doesn't use the changed state)→ New VDOM produced for entire subtree→ Diff finds nothing changed in Child A or B→ DOM untouched for Child A and B ✓The DOM is safe. But you ran JavaScript for components that had no reason to re-render. On a dashboard with 500 components, this adds up.
Let me drive this point home.
Does reconciliation skip children of changed nodes?
No — and this is the part that trips everyone up.
When a DOM node’s attribute changes (a className, a style, a text value), React updates that node and leaves its children alone if they haven’t changed.
But when a component re-renders, its children re-execute their JS regardless — because React has to call their functions to produce new VDOM to diff against. The DOM children might not change, but the JS ran anyway.
So there are two separate questions:
Did the JS re-execute? → Yes, for the whole subtree by defaultDid the DOM get updated? → Only where the diff found actual differences1 Tree or multiple Trees ?
In React, reconciliation uses two trees at the same time via a technique called double buffering.
React does not mutate a single virtual tree in place, nor does it maintain an arbitrary collection of trees. Under React’s Fiber architecture, it maintains exactly two Fiber trees:
- 1.
currenttree: The Fiber tree currently rendered and visible in the real DOM. - 2.
workInProgresstree: The Fiber tree React constructs and reconciles in memory during the render phase when a state update occurs.
How Double Buffering Works
Render / Reconciliation Phase: When state changes, React clones or creates nodes from the
currenttree into a newworkInProgresstree. React computes the diffs, flags changes (like insertions, deletions, updates), and prepares the side effects in memory without touching the real DOM. This work can be paused, aborted, or restarted.Commit Phase: Once the
workInProgresstree is fully calculated, React flushes all changes to the actual DOM in a single synchronous pass.Pointer Swap: After the commit finishes, React simply swaps its root pointer: the
workInProgresstree becomes the newcurrenttree, and the old tree is repurposed for future updates to avoid excessive garbage collection.
Why Not Just 1 Tree?
If React mutated a single in-place tree:
It couldn’t pause, split, or abandon rendering work (a core requirement for Concurrent React and transitions) without leaving the UI in a broken, half-updated state.
It would risk rendering tearing (inconsistent UI states read during rendering).
Is VDOM costlier than direct DOM?
Think about the cost. Maintaining multiple VDOMs, running entire subtrees, comparing between vdoms, comparing between the final vdom and the dom and then painting as opposed to 1 direct
If you know exactly which DOM node to change, element.textContent = 'x' is faster than React's full cycle.
But that’s not the comparison that matters. The real comparison is:
VDOM: re-render whole component → diff → minimal DOM updateManual DOM: you track every possible state transition and write individual update logic for each oneAt small scale, manual DOM is faster. At application scale, manual DOM becomes unmaintainable and bug-prone. You start missing updates, creating inconsistent state, writing spaghetti imperative code.
VDOM’s cost is the price of not having to think about which DOM nodes to update. You just describe the UI, React figures out the rest.
Bottom Line: VDOM is a deliberate performance tradeoff that buys you developer productivity and correctness. It’s not free.
memo, useMemo, useCallback — What They Actually Do
Three tools exist to stop unnecessary re-renders. Most developers use them loosely or “just in case.” Here’s what each actually does at the reconciler level.
memo wraps a component. Before React calls the component function, it checks: have any props changed since last render? If no — skip the function call entirely. No new VDOM produced for that subtree.
const Child = memo(({ name }) => { return <div>{name}</div>})This is the only one that actually prevents a re-render. The other two are supporting tools.
useMemo caches a computed value between renders. The component still re-renders — useMemo just skips recomputing an expensive calculation if its dependencies haven't changed.
const sorted = useMemo(() => { return bigArray.sort(complexComparator)}, [bigArray])useCallback caches a function reference between renders. On its own it does almost nothing useful. Its only real job is to support memo.
const handleClick = useCallback(() => { doSomething(id)}, [id])The mental model:
memo → the goal (prevent re-render)useMemo → stabilises values so memo's comparison succeedsuseCallback → stabilises functions so memo's comparison succeedsThe One Condition That Silently Makes All Three Useless
memo compares props with Object.is() — essentially ===. Primitives compare by value. Objects, arrays, and functions compare by reference.
A new reference equals a changed prop, as far as memo is concerned — even if the contents are identical.
// This memo never worksconst Child = memo(({ config }) => <div>{config.title}</div>)function Parent() { // New object literal on every render = new reference = memo always fails return <Child config={{ title: 'hello' }} />}Every render of Parent creates a new config object. memo sees a new reference, assumes the prop changed, re-renders Child. You wrote memo and got nothing.
Same trap with functions:
// New function reference every render = memo failsreturn <Child onClick={() => doSomething()} />// Fix — stabilise the reference with useCallbackconst handleClick = useCallback(() => doSomething(), [])return <Child onClick={handleClick} />This reference equality problem is what makes memoization fragile. Miss one unstable prop and the whole thing fails silently. Which is exactly what React Compiler is designed to eliminate — it statically analyses your components at build time and injects stable references automatically.
If you’re on Next.js 16 with React Compiler enabled, you mostly write plain functions and objects and the compiler handles reference stability. “Mostly” is load-bearing: the compiler bails out on components that break the Rules of React, and it bails out on manual memoization that doesn’t match what it inferred — which means leftover useMemocan cost you the optimization it was meant to provide.
Fiber — When React Stopped Blocking the Browser
Before React 16, the reconciler had a fundamental architectural problem: once it started diffing a component tree, it couldn’t stop. It ran synchronously to completion, blocking the main thread the entire time.
On a large tree, this diff could take 50–100ms. During that time, the browser couldn’t repaint, handle input events, or run animations. This is why complex React apps felt janky — every state update was a potential freeze.
React Fiber rewrote the reconciler to make work interruptible.
The core idea: instead of one big synchronous call stack, React breaks reconciliation into small units — one per component — called fibers. Between each unit, React checks: does the browser need to do something more urgent?
Old reconciler: [======= blocks everything for 100ms =======]Fiber: [=]check[=]check[=]check[=]check — yields to browser between unitsIf urgent work arrives mid-reconciliation, Fiber pauses, handles the urgent work, and resumes. If new state arrives that makes the current render irrelevant, Fiber throws the in-progress work away and starts fresh with the latest state.
This is called concurrent rendering — multiple renders can be in-flight simultaneously, but only the latest one commits to the DOM.
Fiber is the foundation everything modern in React is built on. Suspense, streaming, startTransition — none of it is possible without an interruptible reconciler.
startTransition — Telling React What's Urgent
Fiber gave React the ability to prioritise work. startTransition is how you expose that priority system to your own code.
import { startTransition } from 'react'// Without startTransition — both updates block the inputsetInputValue(input)setSearchResults(input) // expensive re-render blocks typing// With startTransition — input stays responsivesetInputValue(input)startTransition(() => { setSearchResults(input) // React can interrupt and restart this})Without it, React treats every setState as equally urgent. A slow re-render triggered by typing blocks the input from updating — the input feels laggy even though the data is simple.
With it, React prioritises the input update. The expensive search results render happens when the main thread is free, or gets discarded and restarted if the user types again before it finishes.
You won’t reach for this often. The signal is specific: an input or interaction feels laggy because a state update is triggering an expensive re-render. That’s the exact problem startTransition solves and nothing else.
Hydration Mismatch — What Actually Happens at the Reconciler Level
We covered hydration from the rendering side in the previous blog. Here’s what happens internally.
On the server, React renders your components and produces HTML/RSC. The VDOM is a transient intermediate step — it’s used to generate the HTML string and then discarded.
On the browser, React needs to hydrate. It runs your component functions again to reconstruct the expected VDOM in memory. Then it walks the existing server-rendered DOM simultaneously, comparing node by node. If they match, React attaches event listeners in place — no new DOM nodes created, the server’s HTML is reused.
Server: components → VDOM (transient) → HTML → sent to browserBrowser: components → VDOM (fresh reconstruction) VDOM vs existing DOM → attach listeners (reuse server HTML if match)Your components run twice — once on the server, once on the browser during hydration. They should produce identical output. When they don’t:
In development: React warns loudly, tells you which node mismatched, and tries to continue.
In production: React silently discards the server HTML for that entire subtree and re-renders it from scratch on the client. No error shown. You just lost your SSR benefit for that section — slower paint, potential layout flash as the server HTML gets replaced.
The layout flash is the user-visible symptom. The server painted one thing, React replaces it with something slightly different, there’s a flicker. Most common with timestamps, theme values read from localStorage, or anything that differs between server environment and browser environment.
Common causes:
new Date()orMath.random()in a Server Component — server and browser run at different timestypeof window !== 'undefined'checks during render — server takes one branch, browser takes anotherBrowser extensions mutating the DOM before React hydrates
Invalid HTML nesting that the browser silently auto-corrects before React sees it
The fix is ensuring server and client render identical output. Browser-only logic belongs in useEffect, not in the render function.
Signals — A Completely Different Model
Everything we’ve covered so far is React’s world: describe UI as a function of state, produce VDOM, diff, commit. Signals throw that model out entirely.
In React: state changes → re-render component → new VDOM → diff → DOM update
With Signals: state changes → directly update the exact DOM node bound to that value → done
There’s no VDOM, no diffing, no reconciliation. A Signal is a value wrapper that tracks which DOM nodes are subscribed to it. When the value changes, it notifies exactly those nodes — nothing else in the tree is involved.
// React — entire component re-runs on every clickfunction Counter() { const [count, setCount] = useState(0) return <button onClick={() => setCount(c => c + 1)}>{count}</button>}// Preact Signals — component runs once on mount, only the text node updates on clickconst count = signal(0)function Counter() { return <button onClick={() => count.value++}>{count}</button>}In the Signals version, clicking the button doesn’t re-execute the Counter function. The Signal tells the specific text node in the DOM to update its value. React's entire render-diff-commit cycle is bypassed.
This is called fine-grained reactivity. Updates are O(number of changes) rather than O(size of the component tree).
What You Lose Without the VDOM
Signals aren’t strictly better. Removing the VDOM has real costs.
Predictable data flow. React enforces data flows down, events flow up. You can always trace a bug by asking: which state changed, which component owns it, what did it render. With Signals, state lives outside the component tree and can be read or written from anywhere. That’s powerful and a debugging nightmare on a large team. React’s “annoying” unidirectional data flow is a forcing function for maintainable architecture.
Concurrent rendering. React Fiber’s scheduling model — startTransition, interruptible rendering, priority lanes — is built on the VDOM as an intermediate representation. When React pauses mid-render, it's pausing VDOM construction. The real DOM is never in a half-finished state. Signals update the DOM synchronously and directly. You can't deprioritise a Signal update the way you can with startTransition.
The ecosystem. Error boundaries, Suspense, RSC, DevTools — all built on the VDOM as a shared representation of UI. Signals-based frameworks have their own tooling, but it’s a fraction of React’s maturity.
Not OC
Why React Hasn’t Adopted Signals
Every major framework except React has moved toward Signals or fine-grained reactivity: Vue’s ref(), Solid's native signals, Svelte 5's runes, Angular's signals. React is the holdout.
The React team’s official position: React Compiler makes Signals unnecessary. If the compiler automatically memoizes everything at build time, the wasted re-render problem disappears without changing the programming model or the data flow guarantees.
The counterargument: the compiler is a build-time heuristic. It analyses your code statically and can bail out on complex patterns. Signals are a runtime guarantee — a Signal update always touches exactly the nodes bound to it, no analysis required.
Both positions are defensible. React’s bet is that compiler technology catches up and developers keep the familiar model. The Signals camp’s bet is that fine-grained reactivity is the correct primitive and no compiler fully replicates it.
What’s clear is that frameworks starting fresh have consistently chosen fine-grained reactivity, while React is defending the VDOM model with a compiler rather than with the model itself. Whether that’s React being late or React being right is not settled, and anyone telling you otherwise is selling something.
The Full Picture
React’s architecture is a set of deliberate tradeoffs, not a collection of optimal solutions:
Concept What it does The tradeoff VDOM Cheap scratchpad for UI descriptions JS re-execution cost on every render Reconciliation Minimal DOM updates from VDOM diff O(n) only with type + key assumptions Keys Stable identity across list renders Index keys silently break on mutation Fiber Interruptible, prioritised rendering Complexity, concurrent mode edge cases memo Prevent child re-renders Fails silently on reference equality useMemo / useCallback Stabilise references for memo Boilerplate, easy to get wrong React Compiler Automatic memoization at build time Black box, bails out on complex patterns Signals Direct DOM updates, no diffing Loses unidirectional flow and concurrent mode
None of these are mistakes. Each one is a choice about what to optimise for — developer experience, correctness guarantees, runtime performance, or architectural predictability.
Understanding the tradeoffs is what separates reaching for memo out of habit from knowing exactly why you're reaching for it — and whether you actually need to.
If this helped, or if I got something wrong — find me on LinkedIn or my site. I read everything.