Browser Microtasks and the Render Queue
The browser microtask queue is a short-lived queue of callbacks that the JavaScript runtime drains after the current piece of JavaScript finishes and before it starts another ordinary task. Promise handlers such as .then() and .catch() are the most common source of microtasks; queueMicrotask() adds one directly.
This ordering lets asynchronous APIs continue in a predictable way without running callbacks in the middle of the code that scheduled them. It also means a large or endlessly refilling microtask queue can prevent the browser from rendering a frame or handling input.
The problem it solves
JavaScript in a browser generally runs on an event loop. The loop takes work from queues and runs JavaScript to completion: once a function starts, the browser does not interrupt it halfway through to run unrelated JavaScript.
Without a separate mechanism for very short follow-up work, asynchronous APIs would have awkward choices:
- Run a callback immediately, which can cause surprising re-entrancy: an API call would invoke user code before the caller had finished updating its own state.
- Put every callback behind a timer or event task, which would make promise continuations wait alongside larger, less urgent pieces of work.
Microtasks provide a middle ground. They defer a callback until the current JavaScript stack has finished, but give it priority over the next ordinary task.
Tasks and microtasks are different
A task is a unit of browser work that starts JavaScript execution. Examples include running a timer callback, dispatching a user click, processing some network-related work, or starting a script. Developers often call these “macrotasks,” but the browser platform generally uses task.
A microtask is a callback meant to run at the next safe boundary after the current task. Promise reactions and queueMicrotask() callbacks are microtasks. A MutationObserver callback is another browser API that uses this category.
Consider this example:
console.log("start");
setTimeout(() => console.log("timer"), 0);
Promise.resolve().then(() => console.log("promise"));
queueMicrotask(() => console.log("microtask"));
console.log("end");
The output is:
start
end
promise
microtask
timer
The timer does not run immediately, even with a delay of zero. It creates a task that must wait until the current task and its microtasks are complete. The promise handler and the explicit microtask run first because both were queued during the current task.
Their relative order is the order in which they were queued. Here, the promise reaction was queued before queueMicrotask() was called.
How promise callbacks get scheduled
Calling .then(), .catch(), or .finally() registers a reaction on a promise. When that promise settles—meaning it becomes fulfilled or rejected—the browser schedules the reaction as a microtask. It does not call the reaction synchronously inside the code that settles the promise.
If the promise is already settled, registering the handler still schedules it for a later microtask checkpoint. That is why this remains asynchronous:
const ready = Promise.resolve();
ready.then(() => console.log("callback"));
console.log("after registration");
The output is after registration followed by callback.
An async function uses the same machinery. When execution reaches an await, the function yields. Its continuation is scheduled through a promise reaction after the awaited value is available. This is why code after await usually runs before a later timer, but not in the middle of the current synchronous function.
The microtask checkpoint
After a task finishes its synchronous JavaScript, the browser performs a microtask checkpoint: it repeatedly removes the oldest microtask, runs it to completion, and then checks again. A microtask added by another microtask is therefore processed in the same checkpoint.
queueMicrotask(() => {
console.log("first");
queueMicrotask(() => console.log("second"));
});
setTimeout(() => console.log("timer"), 0);
The output is first, second, then timer. The browser does not stop after one microtask to run the timer. It drains the queue until it is empty.
This is the important difference from merely having “small callbacks”: the browser normally reaches a point where it must empty the entire microtask queue before moving on. A loop that continually queues another microtask can therefore starve the rest of the event loop.
Why rendering and input can wait
Rendering is not performed after every JavaScript statement. Browsers look for rendering opportunities between pieces of work, often targeting a display refresh such as 60 frames per second. A typical sequence is:
- Run one task.
- Drain all microtasks.
- If the browser chooses to render, update the page and display a frame.
- Start another task.
The exact scheduling is browser-dependent: a frame is not guaranteed at every opportunity. But the practical rule is reliable: a long task, and the microtasks it produces, can keep the browser from reaching its next rendering opportunity.
That includes promise chains that perform substantial synchronous work:
function keepBusy() {
queueMicrotask(() => {
// Expensive work here still blocks the browser.
doExpensiveWork();
keepBusy();
});
}
The callback is asynchronous in the promise-and-event-loop sense, but it is not parallel and it does not yield to painting. The browser must keep draining the queue, so visible updates can appear frozen.
User input is affected for the same reason. A click, key press, or pointer movement is normally delivered by a task. If the current task is still running, or if its microtask checkpoint never ends, the input task cannot begin. The user may see a delayed click or a page that stops responding even though each individual microtask looks short.
Practical guidance
Use microtasks for small bookkeeping that must happen after the current synchronous operation but before the next task. Do not use a promise chain as a general-purpose loop for expensive work.
If work can be divided into chunks, yield between chunks with a browser scheduling mechanism that creates a later task or otherwise gives the browser a chance to render and process input. Also remember that setTimeout(..., 0) is only a request to run a later task; it does not interrupt the current task or any microtasks already queued.
When an incident mentions “microtask starvation,” “promise callback delay,” or a page that misses frames while JavaScript appears asynchronous, inspect both sides of the boundary: the task that started the work and every microtask it caused. The browser cannot render or handle the next input event until that work finally yields.