The microtask queue
Learn where promise jobs wait, how hosts run microtask checkpoints, why queues can starve timers, and how queueMicrotask compares.
- 01Name the boundaryExplain which promise rules ECMAScript defines and which queue and checkpoint choices belong to a host.
- 02Trace a checkpointPredict task and microtask output by draining every ready microtask after one task finishes.
- 03Avoid starvationRecognize recursive microtasks and choose when a later task is a healthier yield point.
A small queue with a big effect
A browser or Node program does not run every ready callback at the same moment. It finishes the current JavaScript work, then chooses what is allowed to run next. Promise handlers are part of a special queue called the microtask queue.
A microtask is small queued work that a host runs after the current JavaScript task has finished, before it chooses the next task.
setTimeout(() => console.log("timer"), 0);Promise.resolve().then(() => console.log("promise"));console.log("script");Line 1 asks the host to run a timer later. Line 2 makes a ready promise reaction, which becomes a microtask. Line 3 logs immediately. The real output is script, then promise, then timer.
The everyday Microtasks lesson introduces that order. This lesson asks where promise jobs wait and why a host drains them before moving to a timer, event, or other task.
Job queues in the specification
A job is specification language for work that must happen later. ECMAScript says promise reactions are queued and run later in order. It does not own a browser tab, Node event loop, screen, or timer implementation.
Promise.resolve("tea").then(() => console.log("job"));console.log("task");console.log("task still running");Line 1 creates a ready reaction job. Line 2 logs while the current task still owns the stack. Line 3 proves that the task continues. The output is task, task still running, then job.
That split is useful. JavaScript can promise that a then handler is not called inline, while browsers and Node remain free to fit jobs into their own surrounding event loops. The exact queue data structure is host implementation detail.
Think in two layers when debugging. The language tells you that ready promise work is later work. The host tells you when the current task ends and a microtask checkpoint begins.
Promise.resolve("tea").then(() => console.log("job"));console.log("task");console.log("task still running");Line 1 registers the reaction on an already fulfilled promise. Line 2 runs in the task that is already on the stack. Line 3 is still ordinary current-task work. The output is task, task still running, then job.
The word job is deliberately broad. It names a piece of later language-level work, not a visible browser queue widget. When a browser changes its renderer or Node changes its I/O machinery, the promise rule can stay the same.
HostEnqueuePromiseJob
HostEnqueuePromiseJob is the named hook the promise algorithms use to hand a ready promise job to the host. It is not an API in your program. It is the specification boundary between promise rules and a browser or Node queue.
const order = Promise.resolve("tea");order.then(() => console.log("serve tea"));console.log("take next order");Line 1 creates a fulfilled promise. Line 2 registers a handler, so the promise algorithms arrange a later job through the host hook. Line 3 runs before that job. The output is take next order, then serve tea.
The name helps you avoid a common mistake. Promise behavior is specified by ECMAScript, but no JavaScript source line reaches inside Chrome or Node and pushes onto an implementation queue. Your code asks for promise behavior; the host receives the job.
This is why documentation sometimes sounds different across runtimes while basic promise order stays familiar. The shared rule is later promise work. The surrounding task and rendering details belong to the particular 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 standing in for host-owned microtask storage. Lines 3 through 5 define the teaching version of the hook: it receives a job and stores it. Line 7 hands over the tea job. Line 8 still prints first, and line 9 drains the one stored job.
This is not the browser's source code. It is a boundary model: promise algorithms can request that work be queued without ECMAScript deciding how a host represents the queue or when its event loop selects tasks.
Microtask checkpoints
A microtask checkpoint is the host moment that drains the microtask queue until it is empty. A useful teaching model is: run one task, let the JavaScript stack become empty, then drain every microtask before taking the next task.
queueMicrotask(() => { console.log("first"); queueMicrotask(() => console.log("second"));});console.log("script");Lines 1 through 4 queue a callback that queues another callback. Line 5 logs immediately. At the checkpoint, the first callback runs and adds the second. The real output is script, first, then second.
In browsers, hosts also perform checkpoints around event dispatch and rendering opportunities. For example, separate user event listeners can have a checkpoint between them. Treat rendering timing as browser behavior, not as an extra ECMAScript 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 through 6 model the first listener and queue its small follow-up. Lines 9 through 11 name another listener. The last three lines make the two host turns visible in a controlled example. The printed list is first listener, microtask, second listener.
A real click-dispatch algorithm has more detail than this snippet. The useful rule is narrower: do not assume that every callback from one user action is one uninterrupted block. A host may reach a checkpoint between listener callbacks.
Imagine a supermarket checkout with a main queue and an express lane. After serving one person from the main queue, the cashier serves everyone in the express lane, including people who join while it is being served, before taking the next main-queue person.
- In real life: One main checkout line
- In JavaScript: Tasks
- In real life: An express lane
- In JavaScript: Microtasks
- In real life: Cashier serves all express customers
- In JavaScript: A checkpoint drains the queue
- In real life: New express customer joins the lane
- In JavaScript: A microtask queues another
Where the analogy stops: A supermarket has people and several cashiers; JavaScript hosts have more rules than this one-lane teaching model.
Model one checkpoint
The next source is a teaching model, not the browser's engine code. It makes the important rule visible: runTask takes one task, then repeatedly shifts microtasks until no work remains.
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 create the two queues. Line 4 starts one task. Line 6 drains the microtasks array until it is empty. Line 10 prints the modeled result timer, promise.
Replay the small event-loop model: one task runs, then every queued microtask drains.
script
loop.queueMicrotask("follow-up");const output = loop.runTask();console.log(output.join(", "));The replay calls the real lesson model. It queues a follow-up job, runs the timer task, and records the resulting list. It is an instrumented replay, not a debugger paused inside a browser engine.
Notice the deliberate limitation: the model does not render pixels or choose among many task sources. It exists only to make the drain rule easy to test with named arrays.
Microtask starvation
Microtask starvation happens when each microtask keeps adding more microtasks, so the checkpoint never empties. The host cannot move to the timer queue or a rendering opportunity until that draining work stops.
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 through 6 log one microtask and queue another only while the count is below five. Line 7 requests a timer. Line 8 starts the first microtask. The real output prints microtasks 0 through 4 before timer.
The limit is essential. An unbounded version would freeze the lesson sandbox and make the page unresponsive. The safe version proves the ordering without asking a reader to recover from an infinite loop.
Starvation is not a promise bug. It is a scheduling choice. A long sequence of promise reactions, mutations, or manually queued microtasks can keep doing useful work while still preventing input, timers, and painting from getting a turn.
The checkout analogy has one sharp consequence here. The cashier cannot return to the main line while people in the express lane keep adding another person to that same lane. A cap changes the story: once the fifth person is served, the express lane finally empties and the timer can receive a turn.
let rounds = 0; function continueLater() { console.log("round", rounds); rounds += 1; if (rounds < 3) setTimeout(continueLater, 0);} continueLater();Line 1 starts a small counter. Line 5 chooses a timer for the next round instead of immediately adding a microtask. Line 9 starts the first round. The output is round 0, round 1, then round 2, with a fresh host turn between rounds.
Try the bounded queue
Change one input: how many children each modeled microtask adds. The cap keeps the output readable. Compare zero children with one and two, then 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(", "));Choose a child count, then run the bounded model.
Each completed microtask schedules 1 new microtask, up to a visible cap. The timer is shown only after the modeled checkpoint drains.
With zero children, the checkpoint drains immediately. With one child, it makes a chain. With two children, the queue grows faster. In every bounded run, the timer appears only after the displayed microtasks.
Reset restores one child and removes the old output. Each experiment therefore begins with the same model state rather than using leftovers from a previous queue.
queueMicrotask and promise jobs
queueMicrotask explicitly adds a callback to the same FIFO microtask queue used by ready promise jobs. The two APIs differ in how they package work, not in which queue wins over a later task.
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 synchronously. The real output is script, microtask, promise job, then last microtask.
Replay the FIFO order shared by queueMicrotask and promise jobs.
script
loop.queueMicrotask("first");loop.queueMicrotask("second");const output = loop.runTask();console.log(output.join(", "));There is one practical error difference. A throw inside a then handler turns into a rejection for the promise returned by then. A throw inside queueMicrotask is reported as an uncaught error because no wrapper promise exists.
Choose queueMicrotask when you mean “run this small callback after the current stack.” Choose promises when the work naturally has a value or failure path to chain. Neither API is a way to avoid owning errors.
Promise.resolve() .then(() => { throw new Error("promise problem"); }) .catch((error) => console.log("caught", error.message)); queueMicrotask(() => console.log("microtask still runs"));Lines 1 through 5 create a promise chain. The throw on line 3 becomes a rejection, so line 5 logs caught promise problem. Line 7 independently queues another callback, which logs microtask still runs. A throw directly inside that callback would be reported as uncaught because there is no returned promise to catch it.
That difference matters when choosing an API. Use a promise chain when callers need a value and a rejection route. Use queueMicrotask for a small boundary callback, while still handling errors inside it or letting an uncaught error report honestly.
Using the queue in an app
Microtasks are useful for small consistency work after synchronous code finishes. They are not a general background-work system. A cart update can batch a quick recalculation, but a large loop should yield so the browser can respond.
function updateCart() { queueMicrotask(() => console.log("update total"));}updateCart();console.log("click finished");Line 1 names an update boundary. 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 itself, use a task boundary such as a timer or another scheduler appropriate to the app. That gives the host a chance to paint, handle input, and choose other ready work.
A good boundary is small and deliberate. Queue one recalculation after a click, then let it finish. Do not turn a microtask into a hidden work loop: a checkout page should stay responsive even when several cart changes happen quickly.
- A zero-delay timer callback
- A ready
Promise.thenhandler - A
queueMicrotaskcallback - The first script turn
- Choosing when to drain after a task
- Deciding whether to render before another task
Sort each card by the part of scheduling it describes.
Common misconceptions
- “A microtask is a faster timer.” It has a different checkpoint rule.
- “A checkpoint runs one callback.” It drains until the queue is empty.
- “The specification owns the browser queue.” It uses a host hook for promise jobs.
- “queueMicrotask makes errors into rejections.” It has no promise wrapper.
- “Starvation means a timer was lost.” The timer waits for an endless checkpoint.
| Word | What it means | Do not confuse it with |
|---|---|---|
| Microtask | Queued work that runs at a checkpoint | A tiny timer or a faster timer |
| Checkpoint | Drain until the microtask queue is empty | Run exactly one promise callback |
| HostEnqueuePromiseJob | Specification hook from promise rules to the host | A browser API you call directly |
| queueMicrotask error | An uncaught exception route | A rejected promise created by a wrapper |
When output surprises you, write the synchronous logs first. Then list microtasks in creation order, including new ones created by a running microtask. Only after that should you ask which later task can run.
This small trace also stops a vague explanation like “the event loop did it.” A useful explanation names a task, a queued microtask, the checkpoint, and the next task that had to wait.
Practice exercises
Type the first printed word from the timer and promise example.
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 printed word.
setTimeout(() => console.log("timer"), 0);
Promise.resolve().then(() => console.log("promise"));
console.log("script");promise prints second, during the checkpoint before the timer task.
After one task completes, what does a microtask checkpoint drain?
The host drains all ready microtasks after the task, including jobs added during that draining.
What kind of work should the bounded example stop 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 microtask chain must stop after five rounds so the timer can receive 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 yield?
Schedule a task, such as a timer, when work needs to yield to painting and input.
Check your understanding
Read each code question in the same order: current task first, then FIFO microtasks, then a 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 describe?
Choose an answer to see the explanation.
Question 3 of 8At a checkpoint, what happens to a microtask queued by another microtask?
Choose an answer to see the explanation.
Question 4 of 8Why can recursive 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 does an exception in queueMicrotask differ from one in a promise handler?
Choose an answer to see the explanation.
Question 7 of 8Which statement keeps the specification and the host separate?
Choose an answer to see the explanation.
Question 8 of 8What does this bounded 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 defines promise jobs as later ordered work, while hosts own their queues.
- HostEnqueuePromiseJob hands ready promise work to a browser or Node host.
- A checkpoint drains microtasks after a task and before a later task.
- Recursive microtasks can starve timers and rendering until the queue becomes empty.
- Promise jobs and
queueMicrotaskcallbacks share FIFO order, but have different error routes.
Remember the one-liner.
A host runs one task, then drains every ready microtask before choosing the next task.
Coming next: How async code is compiled down.