Language Internals
Part 2 of 6 · JS/TS Language ProficiencyEvent Loop — Call Stack, Microtasks, Macrotasks & async/await
Call stack, microtasks vs macrotasks, async/await scheduling, Node vs browser loop quirks.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Question ladder
L1
What runs before any queued callback?
Answer
The current call stack, to completion.
L2
What is a microtask?
Answer
A job that drains after the stack and before the next macrotask. Promise reactions and queueMicrotask are microtasks.
L3
What is a macrotask in the browser?
Answer
One task from the task queue, such as a timer or a UI event. Rendering usually gets a chance between tasks.
L4
What does await do to the thread?
Answer
It yields. The async function returns a promise and resumes later as a microtask when the awaited thenable settles.
L5
Why is await in a for loop a latency bug?
Answer
Each iteration waits for the previous promise. Independent work belongs in Promise.all.
L6
Where does process.nextTick run?
Answer
In Node, before promise microtasks. A nextTick loop can starve I/O.
L7
How do you stall timers without a busy spin on the CPU?
Answer
Keep queueing microtasks or nextTick callbacks. The macrotask queue never starts, so timers and I/O appear stuck.
Failure modes
Timer assumed to beat a then
setTimeout of 0 is a macrotask. It runs after microtasks queued in the current turn.
Microtask starvation
Each microtask schedules another, so the next timer or I/O callback never starts.
nextTick starvation
A Node nextTick loop runs before promise jobs and can prevent the poll phase from making progress.
Sequential await
Independent requests are awaited in a loop, so latency sums instead of overlapping.
Unhandled rejection
An async function is called and nobody attaches catch or await, so the rejection surfaces as an unhandled rejection.
Misconceptions
setTimeout of 0 runs immediately after the current line.
The current stack finishes, then microtasks drain, then the timer task runs.
await blocks the thread like a sleep.
The function suspends. Other tasks can run before it resumes.
Node and the browser share one diagram.
Both have tasks and microtasks. Node also has phases and a nextTick queue that runs before promise jobs.
Interviewer traps
Drawing the Node phase list and skipping the log order.
Order the four lines first. Then place nextTick before promise microtasks.
Saying the event loop is one queue.
Name the stack, the microtask queue, and the task queue. Say which one drains fully.
Promising that a timer of 0 waits zero milliseconds.
The delay is a minimum. Nested timers are clamped in browsers, and a busy microtask queue delays the task further.
Design scenario
Same prompt for every reader.
Requirements
Explain the queue that is winning, how you would prove starvation, and how you would yield to the task queue on purpose.
Traffic / scale
Tens of thousands of promise reactions per second on one realm.
Latency
The yield callback should run on the next task, after the current microtask checkpoint.
Consistency
State updated in a then-callback is visible before any timer queued in the same turn.
Availability
The process stays scheduled. Timers and sockets must still make progress.
Failure assumptions
- User code can queue a microtask from inside a microtask.
- A nextTick loop is enough to delay I/O.
- There is one thread for this realm. Workers are a separate loop.
Constraints
- Do not propose a framework scheduler.
- Name microtasks, macrotasks, and nextTick.
Prompt
A health check uses setTimeout of 0 to yield, but under load the callback runs seconds later while CPU is busy inside promise chains.
Which queue should this callback join?
Prefer
Microtask when the turn must finish
Promise reactions and queueMicrotask run before the next timer, I/O callback, or paint. Use them to keep one logical turn consistent.
- Then-callbacks see state from the current turn.
- The queue drains fully, including jobs added during the drain.
- async/await resumes here.
Alternative
Macrotask when you must yield
Timers, I/O, and UI events are tasks. One task runs, then microtasks drain again. This is how you let the loop breathe.
- A zero delay is still a later turn.
- Browsers can render between tasks.
- An infinite microtask loop never reaches this queue.
One turn
The source diagram is a cycle. This ladder is a single pass through it.
- 1
Run the call stack
Synchronous frames finish. Nothing queued runs yet. - 2
Drain microtasks
Promise jobs, queueMicrotask, and MutationObserver callbacks, including jobs scheduled by those jobs. - 3
Run one macrotask
A timer, an I/O callback, or a UI event. Then go back to the microtask drain. - 4
Render if the browser can
Browsers typically get a rendering chance after microtasks and before the next task.
Overview
The event loop is the scheduler. JavaScript in a realm does not preempt the function that is running. When the stack is empty, queued work starts, and the kind of queue decides who goes first.
That is the whole interview. The rest is a table of which API uses which queue, plus two host-specific exceptions: browser rendering, and Node's process.nextTick.
Core model
- Push frames on the call stack until it is empty.
- Drain the microtask queue completely, including microtasks scheduled while draining.
- Run one macrotask (a task), then repeat.
- In browsers, a rendering opportunity typically sits after microtasks and before the next task.
Flow
- 1
1. Call stack runs
- next2. Drain microtasks
- 2
2. Drain microtasks
- next3. Run one macrotask
- 3
3. Run one macrotask
- next1. Call stack runs
Lesson map
Event Loop — Call Stack, Microtasks, Macrotasks & async/await
Call stack, microtasks vs macrotasks, async/await scheduling, Node vs browser loop quirks.
Architecture. Architecture
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB stack["1. Call stack runs"] micro["2. Drain microtasks"] macro["3. Run one macrotask"] stack -->|1. Call stack runs| micro micro -->|2. Drain microtasks| macro macro -->|3. Run one macrotask| stack
The edge back to the stack is the loop. It is not a second thread.
What enqueues where
| Source | Queue | Notes |
|---|---|---|
Promise.then / catch / finally | Microtask | Also queueMicrotask and MutationObserver |
Resume after await | Microtask | The awaited thenable settles, then the resume is scheduled |
setTimeout / setInterval | Macrotask | Delays are minimums. Browsers clamp nested timers |
setImmediate (Node) | Check phase | Node-only. It is a task-like callback after poll, not a browser API |
process.nextTick (Node) | Before microtasks | A nextTick loop can starve I/O |
| I/O callbacks (libuv) | Macrotask phases | Timers, pending, poll, check, close |
| UI events | Macrotask | A click handler is a task |
Node's phases (timers, pending callbacks, poll, check, close) are still tasks. Between phases, Node drains nextTick and then promise microtasks. You do not need the phase list to order a then ahead of a timer. You need it when I/O seems frozen.
async/await
await does not block the thread. The async function returns a promise immediately and resumes later.
async function load() {
const a = await fetchJson("/a");
const b = await fetchJson("/b");
return a + b;
}
// Roughly: return fetchJson("/a").then((a) => fetchJson("/b").then((b) => a + b));There is no network in the page sandbox, so the runnable below only records the schedule. In production, await inside a for loop runs the steps one after another. Promise.all starts the independent calls together. Accidental sequential awaits are a latency bug, not a correctness style.
Press Run. Snippets must be self-contained — no network, files, or native modules.
Microtasks versus macrotasks
Expected order: 1 sync-start, 2 sync-end, 3 micro-1, 4 micro-2, 5 timeout-0. The second microtask is queued during the drain, and it still runs before the timer.
console.log("1 sync-start");
setTimeout(() => console.log("5 timeout-0"), 0);
Promise.resolve()
.then(() => {
console.log("3 micro-1");
return Promise.resolve();
})
.then(() => console.log("4 micro-2"));
console.log("2 sync-end");Press Run. Snippets must be self-contained — no network, files, or native modules.
Unhandled rejections
Calling an async function that throws, and ignoring the returned promise, schedules a rejection. Nothing in the current stack catches it.
async function boom() {
throw new Error("no awaiter");
}
boom();
// Prefer: void boom().catch((err) => console.error(err));Press Run. Snippets must be self-contained — no network, files, or native modules.
The host reports the real case on a later turn. The rule is the same: if nobody awaits or attaches catch, the rejection is unhandled.
Node versus the browser
| Concern | Browser | Node |
|---|---|---|
| Diagram | Tasks, microtasks, and a render chance | Phases: timers, pending, poll, check, close |
process.nextTick | Not available | Runs before promise microtasks |
| Starvation | An endless microtask drain freezes UI and timers | An endless nextTick or microtask drain starves I/O |
| Other loops | Web Workers, each with a loop | worker_threads and child processes |
Decisions
- 1
1. Stack empty
- next2. Node nextTick queued?
- ?
2. Node nextTick queued?
- yes3. Drain nextTick
- no4. Drain promise microtasks
- 3
3. Drain nextTick
- next4. Drain promise microtasks
- 4
4. Drain promise microtasks
- next5. One loop phase task
- 5
5. One loop phase task
- next1. Stack empty
Browsers skip the nextTick box. They still drain microtasks before the next task.
Interview Q&A
Why does Promise.resolve().then often beat setTimeout of 0?
Answer
Then-callbacks are microtasks. Timeouts are macrotasks. After the current stack, microtasks drain before the next timer task. A delay of 0 is a minimum, not a promise to run before jobs already queued as microtasks.
Does await block the thread?
Answer
No. The async function returns a promise and suspends. When the awaited thenable settles, the resume is scheduled as a microtask. Other tasks can run in between.
What is microtask starvation?
Answer
Code that keeps scheduling microtasks, or Node nextTick callbacks, never lets the loop take the next macrotask. Timers and I/O look stuck even though the thread is busy.
What does run to completion mean?
Answer
The function on the stack finishes before any queued callback starts. A long synchronous loop freezes the realm. await is the yield point. A tight for loop without await is not.
Why can a microtask queued inside a microtask still beat a timer?
Answer
The drain is a loop, not a snapshot. Jobs added during the drain join the same checkpoint. The timer waits until that loop is empty.
When do you want a macrotask on purpose?
Answer
When you need to yield so a timer, I/O, or paint can run. setTimeout of 0 is the blunt instrument. It does not run "next line". It runs on a later task.
How is Node's loop different in one sentence?
Answer
Same tasks and microtasks, plus phases and a nextTick queue that runs before promise jobs. setImmediate is the check phase. It is not a browser API.
What do you say in the first minute?
Answer
Stack, then all microtasks, then one macrotask. Promises and await are microtasks. Timers and I/O are macrotasks. In Node, nextTick jumps ahead of promises. An endless microtask chain starves the rest.
Pitfalls
- Explaining a log order with "JavaScript is async" and no queue name.
- Using
awaitin a loop for independent calls. - Assuming
setTimeout(fn, 0)is a microtask. - Draining
process.nextTickforever and then blaming the socket. - Forgetting
catchon a promise you intentionally do not await. - Treating a Worker as a second stack in the same realm.
Add a queueMicrotask log and, on paper, a process.nextTick log beside the promise and the timer. Write the order for the browser (no nextTick) and the order for Node. Say which callback you would move to a timer if the microtask queue were growing without bound.