arthrop0d

Brief articles on varied topics.

Browser Microtasks and the Render Queue

javascriptweb development

A microtask is a short piece of JavaScript the browser runs at a checkpoint between larger units of work. The microtask queue holds these callbacks until the current JavaScript task finishes; the browser then drains the queue before moving on, often before it renders a new frame.

This is where promise callbacks and mutation observer callbacks run. Understanding that timing explains familiar symptoms such as a UI that does not repaint until a large update completes, or an event loop that appears stuck even though each individual callback is small.

The problem microtasks solve

A browser receives many kinds of work: a click handler, a timer callback, a network response, or a script loaded by the page. Each such unit is a task: work the browser takes from one of its task queues and runs to completion.

JavaScript in a task is run synchronously. While it is running, the browser does not interrupt it to run another JavaScript callback or paint the page. Without a separate deferred queue, an API that needed to notify code “after this operation” would have to call it immediately, causing surprising re-entrancy, or schedule another task, causing the callback to wait behind unrelated browser work.

Microtasks provide a middle ground. They defer a callback until the current synchronous operation has finished, but run it before the browser starts another task. This gives code a chance to finish updating related state as one logical turn.

A microtask is deferred, but it is not a future frame. It normally runs before the browser gets an opportunity to paint.

Promises use the queue

A promise represents the eventual result of an asynchronous operation. A callback registered with then, catch, or finally is called a promise reaction—the internal work that invokes that handler—and is scheduled as a microtask.

Even an already-settled promise does not call a newly attached handler inline:

console.log("start");

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

console.log("end");

The output is:

start
end
promise callback

The promise callback waits until the current task reaches its end. This prevents then from behaving differently depending on whether the promise settled before or after the handler was attached. It also lets a chain of promise handlers advance in a predictable, deferred order.

A callback that queues another microtask joins the same drain. Consequently, a promise chain can run many callbacks before the browser handles the next click, timer, or network event.

Mutation observers use the queue

A mutation observer watches a DOM node for changes such as child insertion, removal, or attribute changes. Its callback is not normally invoked at the exact line that changes the DOM. Instead, the browser records a mutation record, a description of the change, and schedules observer delivery as a microtask.

const observer = new MutationObserver((records) => {
  console.log(`changes: ${records.length}`);
});

observer.observe(document.body, { childList: true });
document.body.append(document.createElement("div"));
console.log("DOM changed");

The log prints DOM changed before the observer callback. Several changes made during one task can be delivered together, rather than causing a callback after every individual DOM operation. That batching is useful for libraries that need to react to the final shape of a set of synchronous changes.

Mutation observers fit the microtask timing for the same reason promises do: code that changes the DOM can finish its whole synchronous update first, while observers still run before unrelated tasks and usually before the next render.

What a microtask checkpoint does

A typical browser turn looks like this:

  1. The event loop selects a task, such as a click handler or timer callback.
  2. JavaScript runs that task to completion.
  3. The browser performs a microtask checkpoint: it removes microtasks from the queue and runs them in queue order.
  4. If those callbacks add more microtasks, the browser runs those too before the checkpoint ends.
  5. The browser may perform rendering when it reaches a suitable rendering opportunity, then selects another task.

The exact event-loop algorithm has additional checkpoints and browser-specific scheduling decisions, so rendering is not guaranteed after every task. The important practical rule is that a normal render opportunity does not interrupt a task or the microtask checkpoint that follows it.

This ordering makes a promise callback useful for observing state after synchronous work:

let status = "old";

status = "new";
Promise.resolve().then(() => {
  console.log(status); // "new"
});

The callback sees the completed synchronous update, yet it runs before a later timer task.

How microtasks starve the browser

Draining the queue completely is normally what makes microtasks predictable, but it has a sharp edge: the browser must keep draining as long as callbacks keep adding work.

function keepRunning() {
  queueMicrotask(keepRunning);
}

keepRunning();

queueMicrotask adds a function directly to the microtask queue. In this example, the queue is never empty. The browser cannot reach the next task or a rendering opportunity, so clicks are not processed and the screen may stop updating. A long promise chain or repeated DOM-observer activity can produce the same effect without looking like an infinite loop in one function.

Even a finite but large microtask batch can delay a frame. This often appears in performance traces as a long “microtask” or promise-related block, and it explains why changing state or DOM nodes does not immediately make the change visible.

If work can be divided, deliberately yield back to the browser by scheduling a later task—for example with setTimeout—rather than continuously queuing microtasks. That gives the event loop a chance to handle input and rendering. Use microtasks for small, follow-up consistency work; do not treat them as a general-purpose background queue.

The concise mental model is: tasks are the browser’s larger turns; microtasks are the cleanup and continuation work that must finish before the browser starts another turn or usually paints.

← All articles