DEV Community

Supratik Das
Supratik Das

Posted on • Originally published at jsvisualizer.bytefront.dev

JavaScript Event Loop Explained — A Visual, Step-by-Step Guide

Ask ten developers how the JavaScript event loop works, and you'll get eleven different answers. That's because the event loop is invisible. You can't console.log it. You can't set a breakpoint on it. You just have to know it's there, orchestrating everything.

This guide changes that. We'll walk through the event loop step by step, with code you can actually run and see in JS Visualizer — watching functions enter the call stack, callbacks move through Web APIs, and promises flush from the microtask queue.

The Puzzle That Trips Everyone Up

Before we dive in, try to predict the output of this code:

console.log('A');

setTimeout(() => console.log('B'), 0);

Promise.resolve().then(() => console.log('C'));

console.log('D');
Enter fullscreen mode Exit fullscreen mode

Most beginners guess A, B, C, D. The actual output is:

A
D
C
B
Enter fullscreen mode Exit fullscreen mode

If that surprises you, you're in exactly the right place. By the end of this article, you'll understand why — and it will feel obvious.

👉 Run this puzzle in JS Visualizer

The JavaScript Runtime: Four Moving Parts

JavaScript's runtime has four key components:

Component Role
Call Stack Where functions execute. One at a time — JS is single-threaded.
Web APIs Browser features (setTimeout, fetch, DOM events) running outside the main thread.
Task Queue Callbacks from Web APIs waiting for the call stack to empty.
Microtask Queue Promise callbacks. Always processed before the task queue.

And then there's the event loop itself — the coordinator. Its job: when the call stack is empty, pick the next thing to run.

Step 1: The Call Stack — One Thing at a Time

JavaScript is single-threaded. It has one call stack and can do one thing at a time. When you call a function, it gets pushed onto the stack. When it returns, it gets popped off.

function greet(name) {
  return 'Hello, ' + name;
}

function processUser(name) {
  const message = greet(name);
  console.log(message);
}

processUser('Supratik');
Enter fullscreen mode Exit fullscreen mode

Here's what happens on the call stack:

  1. processUser('Supratik') is pushed onto the stack
  2. Inside it, greet('Supratik') is pushed on top
  3. greet returns → popped off the stack
  4. console.log(message) is pushed, executes, popped
  5. processUser returns → popped off. Stack is empty.

💡 The call stack is like a stack of plates. You can only add or remove from the top. If a function calls another function, the new one goes on top and must finish before we return to the one below it.

Step 2: Web APIs — Where Async Things Wait

When you call setTimeout, fetch, or add a DOM event listener, JavaScript doesn't handle the waiting itself. It hands the job off to Web APIs — features provided by the browser (or Node.js runtime).

console.log('Start');

setTimeout(() => {
  console.log('Timer done');
}, 2000);

console.log('End');
Enter fullscreen mode Exit fullscreen mode

Here's the flow:

  1. console.log('Start') → runs immediately on the call stack
  2. setTimeout → registers the callback with the Web API. The timer starts counting outside the call stack. setTimeout itself returns immediately.
  3. console.log('End') → runs immediately
  4. After 2000ms, the Web API moves the callback to the task queue
  5. The event loop sees the call stack is empty → picks the callback → runs it

Output: Start → End → Timer done. The timer callback runs last, even though it was registered second.

👉 Watch the timer move through Web APIs

Step 3: The Task Queue (Macrotask Queue)

When a Web API finishes its work (timer expires, fetch returns, click happens), it doesn't interrupt whatever's currently running. Instead, it places the callback in the task queue (also called the macrotask queue).

The event loop's rule is simple: when the call stack is empty, take the first task from the queue and push it onto the stack.

setTimeout(() => console.log('First timeout'), 0);
setTimeout(() => console.log('Second timeout'), 0);

console.log('Synchronous');
Enter fullscreen mode Exit fullscreen mode

Output: Synchronous → First timeout → Second timeout. Both timeouts go to the task queue, and they're processed in order — but only after all synchronous code finishes.

⚠️ Common mistake: setTimeout(fn, 0) does NOT mean "run immediately." It means "run as soon as the call stack is empty and you get to the front of the queue." If there's a lot of synchronous code running, the 0ms timer could wait hundreds of milliseconds.

Step 4: The Microtask Queue — Promises Jump the Line

This is where most developers get confused. There are actually two queues, and one has priority over the other.

The microtask queue holds callbacks from:

  • Promise.then(), .catch(), .finally()
  • queueMicrotask()
  • MutationObserver

The critical rule: the event loop drains the entire microtask queue before touching the task queue. Every. Single. Time. If a microtask adds another microtask, that runs too — before any setTimeout callback gets a chance.

setTimeout(() => console.log('Macrotask'), 0);

Promise.resolve().then(() => console.log('Microtask 1'));
Promise.resolve().then(() => console.log('Microtask 2'));

console.log('Synchronous');
Enter fullscreen mode Exit fullscreen mode

Output:

Synchronous
Microtask 1
Microtask 2
Macrotask
Enter fullscreen mode Exit fullscreen mode

Even though setTimeout was registered first, both Promise callbacks run before it. The microtask queue always gets priority.

👉 Watch microtasks jump the line

Step 5: The Event Loop Algorithm

Now we can state the event loop's actual algorithm:

  1. Run all synchronous code on the call stack until it's empty.
  2. Drain the entire microtask queue. If any microtask adds new microtasks, drain those too.
  3. Take one task from the task queue and push it onto the call stack.
  4. Go back to step 2. (Yes — microtasks are checked again after every single macrotask.)

That's it. The entire event loop is these four steps, on repeat, forever.

Solving the Original Puzzle

Now let's go back to our puzzle and trace through it:

console.log('A');           // 1. Synchronous → runs immediately
setTimeout(() =>
  console.log('B'), 0);     // 2. Registers with Web API → callback goes to TASK queue
Promise.resolve().then(()
  => console.log('C'));     // 3. Already resolved → callback goes to MICROTASK queue
console.log('D');           // 4. Synchronous → runs immediately
Enter fullscreen mode Exit fullscreen mode

Step by step:

  1. console.log('A') → prints A
  2. setTimeout → callback sent to Web API → moves to task queue
  3. Promise.resolve().then() → already resolved → callback goes to microtask queue
  4. console.log('D') → prints D
  5. Call stack is empty → drain microtask queue → console.log('C') → prints C
  6. Microtask queue empty → take from task queue → console.log('B') → prints B

Final output: A, D, C, B. Mystery solved.

Bonus: How async/await Fits In

async/await is just syntactic sugar over Promises. When you await something, everything after the await becomes a microtask.

async function demo() {
  console.log('Before await');
  await Promise.resolve();
  console.log('After await');  // This is a microtask!
}

console.log('Start');
demo();
console.log('End');
Enter fullscreen mode Exit fullscreen mode

Output:

Start
Before await
End
After await
Enter fullscreen mode Exit fullscreen mode

"After await" runs as a microtask — same as if you'd written Promise.resolve().then(() => console.log('After await')).

👉 See async/await as microtasks

Common Mistakes and Misconceptions

Myth: setTimeout(fn, 0) runs immediately
It runs after all synchronous code AND all microtasks. The 0 is a minimum delay, not a guarantee.

Myth: Promises are asynchronous
The Promise constructor runs synchronously. Only the .then() callback is deferred (as a microtask).

Myth: JavaScript is multi-threaded because it can do async things
JavaScript itself is single-threaded. Web APIs (provided by the browser) can run in parallel, but your JS code always runs one line at a time on one call stack.

Summary

  1. JavaScript has one call stack — it's single-threaded.
  2. Async operations (timers, network) are handled by Web APIs outside the main thread.
  3. When Web APIs finish, callbacks go to the task queue (macrotask queue).
  4. Promise callbacks go to the microtask queue, which has higher priority.
  5. The event loop: run sync code → drain all microtasks → pick one macrotask → repeat.
  6. async/await is syntactic sugar — everything after await becomes a microtask.

The best way to internalize this? See it happen. Open JS Visualizer, paste any code snippet from this article, and watch every step unfold across the call stack, queues, and event loop — in real time. It's free.

Try JS Visualizer — Free

Top comments (0)