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.
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:
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:
- The call runs on the stack and returns almost immediately.
- The host starts the actual work — a timer, a network request, a DOM listener.
- When that work completes, the host does not push the callback onto the stack. It places it on a queue.
console.log("A")
setTimeout(() => {
console.log("C")
}, 1000)
console.log("B")
// A, B, then C after the timer and after the stack is emptysetTimeoutitself 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 Nodefs.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 queuesonResponseas 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
setTimeoutcannot 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.
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-2A 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 asyncfunction continuations afterawaitqueueMicrotask(fn)MutationObservercallbacks
- A microtask that calls
queueMicrotaskor resolves another Promise adds work to the same checkpoint. - That work runs before the next timer, click, or paint.
queueMicrotaskandPromise.resolve().thenshare one queue. Order is schedule order.- They are not a faster thread. They are a higher-priority queue on the same thread.
awaitis syntax on Promises. Reachingawaitsuspends the function; the remainder is queued as a microtask. Evenawait Promise.resolve()yields.
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 → taskWrapping 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.
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"))setTimeoutstill schedules a task. When that task runs, it callsresolve, which queues.thenas a microtask.- The first
.thenis queued during the current task, so it wins the first checkpoint. delay(0).thencannot 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:
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, 2During the script task:
Promise.resolve().thenimmediately queues its handler on the microtask queue. Nothing logs yet.setTimeouthands the callback to the host. It is not on any JS queue yet.queueMicrotaskqueues the second microtask.console.log(5)is synchronous. It logs 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:
- Promise handler logs 1.
queueMicrotaskcallback logs 3, then queues another microtask.- Nested callback logs 4. Checkpoint done.
Next task:
- 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.
requestAnimationFrameis 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."
| Mechanism | Browser | Node.js |
|---|---|---|
| Timer callback | Task (timer source) | timers phase |
Promise .then / queueMicrotask | Microtask queue | Microtask queue, after nextTick |
process.nextTick | Does not exist | Drains before Promises, after the current operation |
setImmediate | Does not exist | check phase |
setTimeout(fn, 0) vs setImmediate | N/A | Order depends on which phase you scheduled from |
| Rendering | "Update the rendering" | None; this is a server |
process.nextTickis not an HTML microtask, and it has higher priority than Promise jobs.- A
nextTickloop starves I/O the way a microtask loop starves rendering. - Do not port browser ordering puzzles to Node.
setImmediatevssetTimeout(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.
postMessageis 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
whilefrees 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
| API | Queue | Notes |
|---|---|---|
| Sync function call | Call stack, immediately | Blocks everything else |
setTimeout / setInterval | Task | Delay is a lower bound |
addEventListener callback | Task | User-interaction or other DOM source |
postMessage / MessageChannel | Task | Useful for yielding to rendering |
fetch completion then .then | Host I/O, then microtask | The network is not a microtask |
Promise.then / .catch / .finally | Microtask | Even on an already-settled Promise |
queueMicrotask | Microtask | Same queue as Promise jobs |
async function after await | Microtask | await always yields |
MutationObserver | Microtask | Runs in the same checkpoint |
requestAnimationFrame | Rendering | After microtasks, before paint |
requestIdleCallback | Idle task | Not a microtask; may never run under load |
process.nextTick | Node nextTick queue | Before Promise microtasks, each phase |
setImmediate | Node check phase | After poll |
When a log order surprises you:
- What is on the stack right now? That code finishes first, always.
- What was queued as a microtask? It all runs before the next task and before rendering.
- 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.