JavaScript is single-threaded. One call stack, one thing happening at a time. And yet your code fetches data, sets timers, listens for clicks, and handles I/O — all without blocking. How?
The answer is the event loop, and it's one of those concepts every JS developer thinks they understand until someone asks them to predict the output of five nested setTimeout and Promise calls.
This is part 1 of a 3-part series:
- The core event loop model (this post)
- Where Node and the browser actually diverge
- Escaping the event loop with
worker_threads
Let's build the mental model properly.
JS is single-threaded — so what's actually running your async code?
The JS engine (V8, SpiderMonkey, etc.) only executes one thing at a time on the call stack. There's no multithreading inside the engine itself.
But the runtime around the engine — the browser or Node — provides extra machinery: Web APIs in the browser (timers, DOM events, fetch), or C++ APIs via libuv in Node (timers, file I/O, network). These run outside the JS thread. When they finish, they don't just barge into your running code — they queue up a callback to be run later.
That queueing and "run later" part is the event loop's job.
The three pieces: stack, queue, loop
Call stack — where synchronous code executes, frame by frame. If a function calls another function, it stacks. When a function returns, it pops.
Queue(s) — where callbacks wait after some async operation completes. There isn't just one queue (more on that in a second).
Event loop — a simple, repeating check: "Is the call stack empty? If yes, take the next thing from a queue and push it onto the stack."
That's genuinely most of it. The complexity comes from which queue goes first.
Macrotasks vs microtasks
This is the part that trips people up, so let's be precise.
Macrotasks (aka "tasks") include:
-
setTimeout/setIntervalcallbacks - I/O callbacks
- UI rendering-related callbacks (browser)
Microtasks include:
-
Promise.then/.catch/.finallycallbacks queueMicrotask()-
async/awaitcontinuations (they're sugar over promises)
The rule that matters: after every single macrotask, the event loop fully drains the microtask queue before it does anything else — including before rendering a frame or running the next macrotask.
Not "checks it once." Drains it completely. If a microtask queues another microtask, that runs too, before moving on.
The classic gotcha, explained properly
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
Try to predict the order before reading on.
Here's what happens, step by step:
-
console.log('1')runs synchronously → prints1 -
setTimeout(...)hands its callback off to the runtime's timer API and registers a macrotask for later — even with a0msdelay, it doesn't run immediately -
Promise.resolve().then(...)queues a microtask -
console.log('4')runs synchronously → prints4 - Call stack is now empty. Event loop checks the microtask queue first → runs it → prints
3 - Microtask queue is empty. Event loop moves to the macrotask queue → runs the timeout callback → prints
2
Output: 1, 4, 3, 2
The part people get wrong is assuming setTimeout(fn, 0) means "run next." It doesn't mean "run next" — it means "run next macrotask turn," and microtasks always cut the line first.
Why this ordering exists at all
It's not arbitrary. Microtasks are meant for finishing up work that's already in flight — resolving a promise chain, reacting to something that just happened — before the system moves on to genuinely new, separately-scheduled work like a timer firing or an I/O event arriving. It keeps related async logic coherent and predictable, rather than letting it get interleaved with unrelated queued tasks.
Quick check — predict the output
console.log('start');
setTimeout(() => console.log('timeout'), 0);
Promise.resolve()
.then(() => console.log('promise 1'))
.then(() => console.log('promise 2'));
console.log('end');
Drop your answer in the comments before scrolling through devtools to check — this is exactly the kind of ordering that gets asked in interviews, and exactly the kind of bug that shows up in real async code when you assume timers run "immediately."
Answer
start, end, promise 1, promise 2, timeout
Both .then() callbacks are microtasks and both drain before the macrotask queue is touched — even though the second .then() is only queued once the first one runs.
What's next
This model — stack, microtask queue, macrotask queue, drain-before-next-task — is the shared spec-level behavior both browsers and Node implement. But they don't implement the runtime around it the same way.
Browsers have to interleave this with rendering. Node runs on libuv with distinct phases (timers, I/O, setImmediate, close callbacks) and has its own microtask-like queue via process.nextTick() that jumps the line even ahead of promises.
That's where things get genuinely different — and where most "event loop" explanations stop short. Part 2 goes there.
Top comments (0)