A Mental Model for the JavaScript Event Loop
Build a rock-solid mental model for the call stack, Web APIs, macrotasks, and microtasks — so you never get confused about when a line of code actually runs.
If you've written JavaScript for any length of time, you've probably hit something that feels like a bug: you set a setTimeout to 0ms, and it still runs after a Promise further down the file. Or your console.log lines print in an order that makes no sense given where they sit in the code. None of this is actually a bug — it's the event loop quietly doing its job behind the scenes.
JavaScript is single-threaded
JavaScript can only do one thing at a time. It has a call stack, where every piece of code lines up to run — one at a time, start to finish, before moving on to the next.
The call stack works like a stack of plates: calling a function pushes it on, finishing a function pops it off. Last in, first out (LIFO).
So here's the problem: if everything has to funnel through this one lane, wouldn't a three-second network request just freeze the whole page for three seconds? Buttons unresponsive, nothing moving on screen?
Exactly. If JavaScript actually worked that way, the experience would be a disaster. So it needs a way to hand off slow work somewhere else and get notified when that work is done. That's where the event loop comes in.
So who actually handles async? It's not JavaScript itself
This is where most people get it wrong. setTimeout, fetch, DOM event listeners — none of these are actually built into the JavaScript engine. They're provided by the environment running your code: the browser or Node.js.
We call these environment-provided capabilities Web APIs. When you call setTimeout, the timer counting down in the background runs on a separate thread inside the browser — it isn't competing for the same lane as your main program. Once time's up, the browser takes the callback you handed it and drops it into a queue, where it waits to run.
So the cast of characters looks like this:
- Web APIs: browser-provided background workers that handle time-consuming things like timers, network requests, and DOM events.
- Call stack: the only place code actually executes, one thing at a time.
- Microtask queue: where
Promise.thencallbacks, code afterawait, andqueueMicrotaskcalls land. - Macrotask queue: where
setTimeout,setInterval, and DOM event callbacks land. - Event loop: the dispatcher tying it all together.
What does the event loop actually do?
Think of the event loop as an employee who's incredibly hardworking but only does one thing: stare at a single question — "Is the call stack empty yet?"
As long as something's still running on the call stack, it just waits, doing nothing. The moment the stack clears, it springs into action, pulling waiting callbacks out of a queue and pushing them onto the call stack to run.
But the handoff follows a strict order. One full cycle looks like this:
- Pull one task off the macrotask queue and run it on the call stack to completion. (Fun fact: the very first run of your whole
.jsfile counts as the first macrotask.) - Once that task finishes, the event loop runs a microtask checkpoint: it drains the entire microtask queue. Emphasis on entire — if draining it spawns new microtasks, those run too, in the same pass, instead of waiting for next time. If it's time for a rendering-related pass (
requestAnimationFrame, layout, paint), that happens here too. - Go back to step 1 and grab the next macrotask.
See the key part? Every time a macrotask finishes, the entire microtask queue gets drained before the next macrotask even gets a turn. That's the whole reason microtasks always run before the next macrotask — no exceptions.
Seeing it in action
Talking through the rules only gets you so far. Let's trace through an example:
console.log('1') // synchronous
setTimeout(() => console.log('2'), 0) // macrotask
Promise.resolve().then(() => {
console.log('3') // microtask
Promise.resolve().then(() => console.log('4')) // a new microtask spawned during the checkpoint
})
console.log('5') // synchronous
// output order: 1 → 5 → 3 → 4 → 2Let's walk through it step by step:
- Synchronous phase (remember, the whole script counts as the first macrotask):
console.log('1')prints 1setTimeouthands its callback to the Web APIs, which queues it on the macrotask queue after 0ms.thengets queued on the microtask queueconsole.log('5')prints 5- At this point, the screen shows 1, 5
- The call stack clears, so we enter the microtask checkpoint:
- The
.thencallback runs, printing 3 - It spawns another microtask right there. Since the rule is "drain the entire queue," this freshly spawned microtask still has to run in the same pass — so 4 prints too
- The
- Next cycle, grab a macrotask:
- Finally, the
setTimeoutcallback gets its turn, printing 2
- Finally, the
So that solves the mystery of "set to 0ms but runs last." It's not about being slow — it's queued as a macrotask, and macrotasks have to wait for all the synchronous code and every microtask in the current cycle to clear out first.