By Ryan Calloway. Updated May 2026.
Philip Roberts’ 2014 JSConf EU talk “What the heck is the event loop anyway?” has crossed five million views by being the one explanation that finally clicks. Ten years on, the question still trends on r/learnjavascript: a 2024 thread “Can someone explain the event loop clearly?” hit 200+ replies, almost all of them pointing back to Roberts or to Jake Archibald’s “In the Loop”. This is the text version, with the snippets that trip most candidates and the mental model that makes async ordering obvious in Node 22, Chrome 134, and Firefox 138.
Quick answer: JavaScript has one call stack and two task queues. Microtasks (Promise callbacks, queueMicrotask, MutationObserver) drain fully before the next macrotask (setTimeout, I/O, UI events) runs. That is why Promise.resolve().then(...) fires before setTimeout(..., 0), even when both are “immediate”. The rest of this post explains why and how, with five worked examples you should be able to predict in your sleep.
What you’ll understand by the end
- Why
Promise.resolve().then(...)always wins againstsetTimeout(..., 0). - Why
awaitis an async boundary even on an already-resolved Promise. - How Node 22’s six event-loop phases interact with
process.nextTickand Promise microtasks. - What “microtask starvation” looks like in real code and how to break it.
- When to reach for
queueMicrotask,setTimeout,setImmediate, orrequestAnimationFrame.
Prerequisites
- Comfortable reading
async/awaitand Promise chains. If not, start with async/await vs promises. - Node 22 LTS or newer for the Node examples. (Node 24 is current; the behavior described here is identical.)
- A modern browser console for the browser examples. The 4 ms minimum on nested
setTimeout(..., 0)calls is part of the HTML spec, so any post-2020 browser will reproduce the timings.
The mental model in 60 seconds
The JavaScript runtime has one call stack. When the stack is empty, the event loop drains every microtask in the microtask queue. Then it picks one macrotask, pushes it onto the stack, runs it to completion, drains microtasks again, and repeats. That is the entire model.
| Actor | Contents | When it runs |
|---|---|---|
| Call stack | Currently executing function frames | One frame at a time, LIFO |
| Microtask queue | Promise .then/.catch/.finally, queueMicrotask, MutationObserver |
Drained fully after the stack empties, before any macrotask |
| Macrotask queue | setTimeout, setInterval, I/O, UI events, setImmediate (Node) |
One at a time, with the microtask queue drained between each |
The key word is fully. If a microtask queues another microtask, that one also runs before the next macrotask. The microtask queue can starve the macrotask queue; this is a real bug pattern, not a curiosity (see example 5 below). The WHATWG HTML “event loop processing model” defines this as a mandatory step: “perform a microtask checkpoint” runs every time the stack returns to the loop.
Call stack
The call stack is LIFO. Every function call pushes a frame; every return pops one. While there is anything on the stack, the event loop is blocked: no rendering, no event handlers, no timers fire.
function inner() { throw new Error("boom"); }
function outer() { inner(); }
outer();
// Error: boom
// at inner (...)
// at outer (...)
// at <anonymous>
That stack trace is exactly the order of frames at the moment throw ran. If a long synchronous loop sits on the stack for 5 seconds, the page is frozen for 5 seconds. The fix is to break the work into chunks scheduled with setTimeout, or to move it into a Web Worker.
Macrotask queue (setTimeout, setInterval, I/O)
Macrotasks are the “big” units of work the event loop picks up after the stack and microtasks are clear. In a browser they include timer callbacks, UI events, network callbacks, and message events. In Node, they are organized into six phases: timers, pending callbacks, idle/prepare, poll (I/O), check (setImmediate), and close. The Node docs have the phase-by-phase walk-through.
setTimeout(() => console.log("timer"), 0);
setImmediate(() => console.log("immediate"));
// In Node 22, when called from the top level, the order is non-deterministic.
// Called from inside an I/O callback, "immediate" always runs first.
Per the HTML spec, nested setTimeout(fn, 0) calls clamp to a 4 ms minimum after the fifth nesting. In practice, even the first setTimeout(fn, 0) usually runs in 1 to 4 ms on a real browser because there is paint and rendering work between macrotasks.
Microtask queue (Promise.then, queueMicrotask, MutationObserver)
Microtasks are the “small” tasks the event loop runs as soon as the stack is empty, draining the queue completely before touching the macrotask queue. The three sources are:
- Promise reactions:
.then,.catch,.finally, and the continuation of anasyncfunction afterawait. queueMicrotask(fn)— the explicit, official way to schedule one.MutationObservercallbacks (browser only).
queueMicrotask(() => console.log("a"));
Promise.resolve().then(() => console.log("b"));
console.log("c");
// c, a, b
Synchronous "c" runs first. Then the stack empties, and microtasks drain in FIFO order: "a", then "b".
The order of execution: 5 worked examples
Example 1: the classic predictor
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");
Output: 1 4 3 2. Synchronous "1" and "4" run first; the microtask "3" drains before the macrotask "2". If you cannot predict this in your sleep, every async bug will confuse you. Practice until you can.
Example 2: await on an already-resolved Promise
async function a() {
console.log("A1");
await b();
console.log("A2");
}
async function b() {
console.log("B1");
}
a();
console.log("main");
Output: A1 B1 main A2. The common wrong guess is A1 B1 A2 main. The 2023 r/javascript thread “async/await is blocking the event loop, or is it?” shows the same confusion at production scale.
a()runs synchronously untilawait b().- Inside
b(),"B1"logs synchronously;b()returns an already-resolved Promise. awaityields back to the caller, scheduling the rest ofa()(the"A2"log) as a microtask.- Back in the caller:
"main"runs. - Stack empty. Microtasks drain:
"A2".
Even an await on an already-resolved Promise is an async boundary. That is the rule people skip over and then get lost.
Example 3: chained .then versus serial setTimeout
setTimeout(() => console.log("t1"), 0);
setTimeout(() => console.log("t2"), 0);
Promise.resolve()
.then(() => console.log("p1"))
.then(() => console.log("p2"));
Output: p1 p2 t1 t2. The microtask queue drains completely before the first macrotask runs. The second .then is queued only when p1 finishes, but it lands on the microtask queue while we are still draining microtasks, so it runs in the same drain cycle. This is the “microtask checkpoint” from the HTML spec in action.
Example 4: Node — process.nextTick beats Promise reactions
// node 22+
Promise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("nextTick"));
console.log("sync");
Output: sync nextTick promise. Node has a separate nextTickQueue that drains before the microtask queue; both are drained between every callback in any phase. The Node process docs are explicit: process.nextTick “runs immediately after the current operation completes, regardless of the current phase of the event loop.” Avoid it unless you really need to preempt Promises; recursive process.nextTick will starve everything else, including I/O.
Example 5: microtask starvation
function infiniteMicrotasks() {
Promise.resolve().then(infiniteMicrotasks);
}
infiniteMicrotasks();
setTimeout(() => console.log("will I ever run?"), 0);
The setTimeout callback never fires. The microtask queue never empties because each microtask re-queues itself, and per spec the loop must drain microtasks fully before any macrotask. The page goes unresponsive. The cure is inserting a macrotask boundary: wrap the recursion in a setTimeout(infiniteMicrotasks, 0) so the browser can render and process I/O between iterations.
Common gotchas in 2026 (Node 22, browsers)
- Serial
awaitwhere parallel would do. Four independent API calls behind serialawaittake as long as their sum.Promise.alltakes as long as their max. The 2024 GitHub issue nodejs/help#4423 is one of dozens of bug reports that are really this. See async/await vs promises. awaitinsideforEach.Array.prototype.forEachignores the returned Promise. Usefor ... offor serial work, orPromise.all(arr.map(fn))for parallel.- Unbounded microtask recursion. A Promise chain that re-queues itself with no macrotask break starves I/O, UI, and the inspector. Insert a
setTimeoutorsetImmediateboundary every N iterations. process.nextTickin libraries. If you write a library that emits events before any Promise can resolve, callers cannot intercept them. UsequeueMicrotaskunless you have a specific reason for nextTick priority.- State updates timed to render boundaries. In React 19, state updates inside event handlers and Promises are all batched. Effects (
useEffect) fire after the browser paints; if you need to measure layout before paint, useuseLayoutEffect. See React useEffect cleanup. - Mistaking
setImmediatefor “now”.setImmediate(fn)runs in Node’scheckphase, after I/O. Outside an I/O callback, its order versussetTimeout(fn, 0)is non-deterministic. Inside one,setImmediatewins.
Picking the right scheduling primitive
queueMicrotask(() => { /* before any macrotask, after current sync code */ });
requestAnimationFrame(() => { /* before next paint, ~60fps */ });
setTimeout(() => {}, 0); // next macrotask tick, ~1\u20134ms in practice
setImmediate(() => {}); // Node check phase, after I/O
process.nextTick(() => {}); // before microtasks; rarely the right answer
Pick the one whose guarantee matches your intent. “I want it to run after the current code” is queueMicrotask. “I want it to run after a paint” is requestAnimationFrame. “I want it to yield to I/O” is setTimeout(fn, 0).
FAQ
What is a microtask in JavaScript?
A callback scheduled by a Promise (.then, .catch, .finally), queueMicrotask, or MutationObserver. Microtasks drain fully after the call stack empties, before any macrotask (timer, I/O) runs.
What is the difference between setTimeout and queueMicrotask?
setTimeout schedules a macrotask; it runs after the current microtask queue drains and the browser has a chance to render. queueMicrotask schedules a microtask; it runs before the next macrotask. The two are not interchangeable even when the timeout is 0.
Is JavaScript really single-threaded in 2026?
The main thread that runs your code is single-threaded. Web Workers and Node’s worker_threads run separate JS threads with their own event loops; they communicate via messages. A browser also has internal threads for networking, rendering, and GC, but your code does not see them.
Why does a long-running function freeze my page?
Because the browser cannot render or process events while the call stack has a frame on it. The event loop is blocked. Break long work into chunks scheduled with setTimeout, use scheduler.yield() on supporting browsers, or move the work into a Web Worker.
How does the event loop relate to async/await?
await pauses the async function and schedules its continuation as a microtask. The caller continues executing. When the awaited Promise resolves, the continuation is queued and runs in the next microtask drain.
Does Node 22 batch microtasks differently from the browser?
The shape is the same: microtasks drain fully between callbacks. The differences are (1) Node has the extra process.nextTick queue with higher priority than microtasks, and (2) Node organizes macrotasks into six phases instead of the browser’s task-source model. Both follow the same WHATWG-aligned “checkpoint” rule for microtasks.
Sources and further reading
- WHATWG HTML — event loop processing model (the normative spec)
- MDN — JavaScript execution model
- Node.js — The Node.js Event Loop, Timers, and process.nextTick()
- Node.js docs — process.nextTick()
- Jake Archibald — “In the Loop” (JSConf Asia 2018)
Next steps
If the event loop clicks for you, the next thing to read is async/await vs promises for the syntax that wraps these mechanics. For the React-specific angle, useEffect cleanup covers the timing between effects and paint, which is the second-most-confusing part of async React.