Understanding JavaScript’s Event Loop: Why the Logs Don’t Appear in the Order You Expect

Understanding JavaScript’s Event Loop: Why the Logs Don’t Appear in the Order You Expect
Photo by Fabian Castro / Unsplash

If you’ve ever written JavaScript with console.log() sprinkled around timers, promises, and synchronous code, you’ve probably been surprised at the order of execution. Take this short snippet as an example:

console.log("A");

setTimeout(function () {
  console.log("B");
}, 0);

Promise.resolve()
  .then(function () {
    console.log("C");
  })
  .then(function () {
    console.log("D");
  });

console.log("E");

At first glance, many developers expect the output to be:

A
B
C
D
E

But when you actually run it, the console gives you:

A
E
C
D
B

Why does this happen? Let’s break it down.


Step 1: Synchronous Execution Comes First

JavaScript is single-threaded. That means it executes one thing at a time in a stack called the call stack. The engine always runs synchronous code first, line by line.

  1. console.log("A") runs immediately → A
  2. setTimeout(...) schedules a task but does not execute it yet.
  3. Promise.resolve() creates a resolved promise and schedules its .then() callbacks.
  4. console.log("E") runs immediately → E

So after the synchronous phase, we already have:

A
E

Step 2: Microtasks (Promises) Run Before Macrotasks

The event loop manages two key types of queues:

  • Microtask Queue → includes things like Promises (.then, .catch, .finally) and MutationObserver callbacks.
  • Macrotask Queue → includes things like setTimeout, setInterval, setImmediate (Node.js), and I/O events.

Here’s the important rule:

After each synchronous run of code, the event loop empties the microtask queue before moving on to macrotasks.

So when the synchronous stack finishes (A and E), the engine checks the microtask queue:

  • First .then() logs → C
  • Second .then() (chained) logs → D

Now the output becomes:

A
E
C
D

Step 3: Macrotasks Come Last

Only after the microtasks are done, the event loop goes to the macrotask queue. Our setTimeout(..., 0) callback is sitting there waiting.

That finally logs:

  • B

Final order:

A
E
C
D
B

Key Takeaways

  • Synchronous code runs first.
  • Promises (microtasks) are always prioritized over setTimeout (macrotasks).
  • setTimeout with 0ms is not immediate. It only means “execute as soon as possible after the current stack and all microtasks finish.”
  • Chained promises (.then(...).then(...)) execute in sequence within the microtask queue before any macrotask can run.

Other Considerations You Should Know

  1. process.nextTick (Node.js only):
    In Node.js, process.nextTick is even higher priority than microtasks. It always runs right after the current operation, before promises. This can sometimes starve the event loop if abused.
  2. Different environments, same principle:
    Whether you’re running this code in Chrome, Firefox, or Node.js, the behavior is consistent because it’s part of the ECMAScript specification.
  3. Practical advice:
    • Use Promises for fine-grained scheduling.
    • Use setTimeout for deferring tasks when you want to allow the browser/UI thread to “breathe” (e.g., letting rendering catch up).
    • Avoid mixing too many microtasks in hot loops — it can cause performance bottlenecks.
  4. Debugging tip:
    Adding timestamps to logs (console.time) can help you visualize when callbacks actually fire, especially in real applications where you mix network requests, rendering, and user interactions.

Finally

What looks like a few simple logs is actually a window into the heart of JavaScript’s concurrency model. Understanding the event loop, call stack, microtasks, and macrotasks will save you countless headaches.

So the next time you see logs appear in a “weird” order, remember:

  • A and E run first (synchronous).
  • C and D follow (microtasks).
  • B lags behind (macrotask).

And that’s not a bug — that’s just JavaScript doing exactly what it was designed to do.

Support Us