JavaScript Deep Dive — Issue #2

Mastering the Event Loop: Microtasks, Macrotasks & Hidden Async Behaviors

If there’s one concept that separates intermediate JavaScript developers from true experts, it’s this:

Understanding the event loop — not just at a surface level, but deeply.

Every framework, every asynchronous operation, every browser interaction ultimately depends on the event loop.
But many developers still can’t explain why certain lines of code run before others, or why UI freezes happen, or why a Promise resolves “instantly” but still after a log statement.

Today, we’re fixing that.


🧠 Why You Need to Master the Event Loop

The event loop is the beating heart of JavaScript’s execution model.

It controls:

  • When your callbacks run
  • How Promises resolve
  • When rendering updates happen
  • The order of async operations
  • Performance, responsiveness, and race conditions

If you truly understand it, you’ll debug faster, write more predictable code, and avoid async pitfalls that even senior developers struggle with.


🟨 1. JavaScript Is Single-Threaded — But Asynchronous

JavaScript runs one piece of code at a time.
But browsers and Node.js create the illusion of concurrency by scheduling tasks.

Three major players:

1️⃣ Call Stack — where JS executes code
2️⃣ Web APIs / Node APIs — timers, fetch, events
3️⃣ Callback Queues — tasks waiting to run

And controlling it all:

4️⃣ Event Loop — the scheduler that decides what runs next

Think of the event loop as the conductor of an orchestra.


🟨 2. Macrotasks vs Microtasks — The Most Common Confusion

JavaScript has two main queues:

Macrotasks

Examples:

  • setTimeout
  • setInterval
  • DOM events
  • I/O callbacks
  • Script execution

Microtasks

Examples:

  • Promise.then
  • async/await resolution
  • queueMicrotask
  • MutationObserver

Microtasks always run before macrotasks.
This is where the magic (and confusion) happens.


🟦 The Classic Example

console.log("A");

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

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

console.log("D");

What’s the output?

A
D
C
B

Why?

  1. A logs
  2. D logs
  3. Microtasks run → log C
  4. Macrotasks run → log B

Even setTimeout(..., 0) is never immediate.
It always waits for microtasks to finish.


🟨 3. Rendering Happens Between Microtasks & Macrotasks

Here’s a subtle but important detail:
Microtasks can block rendering.

Example:

button.addEventListener("click", () => {
  Promise.resolve().then(() => {
    // Long-running sync work here prevents UI updates
    for (let i = 0; i < 1_000_000_000; i++) {}
  });
});

Even though this is inside a Promise, the UI will freeze.
Why?
Because microtasks run without letting the browser render.

Lesson:
Heavy work should never live inside microtasks.


🟨 4. await Is Just Syntactic Sugar for Microtasks

This might blow some minds:

await something();

…is essentially:

something().then(() => {
  // continuation
});

Meaning:

  • Every await creates a microtask
  • Chained awaits create long microtask queues
  • Too many microtasks = blocked UI

🟨 5. Real-World Examples That Break Without Event Loop Knowledge

Example 1: Race Condition in Loops

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}

Outputs:

3
3
3

Because they’re macrotasks scheduled after the loop finishes → i = 3.

Fix with let:

for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}

Example 2: Microtasks “jump the line”

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

queueMicrotask(() => console.log("Microtask"));

Output:

Microtask
Macrotask

Example 3: async functions and the illusion of “sync” behavior

async function test() {
  console.log("A");
  await null;
  console.log("B");
}

console.log("C");
test();
console.log("D");

Output:

C
A
D
B

🟦 6. Performance Tip: Use requestIdleCallback & requestAnimationFrame

requestAnimationFrame

Runs before the next paint.

Great for:

  • Animations
  • Smooth UI updates

requestIdleCallback

Runs when the browser has free time.

Great for:

  • Analytics
  • Prefetching
  • Non-urgent background work

🧩 Mini Exercises for Readers

1. Predict the output:

console.log("Start");

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

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

console.log("End");

2. Predict this one:

async function x() {
  console.log(1);
  await null;
  console.log(2);
}
x();
console.log(3);

3. Why does this freeze the UI?

Promise.resolve().then(() => {
  while (true) {}
});

🟦 Best Practices for Async JavaScript

✔ Prefer async/await for clarity
✔ Avoid long-running work inside microtasks
✔ Use requestIdleCallback for background tasks
✔ Use Workers for CPU-heavy operations
✔ Always understand the scheduling order before debugging async code
✔ Use logs or Chrome DevTools async tracing


🏁 Final Thoughts

The event loop isn’t just some theoretical model — it determines how every modern JavaScript framework works.

React, Vue, Svelte, Node.js, Deno, Bun, Next.js…
Behind the scenes, they all rely on the exact mechanics we covered today.

Once you understand the event loop deeply, you’ll write:

  • More predictable async code
  • Smoother UI updates
  • Faster applications
  • Far fewer debugging hacks

Next issue:
👉 Issue #3 — Modern JavaScript Performance Patterns (2025 Edition)