arthrop0d

Brief articles on varied topics.

Browser Microtasks and the Render Queue

javascriptweb development

A browser microtask checkpoint is the point where it runs all pending microtasks—small pieces of JavaScript work that must run soon after the current code—before continuing with other browser work. Promise callbacks such as .then() and .catch() run as microtasks.

The checkpoint gives JavaScript a predictable ordering: synchronous code finishes first, promise reactions run next, and only then can the browser move on to another task or usually update the screen.

The problem this ordering solves

Browsers handle many independent events: clicks, timers, network completions, keyboard input, and rendering. JavaScript runs on the browser's main thread alongside much of that work, so it cannot run everything at once.

A task is one scheduled unit of work, such as a click handler, a timer callback, or a script loaded by a network response. If the browser ran every callback immediately whenever it was created, code could interrupt itself in surprising places. If it waited too long to run follow-up work, promise-based APIs would feel inconsistent.

Microtasks provide a middle layer. They let an API say, in effect, “run this after the current JavaScript finishes, but before the browser handles the next ordinary event.” Promises use this layer so that callbacks behave asynchronously even when a promise is already settled.

Without that rule, these two cases would behave differently:

const ready = Promise.resolve("ok");

ready.then(value => console.log(value));
console.log("after");

The output is always:

after
ok

The promise callback does not interrupt the current call stack. It is queued for a microtask checkpoint.

The browser’s basic turn

A simplified browser turn looks like this:

  1. Run one task, such as a timer or event handler.
  2. When that task finishes, perform a microtask checkpoint.
  3. If appropriate, run animation callbacks and update the rendering.
  4. Select another task and repeat.

The browser is allowed to make rendering decisions based on timing, visibility, and available work, so it does not necessarily paint after every task. But a microtask checkpoint normally happens before a rendering opportunity is used. This is why promise callbacks can change the DOM before the user sees an intermediate state.

A checkpoint drains the microtask queue completely. If one microtask queues another, the new microtask runs in the same checkpoint rather than waiting for a later task:

queueMicrotask(() => {
  console.log("first");
  queueMicrotask(() => console.log("third"));
});

queueMicrotask(() => console.log("second"));

The output is first, second, third. The queue is first-in, first-out, and newly added microtasks join the same draining process.

queueMicrotask() directly schedules a microtask. Promise reactions—callbacks registered with .then(), .catch(), or .finally()—also become microtasks. Mutation observers, which report certain DOM changes, use the same general category of checkpoint work.

How promise callbacks are ordered

Calling .then() does not run its callback immediately. When the promise settles, the browser queues the reaction associated with that callback. If the promise was already settled, registering the callback still queues it rather than calling it synchronously.

For example:

console.log("start");

setTimeout(() => console.log("timer"), 0);

Promise.resolve().then(() => console.log("promise"));

console.log("end");

The usual output is:

start
end
promise
timer

The top-level script is itself run as a task. Its synchronous statements print start and end, and it schedules both a timer task and a promise microtask. Once the script task ends, the browser reaches a checkpoint and drains promise. Only afterward can the timer task run.

This is not because a zero-delay timer means “run now.” It means “queue a task no earlier than the timer’s allowed time.” A promise reaction is already waiting in the microtask queue at the end of the current task, so it gets its turn first.

Ordering among promise callbacks follows when their reactions are queued. This means that code which settles a promise, attaches handlers, or queues additional microtasks can affect the observed order. Promise chains also enqueue each next reaction only after the previous callback has completed and its result has been processed.

Microtasks and rendering

Consider code that changes the DOM twice:

status.textContent = "loading";

Promise.resolve().then(() => {
  status.textContent = "ready";
});

The browser may perform the checkpoint before its next rendering opportunity. In that case, the user sees ready without ever seeing a painted loading state. The first assignment happened synchronously, but the browser did not have to paint between the task and its microtasks.

Animation callbacks scheduled with requestAnimationFrame() are associated with a rendering opportunity. In broad terms, microtasks queued by the preceding task are drained before that opportunity, while the animation callback runs as part of the browser’s rendering-related work. Exact frame timing remains under browser control, and a page that is hidden or not ready to render may not receive a callback at the expected moment.

The cost of “run all microtasks”

The checkpoint’s completeness is useful, but it can also delay input and painting. A microtask that continually queues another microtask can prevent the checkpoint from ending:

function keepRunning() {
  queueMicrotask(keepRunning);
}

keepRunning();

This does not yield to the browser. Rendering and user-input tasks can be postponed indefinitely, producing an unresponsive page. Long promise chains or large batches of microtasks can cause a less obvious version of the same problem.

To give the browser a chance to handle input and render, work sometimes needs to yield through a task boundary—for example, by scheduling a timer or another browser task—rather than chaining more promises.

The practical rule is: synchronous JavaScript finishes, the browser drains microtasks, and only then does it proceed to later tasks and an eligible rendering opportunity. That rule explains many “why did this log appear first?”, “why did the DOM skip this state?”, and “why did this promise run before my timer?” observations in browser code.

← All articles