How the JavaScript Event Loop Works

At some point in your JavaScript journey, you’ve probably stared at console output and muttered, “Wait, why did that run after this?”

That moment—when you first bump into asynchronous behavior—is your introduction to the JavaScript Event Loop. And it’s a rite of passage.

I still remember the first time setTimeout(fn, 0) ran after my Promise .then(). It broke my brain. I spent the afternoon debugging a feature that wasn't broken, just misunderstood.

Let’s fix that for good.

JavaScript Is Single-Threaded—But Not Stupid

Despite running on a single thread, JavaScript somehow handles fetch(), UI events, timers, and animations without choking.

So how does it pull off the illusion of multitasking?

Enter the Event Loop: a coordination system that manages what code runs, and when, based on stack state and task queues.

Step One: Understand the Call Stack

At the core, JavaScript uses a call stack to track function execution. It’s literally a stack:

javascript
1
2
3
4
          function greet() {
  console.log("Hello");
}
greet(); // Pushed → Runs → Popped
        

Synchronous tasks get stacked and unstacked in order. No surprises—yet.

Now Add Asynchronous Code to the Mix

javascript
1
2
3
4
5
6
7
          console.log("Start");

setTimeout(() => {
  console.log("Inside setTimeout");
}, 1000);

console.log("End");
        

You might expect:

javascript
1
2
3
          Start  
Inside setTimeout  
End
        

But what you get is:

javascript
1
2
3
          Start  
End  
Inside setTimeout
        

Why? Because setTimeout() doesn't run inside the call stack. It hands off the callback to the browser’s Web API system. That’s where the Event Loop takes over.

How the Event Loop Actually Works

Here’s the simplified flow:

  1. 1
    Synchronous code runs in the Call Stack.
  2. 2
    Async APIs like setTimeout delegate the callback to Web APIs.
  3. 3
    When ready, the callback is queued into the Task Queue.
  4. 4
    The Event Loop checks: Is the stack empty?

* If yes → It pushes the next task from the queue.

This explains why the timeout runs after all synchronous code is done. It’s not late—it’s just waiting in line.

Microtasks vs. Macrotasks: Yes, There's a Hierarchy

JavaScript has two task queues:

  • Macrotasks: setTimeout, setInterval, setImmediate
  • Microtasks: Promise.then, queueMicrotask, MutationObserver

And microtasks always run before the next macrotask.

javascript
1
2
3
4
5
6
7
8
9
10
11
          console.log("Start");

setTimeout(() => {
  console.log("Macrotask");
}, 0);

Promise.resolve().then(() => {
  console.log("Microtask");
});

console.log("End");
        

Output:

javascript
1
2
3
4
          Start  
End  
Microtask  
Macrotask
        

If you're building responsive interfaces or using Promises in DOM code, knowing this order can save you from mysterious bugs.

What About async/await?

async/await is just syntactic sugar for Promises. It feels synchronous but behaves like this:

javascript
1
2
3
4
5
          async function fetchData() {
  console.log("Fetching...");
  const result = await new Promise(res => setTimeout(() => res("Done"), 1000));
  console.log(result);
}
        

Behind the scenes:

javascript
1
2
          new Promise(res => setTimeout(() => res("Done"), 1000))
  .then(result => console.log(result));
        

When you await, you pause your function. The rest of it goes into the microtask queue, waiting until the Promise resolves.

Visualizing the Flow

Here's a mental model I wish I had years ago:

bash
1
2
3
4
5
          → Web APIs (timers, DOM events, fetch)
   ↓
→ Task Queue (macrotasks)       → Microtask Queue (Promises)
   ↓                                ↓
→ Event Loop checks... → If Call Stack is empty → executes next task
        

The Event Loop runs constantly, shuffling in tasks as the stack clears.

Real-World Pitfalls to Watch For

Blocking the Event Loop

javascript
1
2
3
4
5
6
7
8
          function sleep(ms) {
  const start = Date.now();
  while (Date.now() - start < ms) {}
}

console.log("Start");
sleep(2000); // Completely blocks!
console.log("End");
        

This will freeze everything—animations, input, timers. Avoid long synchronous loops like this. If you need to pause without blocking, use setTimeout or async functions.

Misusing setTimeout(fn, 0)

It doesn’t run immediately. It waits until the call stack is clear and microtasks are done.

If you truly want to run something right after the current tick, use:

javascript
1
2
3
          Promise.resolve().then(() => {
  // This runs before setTimeout
});
        

Or use queueMicrotask() for even more precision.

Why This Matters in Real Projects

If you’ve ever built a UI with event handlers, debounce logic, or chained Promises, you’ve dealt with the Event Loop—even if you didn’t know it.

Frameworks like React and Vue rely on microtask timing to batch DOM updates. Async rendering in Node.js also dances around the loop. Understanding these timings helps you:

  • Prevent race conditions
  • Avoid janky UI updates
  • Write faster, cleaner asynchronous logic

And if you’re exploring JS-based AI tools or real-time web apps, it's absolutely critical.

Want to Learn More?

Here are a few battle-tested resources I still revisit:

TL;DR

  • JavaScript is single-threaded but async thanks to the Event Loop.
  • It coordinates the Call Stack, Web APIs, and task queues.
  • Microtasks (Promises) run before macrotasks (setTimeout).
  • async/await is just sugar over Promises and the microtask queue.
  • Don’t block the stack. Ever.

Once you understand the Event Loop, the weird becomes expected—and JavaScript becomes a lot less magical, and a lot more logical.