The microtask queue
Learn where promise jobs wait, how hosts run microtask checkpoints, why queues can starve timers, and how queueMicrotask compares.
- 01Tell the two layers apartSay which promise rules come from ECMAScript, the JavaScript rulebook, and which queue and checkpoint choices belong to the browser or Node.
- 02Follow a checkpointPredict the output of tasks and microtasks: after one task finishes, every waiting microtask runs before the next task.
- 03Avoid starvationSpot microtasks that keep adding microtasks, and know when to use a later task instead, so the page gets a turn.
A small queue with a big effect
A callback is a function you hand over to be called later. A browser or Node does not run every waiting callback at once. First, the code that is running now finishes. Then the browser or Node picks what runs next.
In this lesson, the browser or Node is called the host: the program that runs your JavaScript. The host runs work in pieces:
- A task is one piece of work the host runs from start to end. The whole script is a task. So is one timer callback, or one click handler.
- A queue is a waiting line. The first one in is the first one out.
- Promise callbacks wait in a special line called the microtask queue.
A microtask is a small piece of work that waits in the microtask queue. The host runs it at a microtask checkpoint: a moment when the host stops and runs every waiting microtask. A common checkpoint is right after the current code finishes, before the host picks its next task. Each host decides its exact checkpoints.
setTimeout(() => console.log("timer"), 0);Promise.resolve().then(() => console.log("promise"));console.log("script");Line 1 asks the host to run a timer callback later. setTimeout(callback, 0) means "run this as a new task, as soon as you can". Line 2 makes a promise that is already done and gives it a then callback. Because the promise is ready, the callback goes into the microtask queue. Line 3 logs right away. The output is script, then promise, then timer.
The Microtasks lesson showed this order. This lesson looks closer. Where do promise jobs wait? And why does the host run all of them before a timer, a click, or any other task?
Promise jobs in the language rules
ECMAScript is the official rulebook for JavaScript. People also call it the specification, or the spec. The spec calls work that must happen later a job. When a promise is done and has a then callback, the spec makes a promise job to call it.
The spec says promise jobs wait and run later, in order. It does not describe browser tabs, Node, the screen, or timers. Those belong to the host.
Promise.resolve("tea").then(() => console.log("job"));console.log("task");console.log("task still running");Line 1 makes a promise job for the then callback. Line 2 logs while the current task is still running. Line 3 shows that the task keeps going. The output is task, task still running, then job.
This split is useful. The spec can promise that a then callback is never called in the middle of your current code. At the same time, browsers and Node are free to fit jobs into their own way of running tasks. How they store the queue inside is up to them.
So when you debug, think in two layers:
- The language says: a ready promise callback runs later.
- The host says when the current task ends and when a checkpoint starts.
Promise.resolve("tea").then(() => console.log("job"));console.log("task");console.log("task still running");Run the same three lines yourself. Line 1 adds the callback to a promise that is already done. Lines 2 and 3 are ordinary code in the current task. The output is again task, task still running, then job.
The word job is broad on purpose. It means a piece of later work in the language rules. It is not a queue you can see in the browser. If a browser changes how it draws the page, or Node changes how it reads files, the promise rule stays the same.
HostEnqueuePromiseJob: the hand-over
HostEnqueuePromiseJob is a step in the spec. "Enqueue" means "add to a queue". The promise rules use this step to hand a ready promise job to the host.
You can't call it from your code. It marks the place where the promise rules end and the browser's or Node's queue begins.
const order = Promise.resolve("tea");order.then(() => console.log("serve tea"));console.log("take next order");Line 1 makes a promise that is already done. Line 2 adds a callback. The promise rules then use HostEnqueuePromiseJob to hand a job for that callback to the host. Line 3 runs before that job. The output is take next order, then serve tea.
The name helps you avoid a common mistake. ECMAScript sets the promise rules. But no line of your code reaches inside Chrome or Node and pushes onto their queue. Your code asks for promise behavior. The host receives the job.
This is why the docs for different hosts can sound different, while the basic promise order stays the same. The shared rule is: promise callbacks run later. The tasks around them, and when the page is drawn, depend on the host.
const hostMicrotasks = []; function HostEnqueuePromiseJob(job) { hostMicrotasks.push(job);} HostEnqueuePromiseJob(() => console.log("serve tea"));console.log("take next order");hostMicrotasks.shift()();Line 1 is a plain array. It stands in for the host's microtask queue. Lines 3 to 5 are a pretend version of the step: it takes a job and stores it. Line 7 hands over the tea job. Line 8 still prints first. Line 9 takes the stored job out and calls it. shift() removes the first item of an array and returns it.
HostEnqueuePromiseJob hands it to the browser or Node, which keeps it in its own queue until it is time to run it.This is not the browser's real code. It only shows the hand-over. The promise rules ask for a job to be queued. They don't say how the host stores the queue, or when it picks its next task.
Microtask checkpoints
A microtask checkpoint is the moment the host runs the microtask queue until it is empty. People call this draining the queue. A simple way to picture one turn of the host:
- Run one task.
- Wait until the JavaScript stack is empty. The stack is the list of functions running right now. Empty means no JavaScript is running.
- Run every microtask, one by one, until none are left.
- Only then, pick the next task.
Hosts have other checkpoints too, for example between two click listeners. You'll see that later in this section.
queueMicrotask(() => { console.log("first"); queueMicrotask(() => console.log("second"));});console.log("script");Lines 1 to 4 queue a callback. That callback, on line 3, queues one more callback. Line 5 logs right away. At the checkpoint, the first callback runs and adds the second one to the queue. The checkpoint keeps going, so the second one runs too. The output is script, first, then second.
first runs, it adds second to the same queue. The checkpoint keeps going until the queue is empty, so second also runs before any later task.Browsers also run checkpoints around events, and before they draw the page. For example, when a user clicks, there can be a checkpoint between two click listeners. When the page gets drawn is a browser rule, not an extra promise rule.
const log = []; function firstListener() { log.push("first listener"); queueMicrotask(() => log.push("microtask"));} function secondListener() { log.push("second listener");} firstListener();queueMicrotask(secondListener);queueMicrotask(() => console.log(log.join(", ")));Lines 3 to 6 stand in for the first listener. It logs, then queues a small follow-up microtask. Lines 8 to 10 stand in for a second listener. Line 12 runs the first listener. Lines 13 and 14 queue the second listener and a final log, behind the follow-up. The printed list is first listener, microtask, second listener.
A real click has more steps than this. The point is smaller: don't assume every callback from one click runs as one unbroken block. The host may run a checkpoint between two listeners.
Picture a shop with one cashier and two lines: a main line and an express lane. After serving one person from the main line, the cashier serves everyone in the express lane. That includes people who join the express lane while the cashier is serving it. Only when the express lane is empty does the cashier take the next person from the main line.
- In real life: The main checkout line
- In JavaScript: Tasks
- In real life: The express lane
- In JavaScript: Microtasks
- In real life: The cashier serves the whole express lane
- In JavaScript: A checkpoint drains the queue
- In real life: A new person joins the express lane
- In JavaScript: A microtask queues another
A small model of one checkpoint
The next code is a teaching model. It is not the browser's real code. It shows one rule clearly: runTask takes one task, then takes microtasks out one by one until none are left.
const tasks = ["timer"];const microtasks = ["promise"]; function runTask(label) { const output = [label]; while (microtasks.length) output.push(microtasks.shift()); return output;} console.log(runTask(tasks.shift()).join(", "));Lines 1 and 2 make the two queues as arrays. Lines 4 to 8 define runTask, which runs one task. Line 6 is the drain: while the microtasks array still has items, it moves the first one into the output. Line 10 runs the timer task and prints timer, promise.
Step through the small model: one task runs, then every waiting microtask runs.
script
loop.queueMicrotask("follow-up");const output = loop.runTask();console.log(output.join(", "));The step-through calls the lesson's real model. It queues a follow-up job, runs the timer task, and shows the result list. It replays the model code. It is not a debugger paused inside a browser.
The model leaves things out on purpose. It does not draw the page. It does not choose between many kinds of tasks. It only makes the drain rule easy to see and test.
Microtask starvation
Microtask starvation happens when each microtask adds another microtask, so the checkpoint never ends. The host can't get to the timer, or draw the page, until the microtasks stop. The waiting work "starves": it never gets a turn.
let rounds = 0;function addMicrotask() { console.log("microtask", rounds); rounds += 1; if (rounds < 5) queueMicrotask(addMicrotask);}setTimeout(() => console.log("timer"), 0);queueMicrotask(addMicrotask);Line 1 counts rounds. Lines 2 to 6 define addMicrotask. It logs one round, adds 1 to the count, and queues itself again while the count is below 5. Line 7 asks for a timer. Line 8 starts the first microtask. The output is microtask 0 to microtask 4, then timer.
rounds reaches 5. Only then can the timer run.The limit of 5 matters. A version without a limit would never stop. It would freeze the page, and you would have to close the tab.
Starvation is not a promise bug. It comes from how the work is scheduled. A long chain of promise callbacks or queued microtasks can do useful work, and still keep clicks, timers, and drawing from getting a turn.
Back to the checkout lines: the cashier can't go back to the main line while each person in the express lane calls one more friend into it. With the limit, the fifth person brings no one. The express lane empties, and the timer gets its turn.
let rounds = 0; function continueLater() { console.log("round", rounds); rounds += 1; if (rounds < 3) setTimeout(continueLater, 0);} continueLater();Yielding means pausing your work so the host can run other things first. Line 1 starts a counter. Lines 3 to 7 define continueLater. Line 6 asks for a timer for the next round, instead of adding a microtask. Line 9 starts the first round. The output is round 0, round 1, then round 2. Between rounds, the host gets a fresh turn.
Try it: how fast the queue grows
Change one setting: how many new microtasks each microtask adds. The playground calls these its "children". The model stops at 12 microtasks, so the output stays short. Try 0, then 1, then 2, and watch where the timer appears.
const tasks = ["timer"];
const microtasks = ["promise"];
function runTask(label) {
const output = [label];
while (microtasks.length) output.push(microtasks.shift());
return output;
}
console.log(runTask(tasks.shift()).join(", "));Pick a number, then press Run.
Each microtask adds 1 new microtask, until the model reaches 12. The timer comes only after every microtask has run.
With 0 children, the queue is empty after the first microtask. With 1 child, each microtask adds the next one, like a chain. With 2 children, the queue grows faster. In every run, the timer comes only after all the microtasks shown.
Reset goes back to 1 child and clears the old output. So every try starts from the same state.
queueMicrotask and promise jobs share one queue
queueMicrotask(callback) adds a callback straight to the microtask queue. Ready promise jobs wait in that same queue. The queue is FIFO: first in, first out. So callbacks run in the order they were added, whichever way they were added.
queueMicrotask(() => console.log("microtask"));Promise.resolve().then(() => console.log("promise job"));queueMicrotask(() => console.log("last microtask"));console.log("script");Line 1 queues the first microtask. Line 2 queues a promise job behind it. Line 3 queues one more callback. Line 4 logs right away. The output is script, microtask, promise job, then last microtask.
Step through the first-in, first-out order that queueMicrotask and promise jobs share.
script
loop.queueMicrotask("first");loop.queueMicrotask("second");const output = loop.runTask();console.log(output.join(", "));There is one real difference between the two. It shows when the callback throws an error:
- A throw inside a
thencallback makes the promise returned bythenfail. A.catchlater in the chain can handle it. - A throw inside a
queueMicrotaskcallback is reported as an uncaught error. There is no promise around it to catch it.
Use queueMicrotask when you mean "run this small callback right after the current code". Use promises when the work has a result or an error to pass along. With either one, you still have to handle errors.
Promise.resolve() .then(() => { throw new Error("promise problem"); }) .catch((error) => console.log("caught", error.message)); queueMicrotask(() => console.log("microtask still runs"));Lines 1 to 5 make a promise chain. The throw on line 3 makes the chain fail, so the .catch on line 5 logs caught promise problem. Line 7 queues a separate callback, which logs microtask still runs.
The output is microtask still runs first, then caught promise problem. The .catch callback is only queued after the throw on line 3 happens. By then, the callback from line 7 is already waiting in the queue.
If the callback on line 7 threw an error, nothing would catch it. There is no promise for it to fail.
So pick by what you need. Use a promise chain when the caller needs a result and a way to handle errors. Use queueMicrotask for a small callback right after the current code, and handle errors inside it.
Using the queue in an app
Microtasks are good for small tidy-up work right after the current code finishes. They are not for heavy work. For example, after a cart changes, you can queue one quick update of the total. But a big loop should pause, so the browser can respond to the user.
function updateCart() { queueMicrotask(() => console.log("update total"));}updateCart();console.log("click finished");Lines 1 to 3 define updateCart. Line 2 queues the quick total update. Line 4 calls the function. Line 5 finishes the click handler first. The output is click finished, then update total.
If the queued work keeps adding more of itself, use a task instead, such as a timer. That lets the host draw the page, handle clicks, and run other waiting work.
Keep each microtask small and on purpose. Queue one update after a click, and let it finish. Don't let a microtask quietly turn into a loop that never ends. A checkout page should stay quick, even when the cart changes many times in a row.
- A timer callback with a delay of 0
- A
thencallback on a promise that is done - A
queueMicrotaskcallback - Running the whole script the first time
- Choosing when to run the microtasks after a task
- Deciding whether to draw the page before the next task
Sort each card: is it a task, a microtask, or something the host decides?
Common misconceptions
- "A microtask is just a faster timer." It is not a timer at all. It runs at a checkpoint, before the next task.
- "A checkpoint runs one callback." It keeps running callbacks until the queue is empty.
- "The spec controls the browser's queue." The spec only hands promise jobs to the host, through HostEnqueuePromiseJob.
- "queueMicrotask turns errors into failed promises." There is no promise around its callback, so an error is reported as uncaught.
- "Starvation means a timer got lost." The timer is still waiting. It runs once the checkpoint ends.
Each row of this table is one word from the lesson: what it means, and what it is easy to mix it up with.
| Word | What it means | What it is not |
|---|---|---|
| Microtask | Small work that waits in a queue and runs at a checkpoint. | A tiny timer, or a faster timer. |
| Checkpoint | Running microtasks until the queue is empty. | Running exactly one promise callback. |
| HostEnqueuePromiseJob | The spec step that hands a promise job to the host. | A browser function you can call yourself. |
| Error in queueMicrotask | It is reported as an uncaught error. | A failed promise that .catch can handle. |
When an output surprises you, write it out in this order:
- The normal logs from the current code.
- The microtasks, in the order they were queued. Include new ones that a running microtask adds.
- Only then, the next task, such as a timer.
This also stops vague answers like "the event loop did it". The event loop is the host's cycle: pick a task, run it, run a checkpoint, repeat. A useful answer names the task, the queued microtask, the checkpoint, and the task that had to wait.
Practice exercises
Type the first word this timer and promise example prints.
setTimeout(() => console.log("timer"), 0);
Promise.resolve().then(() => console.log("promise"));
console.log("script");script prints first, because line 3 runs in the current task.
Type the second word it prints.
setTimeout(() => console.log("timer"), 0);
Promise.resolve().then(() => console.log("promise"));
console.log("script");promise prints second. It runs at the checkpoint, before the timer task.
After one task finishes, what does a microtask checkpoint run until it is empty?
The host runs all waiting microtasks after the task, including ones added while it runs them.
In the safe example, what kind of work stops after five rounds?
let rounds = 0;
function addMicrotask() {
console.log("microtask", rounds);
rounds += 1;
if (rounds < 5) queueMicrotask(addMicrotask);
}
setTimeout(() => console.log("timer"), 0);
queueMicrotask(addMicrotask);The chain of microtasks must stop after five rounds, so the timer gets a turn.
Which callback runs first in the mixed example?
queueMicrotask(() => console.log("microtask"));
Promise.resolve().then(() => console.log("promise job"));
queueMicrotask(() => console.log("last microtask"));
console.log("script");The first queueMicrotask callback runs first, before the promise job.
A product page keeps queueing microtasks while the user scrolls. What can it use to pause, so the page can respond?
Schedule a task, such as a timer, so the page can draw itself and respond to the user.
Check your understanding
Read each code question in the same order: the current code first, then the microtasks in the order they were queued, then any later task.
Question 1 of 8What prints first?
Read the code, then predictPromise.resolve().then(() => console.log("promise")); console.log("script");Choose an answer to see the explanation.
Question 2 of 8What does HostEnqueuePromiseJob do?
Choose an answer to see the explanation.
Question 3 of 8At a checkpoint, what happens to a microtask that another microtask added?
Choose an answer to see the explanation.
Question 4 of 8Why can microtasks that keep adding microtasks starve a timer?
Choose an answer to see the explanation.
Question 5 of 8What does this print?
Read the code, then predictqueueMicrotask(() => console.log("a")); Promise.resolve().then(() => console.log("b")); console.log("script");Choose an answer to see the explanation.
Question 6 of 8How is an error thrown in a queueMicrotask callback different from one thrown in a promise callback?
Choose an answer to see the explanation.
Question 7 of 8Which sentence keeps the spec and the host apart?
Choose an answer to see the explanation.
Question 8 of 8What does this small model print?
Read the code, then predictconst jobs = ["promise"]; const output = ["task"]; while (jobs.length) output.push(jobs.shift()); console.log(output.join(", "));Choose an answer to see the explanation.
Key takeaways
- ECMAScript says promise jobs run later, in order. The host (a browser or Node) owns the real queues.
- HostEnqueuePromiseJob is the spec step that hands a ready promise job to the host.
- A checkpoint runs every waiting microtask after a task, before the next task.
- Microtasks that keep adding microtasks stop timers and page drawing from getting a turn, until the queue is empty.
- Promise jobs and
queueMicrotaskcallbacks share one first-in, first-out queue, but handle errors differently.
Remember the one-liner.
A host runs one task, then runs every waiting microtask, then picks the next task.
Coming next: How async code is compiled down.