AcademyJavaScript
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.
3 min readUpdated 3 exercises
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
2 challenges · 1 quizReading this page is the cheap half. These are the exercises that make you use it.
- Asynchronous Programming · Async/AwaitOrdering questions of exactly the shape above — read the snippet, predict the output.QuizTake · MediumQuizTake · Medium
- Promise TimeoutProRace a promise against a timer, which means placing a macrotask and a microtask against each other on purpose.ChallengeSolve · MediumChallengeSolve · Medium
- Task SchedulerProBuild the scheduler itself — the only way to be sure you understand the queue is to implement one.ChallengeSolve · HardChallengeSolve · Hard
Read next
Promise.all, allSettled, race and any
The four ways to combine promises, what each one does with a rejection, and how to pick the right one without guessing.
3 min
Debounce vs throttle
Two ways to fire less often, and the one question that tells you which you need — do you want the last call, or a steady rate?
3 min
Stacks and queues, LIFO and FIFO
Two disciplines built on the same array, why the call stack and the event queue are named what they are, and the monotonic-stack and two-stack patterns.
11 min