JavaScriptAdvanced

The JavaScript Event Loop, Explained With Diagrams

How the call stack, task queue, and microtask queue actually interact — and why your setTimeout(fn, 0) doesn't run immediately.

DevFieldGuideJuly 12, 2026 (updated July 26, 2026)6 min read
Share:

JavaScript is single-threaded, yet it handles thousands of concurrent operations without blocking. The event loop is how — and it trips up almost everyone the first time they see the actual execution order.

The three pieces

  1. Call stack — where currently executing code lives. Synchronous code runs here, one frame at a time.
  2. Microtask queue — where Promise callbacks (.then, async/await continuations) go.
  3. (Macro)task queue — where setTimeout, setInterval, and I/O callbacks go.

The event loop's rule is simple but has a specific order: run everything on the call stack until it's empty, then drain the entire microtask queue, then run one task from the task queue, then repeat.

Call stackSynchronous code runs here
Stack emptiesEvent loop checks queues
Microtask queue drains fullyPromises, async/await continuations
One task runssetTimeout, I/O callbacks

The classic surprising example

js
console.log("1");
 
setTimeout(() => console.log("2"), 0);
 
Promise.resolve().then(() => console.log("3"));
 
console.log("4");

Output:

1 4 3 2

Walking through it:

  1. console.log("1") runs synchronously — call stack.
  2. setTimeout schedules its callback on the task queue and returns immediately — it doesn't run yet, even with a 0ms delay.
  3. Promise.resolve().then(...) schedules its callback on the microtask queue.
  4. console.log("4") runs synchronously.
  5. Call stack is now empty. The event loop drains the microtask queue: "3" logs.
  6. Only now does the event loop pick up the next task: the setTimeout callback runs, logging "2".

Why this matters in practice

async/await continuations are microtasks. This means a chain of awaited promises will always fully resolve before a pending setTimeout fires, no matter how small its delay — a common source of confusion when debugging race conditions between timers and async data fetching.

js
async function load() {
  const data = await fetch("/api/data").then((r) => r.json());
  console.log(data); // this microtask runs before any pending setTimeout
}

The practical takeaway

If you need something to run after the current synchronous code but before the browser repaints or handles the next timer, a microtask (a resolved Promise, or queueMicrotask()) gets there first. If you need to yield back to the browser entirely — letting it paint, handle input, or run other pending timers — setTimeout(fn, 0) (or requestAnimationFrame for visual work) is the tool, precisely because it waits for the next pass of the event loop instead of the current one.

Node.js adds more queues to the same idea

In the browser, "task queue" is a reasonable simplification. Node.js's event loop is more granular — it has distinct phases (timers, pending callbacks, poll, check, close callbacks), and two separate microtask-like queues of its own: process.nextTick() and Promise microtasks, with nextTick draining before the Promise microtask queue on each pass:

js
process.nextTick(() => console.log("nextTick"));
Promise.resolve().then(() => console.log("promise"));
nextTick promise

This ordering detail rarely matters for typical application code, but it matters a great deal for library authors — process.nextTick is powerful enough (able to fully starve I/O if used recursively without care) that Node's own documentation recommends most code should prefer Promise microtasks or setImmediate unless there's a specific reason to need nextTick's stronger ordering guarantee.

queueMicrotask(): the explicit version

Rather than manufacturing a resolved Promise just to schedule a microtask, queueMicrotask() does the same thing directly and reads more clearly about intent:

js
queueMicrotask(() => {
  console.log("runs before the next task, after current sync code");
});

This is the right tool specifically when you want microtask timing (run after current code finishes, but before the browser yields to paint or the next timer fires) without actually needing a Promise's value-carrying or chaining behavior — a small but real readability improvement over Promise.resolve().then(...) for that exact case.

Visualizing it with a slightly longer example

js
console.log("start");
 
setTimeout(() => console.log("timeout 1"), 0);
 
Promise.resolve()
  .then(() => console.log("promise 1"))
  .then(() => console.log("promise 2"));
 
setTimeout(() => console.log("timeout 2"), 0);
 
console.log("end");
start end promise 1 promise 2 timeout 1 timeout 2

Both .then() callbacks (chained on the same promise) run before either setTimeout, because the microtask queue is drained completely — including microtasks scheduled by other microtasks while draining — before the event loop moves on to a single task from the task queue. This is the detail that trips people up most: microtask draining isn't "run what's queued right now," it's "keep running until the queue is truly empty," even if new microtasks keep getting added during that process.

requestAnimationFrame: a third kind of scheduling

For visual updates specifically (animations, DOM measurements that need to happen right before a repaint), neither microtasks nor setTimeout are quite the right tool — requestAnimationFrame schedules a callback to run right before the browser's next repaint, synced to the display's actual refresh rate rather than an arbitrary delay:

js
function animate() {
  element.style.transform = `translateX(${x}px)`;
  x += 2;
  requestAnimationFrame(animate);
}
requestAnimationFrame(animate);

Using setTimeout for animation instead (even at a "60fps" 16ms delay) drifts from the actual display refresh and can cause visibly janky motion, since the timer isn't synchronized with when the browser is actually about to paint — requestAnimationFrame is scheduled specifically to align with that moment.

Understanding the event loop pays off directly in how you write everyday JavaScript — it's why modern array methods that return synchronously behave predictably alongside async code, and it's the exact mechanism React Server Components rely on to stream work to the client without blocking rendering.

Common mistakes

Common mistakes
  • Chaining many .then() callbacks and assuming they interleave with other microtasks fairly — a long chain of microtasks all run before the event loop yields back to the task queue at all, which can starve timers and I/O callbacks if the chain is long enough.
  • Assuming setTimeout(fn, 0) runs "immediately" — it schedules a task for the next pass of the event loop at the earliest, after the current call stack empties and the microtask queue fully drains, which is often measurably later than 0ms suggests.
  • Writing a recursive async function without any yielding point, expecting it to let the UI update between iterations — without an await on something that actually defers (like a setTimeout-based delay), a tight async loop can still block rendering the same way a synchronous one would.
  • Debugging race conditions by adding setTimeout calls to "fix" ordering without understanding why the original order was wrong — this often just changes which specific timing bug shows up, rather than addressing the actual dependency the code has on execution order.
Advertisement

Frequently Asked Questions

Advertisement
DevFieldGuide
DevFieldGuide

Editorial Team

Practical tutorials and developer tools, written and maintained by the DevFieldGuide team.

Enjoyed this article?

Get the next one straight to your inbox, along with the best of what we publish each week.

Related Articles

More in JavaScript

View all