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');
Most beginners guess A, B, C, D. The actual output is:
A
D
C
B
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');
Here's what happens on the call stack:
-
processUser('Supratik')is pushed onto the stack - Inside it,
greet('Supratik')is pushed on top -
greetreturns → popped off the stack -
console.log(message)is pushed, executes, popped -
processUserreturns → 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');
Here's the flow:
-
console.log('Start')→ runs immediately on the call stack -
setTimeout→ registers the callback with the Web API. The timer starts counting outside the call stack. setTimeout itself returns immediately. -
console.log('End')→ runs immediately - After 2000ms, the Web API moves the callback to the task queue
- 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');
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');
Output:
Synchronous
Microtask 1
Microtask 2
Macrotask
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:
- Run all synchronous code on the call stack until it's empty.
- Drain the entire microtask queue. If any microtask adds new microtasks, drain those too.
- Take one task from the task queue and push it onto the call stack.
- 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
Step by step:
-
console.log('A')→ prints A -
setTimeout→ callback sent to Web API → moves to task queue -
Promise.resolve().then()→ already resolved → callback goes to microtask queue -
console.log('D')→ prints D - Call stack is empty → drain microtask queue →
console.log('C')→ prints C - 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');
Output:
Start
Before await
End
After await
"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
- JavaScript has one call stack — it's single-threaded.
- Async operations (timers, network) are handled by Web APIs outside the main thread.
- When Web APIs finish, callbacks go to the task queue (macrotask queue).
- Promise callbacks go to the microtask queue, which has higher priority.
- The event loop: run sync code → drain all microtasks → pick one macrotask → repeat.
-
async/await is syntactic sugar — everything after
awaitbecomes 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.
Top comments (0)