DEV Community

Aniket Misra
Aniket Misra

Posted on

The JS Event Loop — Core Mental Model (Part 1/3)

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:

  1. The core event loop model (this post)
  2. Where Node and the browser actually diverge
  3. 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 / setInterval callbacks
  • I/O callbacks
  • UI rendering-related callbacks (browser)

Microtasks include:

  • Promise.then / .catch / .finally callbacks
  • queueMicrotask()
  • async/await continuations (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');
Enter fullscreen mode Exit fullscreen mode

Try to predict the order before reading on.

Here's what happens, step by step:

  1. console.log('1') runs synchronously → prints 1
  2. setTimeout(...) hands its callback off to the runtime's timer API and registers a macrotask for later — even with a 0ms delay, it doesn't run immediately
  3. Promise.resolve().then(...) queues a microtask
  4. console.log('4') runs synchronously → prints 4
  5. Call stack is now empty. Event loop checks the microtask queue first → runs it → prints 3
  6. 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');
Enter fullscreen mode Exit fullscreen mode

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)