All Posts
JavaScriptPerformanceAsync

Understanding the Event Loop: JavaScript's Secret Engine

Async/await hides a lot of complexity. Understanding the event loop, the call stack, and the microtask queue will make you a dramatically better JavaScript developer.

Understanding the Event Loop: JavaScript's Secret Engine

If you have ever stared at a setTimeout behaving unexpectedly, watched async/await produce surprising order of operations, or wondered why a UI freezes during heavy computation — the answer is always the same thing: the event loop.

Understanding it is one of those rare investments where everything suddenly makes sense.

JavaScript Is Single-Threaded

Here is the fundamental truth most tutorials gloss over: JavaScript runs on a single thread. There is exactly one call stack. It can only do one thing at a time.

This sounds like a recipe for terrible performance. And it would be — if the entire platform were just the engine. But the JavaScript runtime (V8, SpiderMonkey, etc.) lives inside an environment (the browser or Node.js) that provides a much richer set of tools.

The Four Players

To understand the event loop, you need to understand four components:

1. The Call Stack

This is where your code runs. When you call a function, it goes onto the stack. When it returns, it comes off. Simple LIFO (Last In, First Out).

function greet(name) {
  console.log(`Hello, ${name}`); // pushed and popped
}

greet("Jay"); // greet pushed onto stack, then popped

2. Web APIs (or Node.js APIs)

When you call setTimeout, fetch, addEventListener, or fs.readFile, you are handing work off to the environment. These run outside the call stack — in the browser's C++ internals or Node.js's libuv layer.

setTimeout(() => console.log("done"), 1000);
// The timer is managed by the browser, NOT the JS engine
// Your code continues executing immediately

3. The Callback Queue (Macrotask Queue)

When a Web API finishes its work (a timer expires, a network response arrives), it places the callback into the macrotask queue. These callbacks wait until the call stack is completely empty.

4. The Microtask Queue

Promises (and queueMicrotask) place their callbacks in a separate, higher-priority queue: the microtask queue. This queue is drained completely before the event loop moves on to the next macrotask.

The Event Loop Algorithm

The event loop's job is embarrassingly simple:

while (true) {
  if (callStack is empty) {
    drain all microtasks
    pick one task from macrotask queue
    push it onto the call stack
  }
}

That is essentially it. The complexity comes from understanding what goes into which queue.

A Classic Example

console.log("1");

setTimeout(() => console.log("2"), 0);

Promise.resolve().then(() => console.log("3"));

console.log("4");

What do you think the output is?

1
4
3
2

Here is why:

  • 1 and 4 are synchronous — they run immediately on the call stack
  • The setTimeout callback goes to the macrotask queue
  • The Promise .then callback goes to the microtask queue
  • After the synchronous code finishes, microtasks drain first → 3
  • Then the macrotask runs → 2

Even though setTimeout was called with 0ms, it still runs after the Promise.

Why Does This Matter in Practice?

Blocking the Event Loop

If your call stack is never empty, nothing from either queue can run. This is what happens when you do expensive synchronous work:

// This will freeze the UI for however long this loop takes
for (let i = 0; i < 1_000_000_000; i++) {
  // heavy computation
}

The fix? Break it into chunks using setTimeout(chunk, 0) or use a Web Worker for true parallelism.

Understanding async/await Order

async function main() {
  console.log("A");
  await Promise.resolve();
  console.log("B"); // This runs as a microtask
}

main();
console.log("C");
// Output: A, C, B

await suspends the current async function and schedules its continuation as a microtask. Code after the await in the async function runs after the current synchronous block completes.

Practical Takeaways

  1. Never block the main thread with synchronous loops — break work into async chunks or use workers
  2. Promises are higher priority than setTimeout — microtasks drain before macrotasks
  3. await yields control, it does not pause time — code after await runs asynchronously
  4. The 0ms in setTimeout(..., 0) is a lie — it means "next available macrotask slot," not "immediately"

Conclusion

The event loop is the beating heart of every JavaScript application. Understanding it transforms mysterious bugs into predictable, fixable patterns. The next time something runs in the wrong order or your UI freezes unexpectedly, you now have the mental model to diagnose it.

Knowing this is what separates JavaScript developers who write code that works from those who understand why it works.