Jan 28, 2017Last modified May 3, 2025

Deep dive on react virtual DOM

Alright, you know react uses virtual DOM to improve rendering performance. In this article we will try to understand how virtual DOM works and what are those design choices which actually end up improving the rendering performance.

But before that, lets understand how rendering works -

Browser keep tracks of the changes happening to the web page and whenever they happen, it repaints the page by rebuilding the DOM. This operation of detecting changes, calculating layout and repainting the elements is a expensive operation as the whole DOM is rebuilt event though only a part of it has changed.

So everytime a change happens, the browser has to -

  • Compute css rules and styles for every element
  • Calculate position of each element in the page layout
  • Repaint the view port by handling changes in layers, visual effects, transparency etc.
  • Finally combine all the layers and display the page

Let's take an example to understand this better.

Following code snippet contains a simple login form with username, password input fields and a submit button.

I also have added a mutation observer to detect changes in the DOM.

Try to enter a value in the username field and observe the changes in the DOM. You would notice that the mutation observer is triggered for every character you enter in the input fields and for the button click as well.

<!DOCTYPE html>
<html>
  <head>
    <title>React Login</title>
    <script src="https://unpkg.com/react@18/umd/react.development.js"></script>
    <script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
    <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
    <style>
      .error { color: red; }
    </style>
  </head>
  <body>
    <div id="root"></div>

    <script>
        // Set up the observer
      const observer = new MutationObserver((mutations) => {
        mutations.forEach((mutation) => {
          console.log('DOM changed:', mutation.type, mutation.target);
        });
      });

      // Start observing with config
      observer.observe(document.body, {
        subtree: true,       // Watch all descendants
        childList: true,      // Watch for node additions/removals
        attributes: true,     // Watch for attribute changes
        characterData: true,  // Watch for text changes
        attributeOldValue: true // Record old attribute value
      });
    </script>

    <script type="text/babel">
      function LoginForm() {
        const [username, setUsername] = React.useState('');
        const [password, setPassword] = React.useState('');
        const [errors, setErrors] = React.useState({});

        const validate = () => {
          const newErrors = {};
          if (username.length < 3) newErrors.username = 'Username too short';
          if (password.length < 6) newErrors.password = 'Password too short';
          setErrors(newErrors);
          return Object.keys(newErrors).length === 0;
        };

        const handleSubmit = (e) => {
          e.preventDefault();
          if (validate()) {
            alert('Login successful!');
          }
        };

        return (
          <form onSubmit={handleSubmit}>
            <div>
              <label>Username:</label>
              <input
                type="text"
                value={username}
                onChange={(e) => setUsername(e.target.value)}
              />
              {errors.username && <span className="error">{errors.username}</span>}
            </div>
            <div>
              <label>Password:</label>
              <input
                type="password"
                value={password}
                onChange={(e) => setPassword(e.target.value)}
              />
              {errors.password && <span className="error">{errors.password}</span>}
            </div>
            <button type="submit">Login</button>
          </form>
        );
      }

      ReactDOM.createRoot(document.getElementById('root')).render(<LoginForm />);
    </script>
  </body>
</html>

Here's what you would see in console logs -

Now this whole process has few performance bottlenecks -

  1. DOM operations are synchronous and block the main thread until completion. When complex DOM operations block this thread, it causes the dreaded "jank" - stuttering animations and unresponsive interfaces.
  2. The DOM lives outside the JavaScript engine. And cross process or boundary communication is expensive and slow.
  3. DOM changes have cascading effects where a change in one element can affect the position of other elements.

How react solves this problem using virtual DOM ?

Well, react maintains a virtual DOM (tree) in memory. This tree is a lightweight javascript object tree.

Whenever there are changes to the state of the application, react first makes the changes to the virtual DOM. Then it basically checks for a difference between the virtual DOM and the actual DOM( diffing algorithm ).

The diffing algorithm typically works by:

  • Comparing nodes by type and key
  • Using tree traversal techniques (often depth-first)
  • Implementing heuristics like:
    • Different element types will produce different trees
    • Elements with stable keys maintain their identity across renders

React identifies the minimal set of changes required to update the DOM.

Batch Rendering

Batch rendering takes advantage of JavaScript's event loop architecture:

┌─────────────────────────┐
│ Event Queue │
└───────────┬─────────────┘


┌─────────────────────────┐
│ Execute JS/Handle │
│ Callbacks │
└───────────┬─────────────┘


┌─────────────────────────┐
│ Render Queue/Batch │◄── This is where the magic happens
└───────────┬─────────────┘


┌─────────────────────────┐
│ Layout Calculation & │
│ Paint │
└─────────────────────────┘
┌─────────────────────────┐
│ Event Queue │
└───────────┬─────────────┘


┌─────────────────────────┐
│ Execute JS/Handle │
│ Callbacks │
└───────────┬─────────────┘


┌─────────────────────────┐
│ Render Queue/Batch │◄── This is where the magic happens
└───────────┬─────────────┘


┌─────────────────────────┐
│ Layout Calculation & │
│ Paint │
└─────────────────────────┘

When state changes occur:

  • The framework intercepts these changes rather than immediately updating the DOM
  • Each change is placed into an internal update queue
  • Related updates that occur within the same "tick" of the event loop are grouped together
  • A single update operation is scheduled (often using mechanisms like requestAnimationFrame or microtasks)

In React, this is implemented through its reconciler (previously known as the "stack reconciler" and now "Fiber"):

Diffing Algorithm

  • Tree Traversal: React performs a depth-first traversal of the Fiber tree
  • Component Comparison: At each node, checks if the component type matches
  • Key Matching: For lists, uses keys to match old and new elements
  • Update Determination: Decides whether to:
    • Keep the existing instance (bailout)
    • Update props/state (reuse)
    • Replace with new instance (remount)
  • Effect Tagging: Marks nodes that need DOM updates (Placement, Update, Deletion)
  • Commit Phase: Flushes all changes to the DOM in one batch