Print or save as PDF

Choose “Save as PDF” as the destination in your browser's print dialog.

Back

UXAtom Learn · JavaScript

The event loop

Call stack, task queues and microtasks — why your code ran in that order.

Updated August 5, 2026 · 30 min · 2 pages

Summary

JavaScript runs on one thread with one call stack. Anything asynchronous is handed to the host environment, which puts a callback in a queue when it is ready. The event loop moves work from those queues onto the stack, but only when the stack is empty. Microtasks — promise continuations — drain completely between each task, which is why an await resolves before a setTimeout of zero. Almost every ordering surprise in JavaScript follows from those rules.

Contents

  1. 01The call stack
  2. 02Tasks and microtasks

The call stack

JavaScript has a single call stack. One thing executes at a time, and a function runs to completion before anything else gets a turn.

function third() {
  return 'done'
}
function second() {
  return third()
}
function first() {
  return second()
}

first()

The stack grows to first → second → third, then unwinds. Nothing interleaves.

Run-to-completion is a guarantee

Once a function starts, no other JavaScript can run until it returns. This is why you never need locks around a shared variable: no other code can observe an intermediate state.

It is also why one slow function freezes everything:

const start = Date.now()
while (Date.now() - start < 5000) {}
// Nothing else runs for five seconds — no clicks, no timers, no rendering.

Where async work actually happens

The runtime is single-threaded. The environment around it is not.

When you call setTimeout, the timer is not JavaScript — it is the host. When you call fetch, the network request is handled by the browser's networking stack on another thread. Your callback is simply registered.

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

setTimeout returns immediately. The callback is queued for later even with a zero delay, because "later" means "once the stack is empty" — not "in zero milliseconds".

The stack in a stack trace

An error captures the stack at the moment it is thrown. In async code, the stack you get is the one at callback time, not at scheduling time:

function schedule() {
  setTimeout(() => {
    throw new Error('boom')
  }, 0)
}
schedule()
// The trace shows the timer callback. `schedule` is long gone.

Modern engines reconstruct async stack traces across await boundaries, which is one practical reason to prefer async/await over raw callbacks.

Tasks and microtasks

There is not one queue. There are two, and they are drained differently.

QueueSourcesDrained
Task (macrotask)setTimeout, setInterval, I/O, eventsOne per loop iteration
MicrotaskPromise callbacks, queueMicrotask, MutationObserverCompletely, after every task

The rule

After the stack empties, the event loop drains the entire microtask queue before taking the next task. Microtasks added while draining are also processed in the same pass.

console.log('script start')

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

Promise.resolve()
  .then(() => console.log('promise 1'))
  .then(() => console.log('promise 2'))

console.log('script end')

Output:

script start
script end
promise 1
promise 2
timeout

Both promise callbacks run before the timeout, even though the timeout was scheduled first. They are microtasks; the timeout is a task.

await is a microtask boundary

await suspends the function and schedules the remainder as a promise continuation:

async function run() {
  console.log('a')
  await null // still a microtask, even awaiting a non-promise
  console.log('c')
}

run()
console.log('b')
// a, b, c

await null does not skip the queue. Everything after the await becomes a microtask, so the synchronous console.log('b') runs first.

Starving the loop

Because microtasks drain completely, a microtask that schedules another microtask forever prevents any task from ever running:

function spin() {
  Promise.resolve().then(spin)
}
spin()
// Timers never fire. Rendering never happens. The page is frozen —
// but the CPU profile looks like idle promise work, not a busy loop.

Rendering

In a browser, rendering is neither a task nor a microtask — it happens between tasks, at most once per frame. Since microtasks drain before rendering, a long microtask chain delays paint just as effectively as blocking code.

The event loop — uxatom.com/learn/en/courses/event-loop