tl;dr
- The event loop runs one task, drains queued microtasks, may update rendering, then selects another task
- React's render phase can be interrupted during concurrent rendering; the commit phase applies DOM changes synchronously, then effects run after commit. Paint timing is contextual, and React does not promise a macrotask implementation
- Modern React batches many state updates automatically, but not every update under every condition
- Blocking the call stack with heavy synchronous code freezes the entire UI
startTransitionlets React deprioritise state updates and keep the UI responsive
JavaScript is single-threaded, meaning it can only execute one piece of code at a time. The event loop is the mechanism that allows it to handle asynchronous work without blocking. Understanding it is key to reasoning about how React schedules renders, batches updates, and runs effects.
The event loop in a nutshell
There are four main players:
- Call stack - where your code runs, one frame at a time
- Web APIs - handles async work like
setTimeout,fetch, and DOM events outside the stack - Microtask queue - holds resolved promise callbacks (
then,catch,async/await) - Task queues - hold work from sources such as timers, user interactions and networking
In simplified terms, the loop selects and runs one task, drains the entire microtask queue, may update rendering, then selects another task. "Macrotask" is common shorthand for a task, but browsers can maintain multiple task queues and choose between them.
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// Output: 1, 4, 3, 2setTimeout(..., 0) does not mean "run immediately" - it queues a timer task after the minimum delay. The promise callback fires first because microtasks are drained after the current script task completes and before the timer task runs.
How React fits in
React's render phase calculates what the next UI should look like. When you call setState, React schedules an update rather than immediately changing the DOM. During concurrent rendering, React may pause, restart or abandon render work so the browser can handle other work. The commit phase then applies the selected changes to the DOM synchronously.
React does not guarantee a browser paint between updates, and the mechanism it uses to schedule work is an implementation detail rather than part of its public API.
State batching
React can batch multiple setState calls made before it processes an update, often producing a single render and commit.
function Counter() {
const [count, setCount] = useState(0);
const [label, setLabel] = useState('');
const handleClick = () => {
setCount((c) => c + 1);
setLabel('updated');
// React will usually process these as one batched update
};
return <button onClick={handleClick}>{label}: {count}</button>;
}Prior to React 18, React automatically batched updates in React event handlers, but not generally in asynchronous callbacks. With React 18's createRoot, React also batches many updates from sources such as setTimeout, promises and native event handlers. This is broader automatic batching, not a guarantee that every update is batched in every condition.
// React 18+ with createRoot - these are generally batched together
setTimeout(() => {
setCount((c) => c + 1);
setLabel('updated');
// React may process both updates in one render and commit
}, 1000);The exact point at which React renders depends on the update's context and priority. Batching groups updates that React can process together; it does not define a universal event-loop rule.
useEffect and the event loop
After React commits an update, the useEffect setup function runs when its dependencies have changed. For an Effect not caused by an interaction, React will generally let the browser paint the updated screen first. An Effect caused by an interaction may run before the browser paints.
This timing is contextual. React's public API does not guarantee that useEffect is implemented as a macrotask or that it always runs after paint. Once an Effect starts, its synchronous code runs on the main thread, so a long Effect can delay subsequent paints, input handling and other work.
useEffect(() => {
console.log('effect');
}, []);
console.log('render');
// Ordering: render, then (after commit): effect
// The browser may paint before or after the Effect, depending on context.The useLayoutEffect Hook runs after React has updated the DOM and before the browser repaints. Its code, and any synchronous state updates scheduled from it, block repainting. Reserve it for work that must happen before paint, such as measuring layout and synchronously correcting visual positioning. Prefer useEffect unless the work must happen before paint.
Blocking the event loop breaks React
Because rendering happens on the same thread, a long-running synchronous operation will freeze your UI - no re-renders, no event handling, nothing.
function ExpensiveComponent() {
const handleClick = () => {
// This blocks the event loop for ~2 seconds
const start = Date.now();
while (Date.now() - start < 2000) {}
setDone(true);
};
return <button onClick={handleClick}>Run</button>;
}Scheduling heavy work later is not the same as moving it off the main thread:
setTimeoutschedules later work on the main thread.requestIdleCallbackschedules low-priority work during idle periods, also on the main thread.- Chunking work and yielding between chunks can improve responsiveness, but the chunks still run on the main thread.
Web Workerscan run CPU-heavy JavaScript on another thread.startTransitionmarks React state updates as non-urgent, so React can interrupt the rendering they cause. It does not move arbitrary calculations off the main thread or make CPU-heavy calculations faster. The function passed to it is called immediately.
import { useState, startTransition } from 'react';
function SearchResults({ items }) {
const [query, setQuery] = useState('');
const [resultsQuery, setResultsQuery] = useState('');
const handleChange = (event) => {
const nextQuery = event.target.value;
setQuery(nextQuery); // Keep the input update urgent
startTransition(() => {
// Results renders a potentially expensive list
setResultsQuery(nextQuery);
});
};
return (
<>
<input value={query} onChange={handleChange} />
<Results items={items} query={resultsQuery} />
</>
);
}startTransition tells React that the update can be interrupted - if a higher-priority update arrives, React can handle it first. The rendering work still runs on the main thread, so a CPU-heavy calculation must be optimised, chunked or moved to a Worker separately.