React 18 Automatic Batching: How It Works, Where It Stops, and How to Check It
React 18 automatically batches state updates from timers, promises, and native events into one render when using createRoot. Learn the mechanism, limits, and how to verify it with the Profiler.
16 Oct 2025, 16:41 UTC

The short answer
In React 18, multiple state updates triggered in the same JavaScript tick—inside a setTimeout, a promise callback, or a native browser event—are grouped into a single render pass. This behavior is called automatic batching. It is enabled by default only for applications mounted with createRoot from react-dom/client. If your app still uses the legacy ReactDOM.render API, those same updates will trigger separate renders.
How the batching scheduler works
Before React 18, batching was limited to React's synthetic event system. When you called a state setter inside a React event handler, React collected all updates and rendered once. But if the same setters ran inside a setTimeout, a fetch promise, or a raw DOM listener, React rendered immediately after each call.
React 18 replaces that with an automatic batching scheduler. When any state update occurs, React checks whether it is already inside a batch. If not, it starts one, queues the update, and schedules the flush for the end of the current JavaScript tick. Any additional updates that arrive before the flush are added to the same queue. The result is one render and one commit for the whole group.
Behavior at a glance
| Update location | Legacy ReactDOM.render | React 18 createRoot |
|---|---|---|
| React synthetic event | Batched | Batched |
setTimeout callback | Separate renders | Batched |
Promise .then | Separate renders | Batched |
Native addEventListener | Separate renders | Batched |
Inside flushSync | Synchronous | Synchronous |
A worked example
Consider a component with two independent pieces of state. In a React 18 root, the following code causes one render, not two:
import { createRoot } from 'react-dom/client';
import { useState, useEffect } from 'react';
function Counter() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
useEffect(() => {
const timer = setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
}, 1000);
return () => clearTimeout(timer);
}, []);
return (
<div>
<p>Count: {count}</p>
<p>Flag: {flag ? 'on' : 'off'}</p>
</div>
);
}
const root = createRoot(document.getElementById('root'));
root.render(<Counter />);
Run this in any React 18 project. Open the React DevTools Profiler and record while the timer fires. Check the commit count: a single commit that includes both the count and flag changes confirms automatic batching is active. If you replace createRoot with the legacy ReactDOM.render call, the Profiler will show two separate commits for the same code.
Where automatic batching stops
Automatic batching is not universal. Three boundaries matter:
- Legacy roots. Apps mounted with
ReactDOM.renderkeep the pre-18 behavior. Async updates outside React event handlers are not batched. - Manual flushing. Calling
flushSyncfromreact-domforces React to apply updates synchronously, bypassing the batch queue entirely. - State consistency across ticks. Batching groups renders; it does not merge state values. If two setters in the same tick read the same stale variable, both will use that old value.
Common mistakes
- Expecting chained updates to accumulate. Writing
setCount(count + 1); setCount(count + 1);inside one timer still increments by one, because both calls close over the samecountvalue. Use the functional formsetCount(c => c + 1)when an update depends on the previous state. - Reading the DOM immediately after a setter. Because the render is deferred until the batch flushes, the DOM will not reflect the new state until after the current tick completes.
- Mixing root APIs. Mounting some parts of the tree with
createRootand others withReactDOM.renderproduces inconsistent batching behavior that is difficult to debug. - Treating batching as a performance fix. Batching reduces render count, but it does not reduce the cost of each render. Components that are expensive to render still need memoization or profiling.
How to verify the behavior in your project
First, confirm the React version. In your project directory, run:
npm list react react-dom
Expected output shows versions 18.0.0 or higher. If you see 17.x or lower, automatic batching is not available.
Next, confirm the root API. Search your entry file for createRoot. If it uses ReactDOM.render, migrate that root before testing batching.
Finally, use the React DevTools Profiler. Record a session, trigger the batched updates, and check the commit count. A single commit for multiple state changes confirms automatic batching is active. Two commits means the root is still legacy or the updates were flushed manually.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.