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.