tl;dr
- The event loop processes the call stack, flushes all microtasks, then picks one macrotask - in that order
- React rendering is synchronous call stack work; effects are deferred macrotasks
- React 18 batches all state updates by default, reducing unnecessary re-renders
- Blocking the call stack with heavy synchronous code freezes the entire UI
startTransitionlets React deprioritise expensive 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) - Macrotask queue - holds callbacks from
setTimeout,setInterval, and I/O events
The loop works like this: run everything on the call stack, drain the entire microtask queue, then pick one macrotask, repeat.
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 means "schedule as a macrotask". The promise callback fires first because microtasks flush before the next macrotask.
How React fits in
React's rendering is synchronous work on the call stack. When you call setState, React does not re-render immediately - it schedules a re-render. In React 18, this scheduling runs through its own internal scheduler built on top of MessageChannel (a macrotask), giving the browser a chance to paint between updates.
This is the foundation of Concurrent Mode: React can yield to the browser mid-render, preventing long renders from blocking user interactions.
State batching
React batches multiple setState calls in the same synchronous block into a single re-render.
function Counter() {
const [count, setCount] = useState(0);
const [label, setLabel] = useState('');
const handleClick = () => {
setCount((c) => c + 1);
setLabel('updated');
// Only one re-render happens here, not two
};
return <button onClick={handleClick}>{label}: {count}</button>;
}Prior to React 18, batching only happened inside React event handlers. Code inside setTimeout or fetch callbacks would trigger separate re-renders per setState call. React 18 introduced automatic batching everywhere.
// React 18 - both setStates are batched even inside setTimeout
setTimeout(() => {
setCount((c) => c + 1);
setLabel('updated');
// Still only one re-render
}, 1000);This works because React defers the actual render to a scheduled task, accumulating all state changes that happen synchronously first.
useEffect and the event loop
useEffect callbacks run after the browser has painted. React schedules them as macrotasks, which is why they never block the render.
useEffect(() => {
console.log('effect');
}, []);
console.log('render');
// Output: render, then (after paint): effectuseLayoutEffect is different - it fires synchronously after the DOM mutation but before the browser paints, similar to how microtasks flush before the next macrotask.
Use useLayoutEffect when you need to read layout (e.g. element dimensions) before the user sees the screen. Use useEffect for everything else.
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>;
}The fix is to move heavy work off the main thread using setTimeout, requestIdleCallback, Web Workers, or React's own startTransition to mark updates as non-urgent.
import { startTransition } from 'react';
startTransition(() => {
setFilteredList(computeHeavyFilter(data));
});startTransition tells React this update can be interrupted - if a higher-priority update arrives (like a keypress), React will pause and handle it first.