The JavaScript event loop
How one thread runs everything: the call stack, the microtask queue, timers, and why a promise always resolves before a setTimeout of 0.
Updated
One thread, two queues
JavaScript runs your code on a single thread. Everything asynchronous — a timer, a network response, a resolved promise — is work that was handed to something else and comes back as a callback waiting in a queue.
The event loop is the rule that decides which callback runs next. It is short enough to state in three lines:
1. Run the current task to completion. Nothing interrupts it.
2. Drain the microtask queue entirely — including microtasks added while draining.
3. Take one macrotask (timer, I/O, event), run it, go back to step 2.
Almost every ordering question in JavaScript is answered by those three lines.
Who goes in which queue
| Microtasks | Macrotasks |
|---|---|
.then / .catch / .finally | setTimeout, setInterval |
the continuation after an await | setImmediate (Node) |
queueMicrotask | I/O callbacks, DOM events |
MutationObserver | requestAnimationFrame (its own phase) |
Microtasks are not "faster" — they are privileged. The loop refuses to pick up another macrotask while a single microtask is pending.
The ordering everyone gets asked
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// 1, 4, 3, 21 and 4 are synchronous — they are the current task, and it runs to completion first. Then the microtask queue is drained, so 3. Only then does the loop accept a macrotask: 2.
The 0 in setTimeout is a *minimum* delay, not a priority. It means "no sooner than now", and the loop will still finish every microtask before it.
Why await behaves like .then
An await splits the function in two. Everything after it becomes a microtask scheduled when the awaited value settles:
async function run() {
console.log('a');
await null; // not a promise — still schedules a microtask
console.log('b');
}
run();
console.log('c');
// a, c, bawait null looks like it should do nothing. It does not: await always yields once, even on a non-promise. That single yield is enough for c to run first.
The failure this explains
A microtask that schedules another microtask keeps the loop in step 2 forever:
function spin() {
Promise.resolve().then(spin);
}
spin(); // the page freezes — no timer, no click, no paintNo macrotask ever runs again. The tab stops responding, and no error is thrown, because nothing is wrong from the loop's point of view — it is doing exactly what it was told.
The same shape, slower, is what a long synchronous loop does to a UI. Rendering is a macrotask; while your task runs, the frame cannot be painted. "The page is janky" almost always means "something on the main thread is taking longer than a frame".
Splitting work across
setTimeout(fn, 0)gives the loop a chance to paint between chunks. Splitting it withPromise.resolve().then(fn)does not — you stay inside the microtask drain.
Node adds phases, not exceptions
Node's loop has ordered phases (timers, pending callbacks, poll, check, close), and process.nextTick runs before promise microtasks. The three-line rule still holds: the current task finishes, microtasks drain, then one macrotask from whichever phase is current.
Now practice it
Reading this page is the cheap half. These are the exercises that make you use it.
- Quizmedium
Asynchronous Programming · Async/Await
Ordering questions of exactly the shape above — read the snippet, predict the output.
- ChallengemediumPro
Promise Timeout
Race a promise against a timer, which means placing a macrotask and a microtask against each other on purpose.
- ChallengehardPro
Task Scheduler
Build the scheduler itself — the only way to be sure you understand the queue is to implement one.