PAUL CHONGSenior Software Engineer

Understanding the JavaScript Event Loop

2026-08-04 · 4 min read

JavaScript is single-threaded — it can only execute one piece of code at a time. Yet it handles asynchronous operations like network requests, timers, and I/O without blocking. The mechanism that makes this possible is the event loop.

The Building Blocks

The Call Stack

The call stack is a LIFO data structure that tracks function execution. When you call a function, it's pushed onto the stack. When it returns, it's popped off.

function greet(name) {
  return `Hello, ${name}`
}

function main() {
  const message = greet('Paul')
  console.log(message)
}

main()

Execution order: main is pushed, then greet, then greet returns and is popped, then console.log runs and is popped, then main returns and is popped.

Web APIs / libuv

The JS runtime itself has no concept of timers or HTTP. These are provided by the host environment. When you call setTimeout or fetch, you're handing work off to the browser's Web APIs (or Node's libuv thread pool). The JS thread is free to continue while this work happens elsewhere.

The Task Queue (Macrotask Queue)

When a Web API finishes, it places its callback in the task queue. Callbacks here include:

  • setTimeout / setInterval callbacks
  • DOM events (click, keypress)
  • MessageChannel messages

The Microtask Queue

The microtask queue has higher priority than the task queue. It drains completely after every task, before the event loop picks up the next one. It includes:

  • Promise .then / .catch / .finally callbacks
  • queueMicrotask()
  • MutationObserver callbacks

The Event Loop

The event loop's job: if the call stack is empty, drain the microtask queue fully, then run the next task, and repeat.

// Pseudocode
while (true) {
  drainMicrotaskQueue()
  runNextTask()
}

Putting It Together

console.log('1')

setTimeout(() => console.log('2'), 0)

Promise.resolve().then(() => console.log('3'))

console.log('4')

Output: 1, 4, 3, 2

Here's why:

  1. console.log('1') — synchronous, runs immediately
  2. setTimeout — callback handed to Web API, will land in task queue after 0ms
  3. Promise.resolve().then(...) — microtask queued
  4. console.log('4') — synchronous, runs immediately
  5. Call stack empty → drain microtask queue → 3 logs
  6. Event loop picks next task → 2 logs

Even though setTimeout(..., 0) was registered before the promise, the microtask queue always drains first.

A Trickier Example

Promise.resolve()
  .then(() => {
    console.log('microtask 1')
    return Promise.resolve()
  })
  .then(() => console.log('microtask 2'))

setTimeout(() => console.log('task 1'), 0)

Output: microtask 1, microtask 2, task 1

Each .then schedules a new microtask. Since the queue drains completely before any task runs, both microtasks finish before task 1 gets a turn.

async/await Under the Hood

async/await is syntactic sugar over promises. await suspends the async function and schedules the continuation as a microtask when the awaited value resolves.

async function fetchData() {
  console.log('before await')
  const data = await Promise.resolve(42)
  console.log('after await', data)
}

fetchData()
console.log('synchronous')

Output: before await, synchronous, after await 42

The code after await behaves like a .then callback — it goes into the microtask queue and runs only after the current synchronous code finishes.

Blocking the Event Loop

Any long-running synchronous code blocks everything else — UI updates, incoming requests, timers — because there's only one thread.

function blockFor(ms) {
  const start = Date.now()
  while (Date.now() - start < ms) {}
}

blockFor(5000) // nothing else runs for 5 seconds

For CPU-intensive work, offload to a Web Worker (browser) or worker_threads (Node.js).

Summary

ContentsRuns
Call StackCurrently executing codeImmediately
Microtask QueuePromise callbacks, queueMicrotaskAfter current task, fully
Task QueuesetTimeout, events, I/OOne per event loop tick

Understanding this model explains a lot of subtle async behavior: why promise chains resolve before timers, why await doesn't actually block, and why long synchronous work is dangerous on a single thread.