Free lesson · JavaScript Basic to Advance
The Microtask Queue, Deeply
Going Deeper on the Event Loop
Topic 9.2 introduced the event loop and the two queues. This Topic goes deeper — into the precise rules of the microtask queue, the edge cases around microtask scheduling, and the questions that come up at the senior level when interviewers want to test runtime model depth.
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve()
.then(() => console.log("3"))
.then(() => console.log("4"))
.then(() => console.log("5"));
setTimeout(() => console.log("6"), 0);
console.log("7");
The output: 1, 7, 3, 4, 5, 2, 6. Three microtasks (the three .then) all run before the first setTimeout callback. This is the rule from Topic 9.2 — the microtask queue is fully drained before the event loop pulls the next task. But there's more to it than that. The drain happens each time, with new microtasks added during the drain also being processed before any task runs.
This Topic is for the senior-track reader. If async still feels new, you might skip this on first pass and return after Topics 9.6 and 9.7 are solid. The Interview Lens Topic at the end of the module (9.11) covers most of this material in interview-question form.
Interactive animation: The event loop with its microtask lane - drain every microtask completely, then run one task.
The Microtask Drain Rule, Precisely
When the event loop processes microtasks, it doesn't just process the ones queued at the start. It processes all microtasks, including ones added during processing:
console.log("sync");
Promise.resolve().then(() => {
console.log("microtask 1");
Promise.resolve().then(() => {
console.log("microtask 1.1 (nested)");
Promise.resolve().then(() => {
console.log("microtask 1.1.1 (deeply nested)");
});
});
});
Promise.resolve().then(() => console.log("microtask 2"));
setTimeout(() => console.log("task"), 0);
console.log("sync end");
The output:
sync
sync end
microtask 1
microtask 2
microtask 1.1 (nested)
microtask 1.1.1 (deeply nested)
task
Trace it:
- Synchronous code runs:
sync,sync end. - Microtask drain begins. Queue has
microtask 1andmicrotask 2. - Run
microtask 1. It schedulesmicrotask 1.1. Queue now hasmicrotask 2andmicrotask 1.1. - Run
microtask 2. Queue now hasmicrotask 1.1. - Run
microtask 1.1. It schedulesmicrotask 1.1.1. Queue now hasmicrotask 1.1.1. - Run
microtask 1.1.1. Queue is empty. - Microtask drain ends. Pull a task from the task queue. Run
task.
The microtask queue keeps being processed until it's completely empty. Newly scheduled microtasks during processing are part of the same drain.
This is the rule that produces "microtasks starve the task queue" — if microtasks keep scheduling more microtasks, the task queue never gets a turn. A recursive Promise chain can lock up the page.
Interactive animation: The canonical quiz as a ledger - a promise reaction queued mid-drain still runs before the zero-delay timeout.
The Task Queue Boundary
The task queue, by contrast, processes exactly one task per cycle. After each task, the microtask queue is drained again before the next task runs.
console.log("sync");
setTimeout(() => {
console.log("task 1");
Promise.resolve().then(() => console.log("microtask after task 1"));
}, 0);
setTimeout(() => {
console.log("task 2");
}, 0);
console.log("sync end");
The output: sync, sync end, task 1, microtask after task 1, task 2. The microtask scheduled inside task 1 runs before task 2, even though task 2 was queued earlier than the microtask.
This is the precise rule: between every two consecutive tasks, the entire microtask queue is drained. Even one microtask scheduled in the middle delays the next task.
graph TD
A[Synchronous code] --> B[Drain microtasks]
B --> C[Run one task]
C --> D[Drain microtasks]
D --> E[Run next task]
E --> F[Drain microtasks]
F --> G[...]
style B fill:#10b981,color:#fff
style D fill:#10b981,color:#fff
style F fill:#10b981,color:#fff
style C fill:#3b82f6,color:#fff
style E fill:#3b82f6,color:#fff
This diagram shows the precise event loop ordering. After synchronous code finishes, the microtask queue is drained completely. Then one task is pulled from the task queue and run. After that task, the microtask queue is drained again — including any microtasks scheduled during that task. Then the next task runs. The pattern is: drain microtasks, run one task, drain microtasks, run one task, repeating.
queueMicrotask: Direct Microtask Scheduling
JavaScript provides a direct way to schedule a microtask without going through a Promise:
console.log("sync");
queueMicrotask(() => console.log("queueMicrotask"));
Promise.resolve().then(() => console.log("Promise.then"));
setTimeout(() => console.log("setTimeout"), 0);
console.log("sync end");
The output: sync, sync end, queueMicrotask, Promise.then, setTimeout. queueMicrotask puts its callback directly into the microtask queue, in order with the Promise resolution callbacks.
This is rarely used in application code. It's useful when you specifically want to defer work to "as soon as the current task finishes, before any timers fire" — for things like cleanup after state changes, batching updates, or running checks after a synchronous code path.
Most of the time, Promise.resolve().then(fn) does the same thing with a slightly different idiom. queueMicrotask is the explicit version when you don't have a Promise to attach to.
Interactive animation: What lands in the microtask queue - promise reactions, await resumes and queueMicrotask - and what goes to the task queue.
Await Is a Microtask Point
Every await in an async function is a microtask boundary. The code after the await is queued as a microtask when the awaited Promise resolves.
async function asyncFunc() {
console.log("before await");
await Promise.resolve();
console.log("after await — this is a microtask");
}
console.log("sync 1");
asyncFunc();
console.log("sync 2");
// Output:
// sync 1
// before await
// sync 2
// after await — this is a microtask
When asyncFunc hits the await, it suspends. Control returns to the caller. console.log("sync 2") runs. Then microtasks drain — including the resumption of asyncFunc, which logs after await.
This is the bridge between async/await and the event loop model. Every await produces a microtask boundary. Sequential awaits in the same function produce multiple microtask boundaries — each one yields control briefly to the event loop.
Interactive animation: An async function runs synchronously up to await null, and the rest rejoins as a microtask - A, C, B.
A Subtle Interview Question
A puzzle that probes microtask understanding at the senior level:
async function example() {
console.log("1");
await null; // even awaiting null produces a microtask point
console.log("2");
}
console.log("3");
example();
console.log("4");
The output: 3, 1, 4, 2. Why?
console.log("3")runs synchronously.example()is called. Inside,console.log("1")runs synchronously.await null— JavaScript wrapsnullinPromise.resolve(null). Then suspendsexample, queuing the resumption as a microtask.- Control returns to caller.
console.log("4")runs. - Synchronous code done. Drain microtasks.
exampleresumes, logs"2".
Even await null produces a microtask boundary. The mechanic doesn't care whether the awaited thing is actually async — await always produces at least one microtask delay.
Why the Microtask Queue Exists
Why bother with a separate queue for Promises? Why not just use the task queue for everything?
The answer is about responsiveness. Promise callbacks often represent "the next step after this operation finished." Treating them with high priority means UI updates, state changes, and other reactive logic happen quickly after their triggers, without being delayed by intervening tasks.
If Promise callbacks went on the task queue, a setTimeout(fn, 0) queued before a Promise resolution would run first. State updates derived from Promise results would be delayed. Reactive frameworks would feel sluggish.
The microtask priority ensures Promise-driven workflows happen "as fast as possible while still being async." It's a design choice that prioritizes reactive responsiveness over fairness with timers.
Common Pitfalls
Warning: The microtask traps.
Starving the task queue with infinite microtasks. Recursive Promise chains or recursive
queueMicrotaskcalls can prevent tasks (including UI events) from running. Be careful with recursion in async code.Mixing assumptions about ordering.
setTimeout(fn, 0)doesn't run "immediately." It waits for the microtask queue to drain first. Code that assumessetTimeout(0)is faster thanPromise.resolveis wrong.Assuming
await nullis a no-op. It produces a microtask boundary. If you're in the middle of synchronous logic and doawait nullfor some reason, you've yielded control. Other code can run between the lines around theawait.
Interactive animation: The same self-scheduling loop on each queue - as microtasks it never yields, as tasks the page breathes between rounds.
Why This Matters in Interviews
Interview: The microtask queue is the deep async territory in JavaScript interviews. Senior questions probe the rules: "What's the difference between a microtask and a task?", "When is the microtask queue drained?", "Why does this puzzle output what it does?"
Output-prediction puzzles involving mixed
setTimeout,Promise.resolve, andasync/awaitare extremely common at senior levels. The candidate has to walk through the queue mechanics step by step.Topic 9.11 — Interview Lens: The Event Loop Question — covers the canonical interview question on this material, with the canonical answer pattern.
Try It
Exercise: Predict the exact output of this code:
console.log("A"); setTimeout(() => console.log("B"), 0); Promise.resolve() .then(() => { console.log("C"); setTimeout(() => console.log("D"), 0); Promise.resolve().then(() => console.log("E")); }) .then(() => console.log("F")); async function asyncOp() { console.log("G"); await null; console.log("H"); } asyncOp(); console.log("I");Nine things to log. Trace the rules: synchronous code first, then drain microtasks (including newly scheduled ones), then run one task, drain microtasks again, run next task, etc. The trick is keeping track of which queue each item is in, and what microtasks get scheduled during the drain.
Once you can predict this output reliably, the event loop is mastered.