Concept
The beginner framing: JavaScript can only do one thing at a time, but your browser or Node.js process is doing lots of things "at once": a timer counting down, a network request in flight, a click waiting to happen. The event loop is the mechanism that lets JavaScript's single thread coordinate with all of that surrounding activity without ever actually running two pieces of your code simultaneously.
The precise mental model: nothing about async JavaScript happens inside the JavaScript engine's single thread. setTimeout, fetch, DOM events, and file I/O are all handled by the host environment (the browser's Web APIs, or Node's C++ bindings/libuv), genuinely concurrent, running outside your JS code entirely. When one of those finishes, it doesn't interrupt your running code; it places a callback into a queue. The event loop's entire job is simple and mechanical: repeatedly check "is the call stack empty? If so, take the next thing from a queue and run it."
The engine-level view, there are two queues, and their priority is not equal:
- Macrotask queue (a.k.a. the "callback" or "task" queue):
setTimeout,setInterval, DOM events,fetch's network completion, I/O callbacks in Node. Roughly: one task is processed per event loop "tick." - Microtask queue: Promise
.then/.catch/.finallycallbacks,async/awaitcontinuations,queueMicrotask(). The entire microtask queue is drained completely, including any new microtasks scheduled while draining, before the event loop ever looks at the macrotask queue again.
This priority difference is the entire reason the classic ordering puzzle exists:
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// Output: 1, 4, 3, 2Even with a 0ms delay, setTimeout's callback goes to the macrotask queue, it never jumps ahead of a microtask, no matter how small the delay.
console.log('1');setTimeout(() => console.log('2'), 0);Promise.resolve().then(() => console.log('3'));console.log('4');
console.log('1') runs synchronously, straight onto the call stack, straight into the output.
Microtasks fully drain before ANY macrotask runs, even chained ones
console.log('start');
setTimeout(() => console.log('timeout'), 0);
Promise.resolve()
.then(() => console.log('then 1'))
.then(() => console.log('then 2'))
.then(() => console.log('then 3'));
console.log('end');
// Output: start, end, then 1, then 2, then 3, timeoutEach .then() in the chain is only scheduled as a microtask once the one before it actually runs, but that new microtask still goes into the same, currently-draining queue. The event loop won't move on to the macrotask queue until that queue is genuinely, completely empty, even if new items kept getting added to it while it was draining.
console.log('start');setTimeout(() => console.log('timeout'), 0);Promise.resolve().then(() => console.log('then 1')).then(() => console.log('then 2')).then(() => console.log('then 3'));console.log('end');
console.log('start') runs first, synchronously.
async/await is built on this exact mechanism
async function example() {
console.log('A');
await null; // pauses here, resumes as a MICROTASK
console.log('B');
}
console.log('start');
example();
console.log('end');
// Output: start, A, end, Bawait doesn't block anything. Everything before the first await runs synchronously (hence 'A' logs before 'end'). The moment await is hit, the rest of the function is scheduled to resume as a microtask, behaviorally identical to a .then() callback. This is why async/await output interleaves with .then() chains using the exact same microtask-queue rules described above.
Try It
Predict the exact output order before running.
console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve()
.then(() => console.log('C'))
.then(() => {
console.log('D');
setTimeout(() => console.log('E'), 0);
});
console.log('F');Solution
A, F, C, D, B, EA and F run synchronously first. Then the microtask queue drains: C runs, schedules the next .then(), which runs and logs D, and while running, schedules a NEW setTimeout (E), which goes to the back of the macrotask queue, behind the already-waiting B. Once the microtask queue is empty, the macrotask queue runs in order: B first (it was queued earlier), then E.
Implement It Yourself
Build a tiny, simplified event loop simulator to internalize the actual algorithm:
function runEventLoop(syncScript, microtasks, macrotasks) {
const log = [];
// 1. Run the synchronous script to completion first.
syncScript(log);
// 2. Drain the ENTIRE microtask queue, including tasks
// added to it WHILE draining (a real, subtle rule).
while (microtasks.length > 0) {
const task = microtasks.shift();
task(log, microtasks); // may push MORE microtasks onto the same queue
}
// 3. Now, and only now, process ONE macrotask per "tick"
// (a real event loop repeats forever; we just drain everything here).
while (macrotasks.length > 0) {
const task = macrotasks.shift
The comment about draining microtasks again between each macrotask is real, that's exactly what your browser or Node does on every single tick. Extending the simulator to do that (drain microtasks after every individual macrotask, not just once at the very end) will make it match real engine behavior even for more complex interleavings.
In React
React batches state updates, and understanding the event loop clarifies when that batching boundary actually is. In React 18+, updates inside promise callbacks, setTimeout, and native event handlers are all automatically batched, multiple setState calls in the same microtask or macrotask are grouped into a single re-render. Before React 18, only updates inside React's own synthetic event handlers were batched, a setState inside a raw setTimeout callback triggered an immediate, separate render for each call. This distinction only makes sense once you know timers and promise callbacks run as separate macrotasks/microtasks, outside of React's normal synchronous render call stack, React has to explicitly opt into batching them, because by default each one is a fresh entry point back into your code.
useEffect callbacks themselves are scheduled as a specific kind of task too, they run after the browser has painted, deliberately deprioritized below rendering work, which is exactly why useLayoutEffect exists as an escape hatch for the rarer cases where you need to read/mutate the DOM synchronously, before paint.
Common Mistakes
1. Assuming setTimeout(fn, 0) runs immediately
setTimeout(() => console.log('later'), 0);
console.log('now');
// "now" ALWAYS logs first, 0ms is not "immediately", it's "as soon as the
// queue lets it, after the current script and all microtasks finish"There is no delay short enough to jump the call stack or the microtask queue. setTimeout(fn, 0) means "run this as soon as possible, but only once everything currently running (and all pending microtasks) is done."
2. Blocking the event loop with synchronous work
function blockFor(ms) {
const end = Date.now() + ms;
while (Date.now() < end) {} // busy-wait, nothing else can run during this
}
button.addEventListener('click', () => blockFor(3000));
// clicking the button freezes EVERYTHING for 3 seconds, no other events,
// timers, or rendering can happen, because the single thread is stuck hereAny synchronous code that takes a long time to run blocks the entire event loop, no timers fire, no clicks register, no rendering happens, because there's only one thread, and it's busy. This is the actual mechanism behind a "frozen" web page.
3. Expecting a promise chain to "wait its turn" behind a slow macrotask
setTimeout(() => console.log('timeout'), 100);
Promise.resolve().then(() => console.log('microtask'));
// 'microtask' logs first EVEN THOUGH the timeout's delay (100ms) will
// have elapsed long before the synchronous script + microtask queue finish
//, but that doesn't matter; microtasks always fully drain first, regardless
// of how "ready" a macrotask is.A macrotask being "ready" (its timer elapsed, its I/O completed) doesn't let it cut in line, the event loop always fully drains the current microtask queue before even glancing at the macrotask queue.
Best Practices
- Never busy-wait or run long synchronous loops on code that shares a thread with rendering or event handling. Break long synchronous work into chunks (
setTimeout,requestIdleCallback, or a Web Worker for genuinely heavy computation). - Prefer
async/awaitover raw.then()chains for readability, they're behaviorally identical (both are microtasks under the hood), butasync/awaitreads top-to-bottom like synchronous code. - Don't rely on
setTimeout(fn, 0)for ordering guarantees relative to promises, if you need "run after all current work, but before any timer," usequeueMicrotask()or a resolved promise instead, since they use the higher-priority queue.
Performance Tips
- A congested microtask queue (e.g., a runaway chain that keeps scheduling more microtasks) can starve the macrotask queue indefinitely, including blocking rendering and user input, since those are handled as macrotasks/rendering steps too. This is a real, if rare, performance bug class: "microtask starvation."
requestAnimationFramecallbacks run at a specific point in the loop (right before rendering/painting), distinct from both microtasks and regular macrotasks. Use it specifically for anything that should sync with the browser's paint cycle (animations), notsetTimeout.- Use Chrome DevTools' Performance panel to see actual call stack, microtask, and task boundaries on a real timeline, it's the same three-lane model as the visualizers above, applied to real recorded execution.
