STORM

SOFTWARE  DEVELOPER

SYSTEM  INITIALIZING

[ VIEW PORTFOLIO ]

01
Back to Blog
JavaScript

The JavaScript Event Loop: Why setTimeout(fn, 0) Isn't Instant

May 8, 2026 7 min read

Here's a classic JavaScript puzzle. What order do these log?

js
console.log('A')
setTimeout(() => console.log('B'), 0)
Promise.resolve().then(() => console.log('C'))
console.log('D')

If you said A → D → C → B, you understand the event loop. If that surprised you, this article is for you.


JavaScript Is Single-Threaded

Despite handling network requests, timers, and user events simultaneously, JavaScript runs on a single thread. It can only execute one piece of code at a time. The event loop is the system that coordinates everything - deciding what runs when.

To understand it, you need to know three structures: the call stack, the task queue (also called the macrotask queue), and the microtask queue.


The Call Stack

The call stack is where JavaScript tracks which function is currently running. When you call a function, it gets pushed onto the stack. When it returns, it's popped off.

js
function greet() {
  console.log('Hello')
}

function run() {
  greet() // pushed, then popped when done
}

run() // pushed, then popped
// Stack is now empty

JavaScript can only process the next piece of work when the call stack is empty. This is why a long synchronous operation freezes the browser - nothing else can run while the stack has frames on it.


The Task Queue (Macrotasks)

When an asynchronous operation completes - a timer fires, a network response arrives, a click event happens - its callback isn't pushed onto the call stack immediately. It's placed into the task queue and waits.

The event loop checks the task queue only when the call stack is completely empty. It picks one task, runs it to completion, and then checks again.

  • › setTimeout / setInterval callbacks
  • › DOM events (click, keydown, etc.)
  • › MessageChannel callbacks
  • › I/O callbacks in Node.js

This is why setTimeout(fn, 0) doesn't mean "run immediately." It means "run after the current call stack clears and all microtasks are done." Even a 0ms timer can be delayed.


The Microtask Queue

Microtasks are a higher-priority queue. After each task completes (and after the call stack empties), the engine drains the entire microtask queue before moving on to the next macrotask.

  • › Promise .then() / .catch() / .finally() callbacks
  • › async/await continuations (they compile to Promises under the hood)
  • › queueMicrotask() callbacks
  • › MutationObserver callbacks
js
// This is why Promise callbacks run before setTimeout callbacks
setTimeout(() => console.log('timer'), 0)  // macrotask
Promise.resolve().then(() => console.log('promise')) // microtask

// Output:
// promise  ← microtask queue drained first
// timer    ← macrotask picked next

The Full Loop - Step by Step

Here's exactly what the event loop does on each cycle:

  • › Execute the current synchronous code until the call stack is empty
  • › Drain the entire microtask queue (run all pending microtasks, including any new ones queued during this step)
  • › Pick one task from the macrotask queue and run it
  • › Drain the microtask queue again
  • › Repeat

Now the original puzzle makes sense:

js
console.log('A')           // ① sync - runs immediately
setTimeout(() => log('B'), 0) // ② schedules a macrotask
Promise.resolve().then(() => log('C')) // ③ schedules a microtask
console.log('D')           // ④ sync - runs immediately

// Call stack empties after ④
// Microtask queue drained: 'C'
// Macrotask picked: 'B'
// Output: A D C B

Practical Implications

async/await and the loop

Every await suspends the current function and schedules the rest as a microtask. This keeps long async chains from blocking, but their callbacks still don't run until the current synchronous block finishes.

js
async function fetchUser() {
  console.log('fetching...')
  const user = await getUser() // suspends here
  console.log(user.name)       // resumes as a microtask after getUser resolves
}

fetchUser()
console.log('this runs before user.name logs')

Avoid blocking the event loop

Any synchronous work that takes more than a few milliseconds will freeze your UI. Common culprits: sorting huge arrays, parsing large JSON, or deeply recursive operations on the main thread.

js
// This freezes the browser for however long the loop runs
for (let i = 0; i < 1_000_000_000; i++) { /* ... */ }

// Solutions:
// 1. Move to a Web Worker (separate thread)
// 2. Break work into chunks with setTimeout
// 3. Use requestIdleCallback for non-urgent work

Mental Model Summary

The call stack runs synchronous code. Microtasks (Promises) cut the queue and run before the next macrotask. Macrotasks (timers, events) wait their turn. The event loop keeps cycling until there's nothing left.


  • Synchronous code always runs first, in order
  • Promises resolve in the microtask queue - before any timer callback
  • setTimeout(fn, 0) is not zero delay - it waits for the stack and microtasks to clear
  • Heavy sync work blocks everything - use Workers for CPU-intensive tasks
  • async/await is syntactic sugar over Promises - same queue, same rules
Back to Blog
PLAYING