Mastering the Event Loop: Why JavaScript Isn’t Asynchronous (But Still Feels Like It)

Emma GeorgeEmma George
14 Jun, 2025
Mastering the Event Loop: Why JavaScript Isn’t Asynchronous (But Still Feels Like It)

TABLE OF CONTENTS

1 . What Does “Single-Threaded” Mean?

2 . The Call Stack: JavaScript’s Execution Backbone

3 . Web APIs: Browser-Level Asynchrony

4 . Task Queues and Callback Queues

5 . Microtasks vs Macrotasks

6 . Understanding setTimeout, Promises, and I/O

7 . The Event Loop: Step-by-Step Flow

9 . Visualizing with Examples

11 . Best Practices for Working with the Event Loop

12 . Tools for Debugging Asynchronous Behavior

13 . Evolution: How the Event Loop Works in Node.js

Conclusion

1 . What Does “Single-Threaded” Mean?

JavaScript engines like V8 (used in Chrome and Node.js) execute JavaScript code on a single thread. This means:

  • One task is executed at a time.
  • There’s only one call stack.
  • No true parallel execution of JS code.

But JavaScript doesn’t operate in a vacuum. It interacts with the host environment (browser or Node.js), which has its own threads and APIs to provide async capabilities.

2 . The Call Stack: JavaScript’s Execution Backbone

The call stack is where functions are executed and tracked.

Example:

function greet() {
  console.log("Hello");
}
function start() {
  greet();
}
start();

Call stack sequence:

  • start() is pushed
  • greet() is pushed
  • console.log() is pushed → executed → popped
  • greet() is popped
  • start() is popped

Everything runs in order. No parallel execution.

3 . Web APIs: Browser-Level Asynchrony

Here’s the twist.

When we call something like setTimeout, fetch, or DOM events, JavaScript itself doesn’t handle the delay. The browser or Node’s environment does.

For example:

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

The timer is handled by the browser. Once it's done, the callback is queued to be executed by the event loop.

JavaScript says, “Hey browser, set this timeout. When it’s done, notify me via the queue.”

4 . Task Queues and Callback Queues

Once async tasks are done (e.g., HTTP response received), they are placed in a queue. JavaScript picks tasks from this queue when the call stack is empty.

These queues include:

  • Macrotask Queue (a.k.a. Task Queue)
  • Microtask Queue (for promises, mutations, etc.)

5 . Microtasks vs Macrotasks

Microtasks include:

  • Promise.then()
  • queueMicrotask
  • MutationObserver

Macrotasks include:

  • setTimeout
  • setInterval
  • setImmediate (Node.js)

Execution priority:

  • Microtasks are executed immediately after the current task finishes and before the next one.
  • Macrotasks wait until the call stack and microtask queue are empty.

Example:

console.log("Start");

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

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

console.log("End");

Output:

Start → End → Microtask → Macrotask

6 . Understanding setTimeout, Promises, and I/O

Although setTimeout(..., 0) seems instant, it’s not. It gets queued after the current task and all microtasks finish.

Example:

setTimeout(() => console.log("timeout"), 0);
Promise.resolve().then(() => console.log("promise"));
console.log("sync");

Output:

sync → promise → timeout

Even though setTimeout has 0ms, Promise resolves first due to microtask priority.

7 . The Event Loop: Step-by-Step Flow

Here’s a simplified version of what happens:

1 . Execute global script → build call stack 2 . Finish stack execution 3 . Check microtask queue → execute all 4 . Check macrotask queue → execute next

Repeat

This endless loop is called the “Event Loop.”

It ensures that JS doesn’t block and handles asynchronous behavior predictably.

8 . Asynchronous Myths in JavaScript

❌ JavaScript is asynchronous ✅ JavaScript is synchronous with asynchronous capabilities provided by its runtime ❌ setTimeout executes after the given time ✅ setTimeout only queues after that time—actual execution depends on when the event loop is free

9 . Visualizing with Examples

Let’s look at a complex sequence:

console.log("A");

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

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

console.log("D");

Output:

A → D → C → B

Reason:

  • A & D = sync
  • C = microtask
  • B = macrotask

10 . Real-World Scenarios & Pitfalls

Example: DOM Manipulation timing

document.querySelector("button").addEventListener("click", () => {
  setTimeout(() => {
    console.log("Clicked");
  }, 0);
});

Pitfall: Assuming setTimeout(..., 0) will run immediately. In a large call stack or blocked main thread, it can be delayed significantly.

Another scenario: nested promises

Promise.resolve()
  .then(() => {
    console.log("First");
    return Promise.resolve();
  })
  .then(() => {
    console.log("Second");
  });

These chained promises queue in microtask queue sequentially.

11 . Best Practices for Working with the Event Loop

  • Avoid blocking the main thread (e.g., long loops or expensive calculations)
  • Use async/await for readable async code
  • Batch DOM manipulations
  • Understand micro vs macro tasks
  • Use queueMicrotask() for fine-grained async callbacks
  • Know the browser quirks (e.g., throttling setTimeout in inactive tabs)

12 . Tools for Debugging Asynchronous Behavior

  • Chrome DevTools → Use Performance tab
  • Node.js → Use --trace-events
  • Tools like why-did-you-render (for React)
  • Visualize with https://latentflip.com/loupe/ (Event Loop playground)

13 . Evolution: How the Event Loop Works in Node.js

Node.js follows a slightly different model based on libuv.

It has six phases:

  1. Timers
  2. Pending callbacks
  3. Idle/prepare
  4. Poll
  5. Check
  6. Close callbacks

Node also has:

  • Microtasks (Promises)
  • Next tick queue (process.nextTick())

process.nextTick() is even higher priority than promises in Node.

Conclusion

JavaScript isn't truly asynchronous, but with the help of the Event Loop, it behaves as if it were. This illusion is what makes JavaScript such a powerful language for handling non-blocking I/O and UI responsiveness.

To recap:

  • JavaScript is single-threaded and synchronous by design
  • The Event Loop enables it to handle async operations without blocking
  • Microtasks (Promises) have higher priority than Macrotasks (setTimeout)
  • Understanding the Event Loop is essential for writing performant and bug-free code
  • Async code is about coordination, not concurrency

Mastering the Event Loop allows you to debug effectively, write non-blocking applications, and fully understand what’s happening under the hood of every async function, await statement, or setTimeout() call.

It’s not magic, it’s the Event Loop.

Emma George

Emma George

Software Engineer

Senior Software Engineer