Skip to content
Back

The JavaScript Event Loop in Depth

Frontend

How the call stack, Web APIs, task queue, and microtask queue actually cooperate — and why 0ms is never immediate

JavaScript is single-threaded: the engine runs only one piece of JavaScript at a time, on a call stack. The host — browser, Node.js, Deno — does other work in the background and later asks the engine to run callbacks.

The event loop coordinates that handoff. It is not a JavaScript feature. ECMAScript specifies calls and Promise jobs. The HTML Standard specifies a repeating turn: one task, drain all microtasks, maybe update the rendering, then another task.

This note follows Lydia Hallie's visualization: stack, host APIs, task queue, microtask queue, then 5 1 3 4 2. The survey version is Core JavaScript Concepts.


  • Synchronous code runs on the stack.
  • Host APIs finish work off the stack and enqueue a callback.
  • When the stack is empty, the loop empties the microtask queue, then moves one task onto the stack.

1. The Call Stack

The call stack is a last-in, first-out record of what the engine is currently executing. Calling a function pushes an execution context. Returning or throwing pops it. Only the top frame runs.


ts
function add(a: number, b: number): number {
  return a + b
}

function printSum(): void {
  const result = add(2, 3)
  console.log(result)
}

printSum()

  • The stack grows printSum → add, then shrinks as each function returns.
  • Nothing else — no timer, click, or .then — can run while those frames are still there.
  • JavaScript does not preempt the current function.

A tight loop does not share the thread. It owns it:


ts
const start = Date.now()
while (Date.now() - start < 3000) {
  // The page cannot paint, handle clicks, or fire timers.
}

The useful question is not "is this asynchronous?" It is does this keep a frame on the stack, or does it return and let the host finish the work?


2. Web APIs Are the Host, Not the Engine

V8, JavaScriptCore, and SpiderMonkey evaluate JavaScript. They do not implement setTimeout, document.querySelector, or fetch. Those are Web APIs: interfaces the host exposes to script.

When script calls a callback-based host API:

  1. The call runs on the stack and returns almost immediately.
  2. The host starts the actual work — a timer, a network request, a DOM listener.
  3. When that work completes, the host does not push the callback onto the stack. It places it on a queue.

ts
console.log("A")

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

console.log("B")

// A, B, then C after the timer and after the stack is empty

  • setTimeout itself is synchronous. It only schedules.
  • The delay is a lower bound. Whether the callback runs at 1000ms or later depends on the stack and queues, not the timer alone.
  • The same split applies to addEventListener, XMLHttpRequest, MessageChannel, older Node fs.readFile(path, cb).
  • Promise-based APIs still use the host for I/O. The difference is which queue the continuation joins: fetch(url).then(onResponse) waits on the network, then queues onResponse as a microtask.

3. The Task Queue

The HTML Standard calls these tasks. Informal writing says macrotasks. Timers, user input, postMessage, classic <script> evaluation, and many networking callbacks become tasks.

  • The loop runs one selected task, then a microtask checkpoint, then another task.
  • A nested setTimeout cannot continue in its parent's turn — it is future work.
  • One global FIFO queue is a cartoon. The host has task sources and may pick among queues.
  • Script evaluation is itself a task. Top-level module code is that task running to completion.

ts
const order: string[] = []

setTimeout(() => {
  order.push("timeout-1")
  setTimeout(() => {
    order.push("timeout-2")
  }, 0)
}, 0)

setTimeout(() => {
  order.push("timeout-3")
}, 0)

// Typical order: timeout-1 → timeout-3 → timeout-2

A delay of 0 means "as soon as you are allowed to run a timer task," not "before the next line." Nested-timer clamping (HTML's 4ms after nesting level 5) is secondary. The primary delay is the event loop.

The zero-delay callback waits for:

  • The current task to finish and the stack to empty.
  • Every microtask queued so far, and every microtask those microtasks enqueue.
  • Any earlier tasks already waiting.

Failure: a zero-delay timer still loses to Promise.resolve().then(...). The timer is ready. It is in the wrong queue.


4. The Microtask Queue

After a task finishes and the stack is empty, the host performs a microtask checkpoint: run the oldest microtask, repeat until the queue is empty.

Sources that enqueue microtasks:

  • Promise reaction jobs: .then, .catch, .finally
  • async function continuations after await
  • queueMicrotask(fn)
  • MutationObserver callbacks

  • A microtask that calls queueMicrotask or resolves another Promise adds work to the same checkpoint.
  • That work runs before the next timer, click, or paint.
  • queueMicrotask and Promise.resolve().then share one queue. Order is schedule order.
  • They are not a faster thread. They are a higher-priority queue on the same thread.
  • await is syntax on Promises. Reaching await suspends the function; the remainder is queued as a microtask. Even await Promise.resolve() yields.

ts
const order: string[] = []

setTimeout(() => {
  order.push("task")
}, 0)

queueMicrotask(() => {
  order.push("micro-1")
  queueMicrotask(() => {
    order.push("micro-2")
  })
})

Promise.resolve().then(() => {
  order.push("promise")
})

// After the timeout finally runs:
// micro-1 → promise → micro-2 → task

Wrapping a callback-based API in a Promise does not turn the host work into a microtask. It changes what happens after the host task runs.


ts
function delay(ms: number): Promise<void> {
  return new Promise((resolve) => {
    setTimeout(resolve, ms)
  })
}

Promise.resolve().then(() => console.log("microtask first"))
setTimeout(() => console.log("task second"), 0)
delay(0).then(() => console.log("microtask after its timer task"))

  • setTimeout still schedules a task. When that task runs, it calls resolve, which queues .then as a microtask.
  • The first .then is queued during the current task, so it wins the first checkpoint.
  • delay(0).then cannot run until its timer task has run.

Failure: an unbounded microtask chain never returns to the task queue or to rendering. A then that always schedules another then can freeze a page without a while.


5. Walkthrough: Why the Log Is 5 1 3 4 2

Hallie's challenge is the shortest program that forces every part of the model to show up:


ts
Promise.resolve().then(() => console.log(1))

setTimeout(() => console.log(2), 10)

queueMicrotask(() => {
  console.log(3)
  queueMicrotask(() => console.log(4))
})

console.log(5)

// 5, 1, 3, 4, 2

During the script task:

  1. Promise.resolve().then immediately queues its handler on the microtask queue. Nothing logs yet.
  2. setTimeout hands the callback to the host. It is not on any JS queue yet.
  3. queueMicrotask queues the second microtask.
  4. console.log(5) is synchronous. It logs 5.
  5. When the timer expires, the host enqueues () => console.log(2) on the task queue.

The script task finishes. The stack is empty.

Microtask checkpoint:

  1. Promise handler logs 1.
  2. queueMicrotask callback logs 3, then queues another microtask.
  3. Nested callback logs 4. Checkpoint done.

Next task:

  1. Timer callback logs 2.

  • If the 10ms timer had not expired yet, the log would still be 5 1 3 4 2 — the timer would join the task queue later.
  • Microtasks do not wait for the timer. The timer waits for microtasks.
  • The puzzle is when the stack empties and which queue each callback joined.

6. The HTML Event-Loop Algorithm

A browser event-loop turn, closer to the spec:


  • The checkpoint can run whenever the JS stack is empty, not only at the end of a named "turn." Promise reactions never interrupt the function that resolved them.
  • Rendering is optional. The browser aims at the display refresh, not "after every task." Skipping a frame because the main thread is busy is jank.
  • requestAnimationFrame is not a microtask. It runs during "update the rendering," after the checkpoint and before style and layout.
  • The critical rendering path is what happens inside that rendering branch. The event loop decides when the branch is allowed to run.

Failure: await in a tight loop yields microtasks, not tasks. To give the browser a chance to paint, the work has to return to a task — setTimeout, scheduler.yield(), MessageChannel, or a Worker.


7. Node.js Is a Different Host

The language is the same. The loop is not. Node.js is driven by libuv phases, not HTML's "task, microtasks, maybe render."


MechanismBrowserNode.js
Timer callbackTask (timer source)timers phase
Promise .then / queueMicrotaskMicrotask queueMicrotask queue, after nextTick
process.nextTickDoes not existDrains before Promises, after the current operation
setImmediateDoes not existcheck phase
setTimeout(fn, 0) vs setImmediateN/AOrder depends on which phase you scheduled from
Rendering"Update the rendering"None; this is a server

  • process.nextTick is not an HTML microtask, and it has higher priority than Promise jobs.
  • A nextTick loop starves I/O the way a microtask loop starves rendering.
  • Do not port browser ordering puzzles to Node. setImmediate vs setTimeout(fn, 0) from the main module is famously unordered.
  • Deno and Bun sit closer to the browser for timers and microtasks, then add their own I/O.

The portable rule: know the host's queues, not only the language's Promises.


8. Workers Have Their Own Loops

A Web Worker, Shared Worker, or Service Worker is another agent: its own heap, stack, task and microtask queues, and event loop.

  • postMessage is a host API. The message is serialized and queued as a task on the receiving agent.
  • A worker can compute while the page paints. It cannot touch the page's DOM.
  • Sharing memory requires SharedArrayBuffer. It does not merge the two loops.
  • Offloading a while frees the page's stack. It does not make the worker's stack preemptive.

Failure: a worker can still starve its timers and message handlers with a long task or a microtask chain.


9. What Enqueues Where

APIQueueNotes
Sync function callCall stack, immediatelyBlocks everything else
setTimeout / setIntervalTaskDelay is a lower bound
addEventListener callbackTaskUser-interaction or other DOM source
postMessage / MessageChannelTaskUseful for yielding to rendering
fetch completion then .thenHost I/O, then microtaskThe network is not a microtask
Promise.then / .catch / .finallyMicrotaskEven on an already-settled Promise
queueMicrotaskMicrotaskSame queue as Promise jobs
async function after awaitMicrotaskawait always yields
MutationObserverMicrotaskRuns in the same checkpoint
requestAnimationFrameRenderingAfter microtasks, before paint
requestIdleCallbackIdle taskNot a microtask; may never run under load
process.nextTickNode nextTick queueBefore Promise microtasks, each phase
setImmediateNode check phaseAfter poll

When a log order surprises you:

  1. What is on the stack right now? That code finishes first, always.
  2. What was queued as a microtask? It all runs before the next task and before rendering.
  3. What must wait for a later task? Timers, input, messages, and the next script.

The event loop is not magic and it is not parallelism. It is a policy for when a single thread is allowed to run the next callback.


Recap Q&A