Skip to content

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.