codejump
Academy

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

MicrotasksMacrotasks
.then / .catch / .finallysetTimeout, setInterval
the continuation after an awaitsetImmediate (Node)
queueMicrotaskI/O callbacks, DOM events
MutationObserverrequestAnimationFrame (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, 2

1 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, b

await 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 paint

No 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 with Promise.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.