MutationObserver delivers records at the microtask checkpoint that follows the code which caused them — after your synchronous work finishes, alongside resolved promises, and always before the browser recalculates style or layout.

Problem / Scenario Context

A rich-text editor listens for DOM changes to keep a word count and a floating toolbar in sync. The word count works perfectly. The toolbar, which reads getBoundingClientRect() on the selection's container inside the observer callback, is sluggish — each keystroke costs an extra layout in the profile, and on long documents typing starts to lag.

Both behaviours come from the same scheduling model. The Rendering Pipeline & Observer Timing topic places it among the other observers; this page explains the microtask part in detail, building on the API basics in MutationObserver DOM change tracking.

Mechanics Explanation

When the DOM is mutated, the engine appends a MutationRecord to the queue of every interested observer. If no delivery is already pending for the agent, it queues a mutation observer microtask. Nothing else happens until the current JavaScript call stack empties.

At the next microtask checkpoint, the microtask queue drains in FIFO order. When the mutation observer microtask runs, it notifies each observer with its accumulated records and clears the queue. Any mutations made during those callbacks queue records again, and because the checkpoint keeps draining until the queue is empty, they are delivered in the same checkpoint.

The practical consequences:

  • Batching is automatic. Two hundred appendChild calls in one loop produce one callback with two hundred records.
  • Ordering with promises follows queue order. A promise resolved before the first mutation runs its .then first; one resolved after runs after.
  • Layout is stale. Style and layout have not run since the mutation. Any geometry read forces a synchronous layout — the cost the editor was paying.
  • It fires in background tabs. Nothing about delivery depends on rendering, unlike the resize loop.

Microtask Ordering Around a MutationA timeline of one task. Synchronous code runs first and makes a mutation and resolves a promise. At the microtask checkpoint, a promise callback queued before the mutation runs first, then the MutationObserver callback, then a promise resolved afterwards. Style and layout only run later, in the rendering steps.One task — microtasks drain in the order they were queuedsync codeyour functionmicrotaskspromise AMutationObserverpromise Brendering stepslayout0ms2ms4ms6ms8ms10msstack emptiesPromise A was resolved before the first mutation, promise B after it. Layout has not run when the observer callback executes.

Comparison Table: MutationObserver vs Neighbouring Mechanisms

Mechanism Delivery point Batches? Layout fresh?
MutationObserver microtask checkpoint yes, per checkpoint no
Promise.then microtask checkpoint no no
queueMicrotask microtask checkpoint no no
Mutation events (removed) synchronously, per change no no
ResizeObserver rendering steps yes, per frame yes
setTimeout(fn, 0) new task no not guaranteed

Minimal Reproducible Example

TypeScript
const log: string[] = [];
const host = document.createElement('div');
document.body.append(host);

new MutationObserver((records) => log.push(`observer: ${records.length} records`))
  .observe(host, { childList: true });

Promise.resolve().then(() => log.push('promise A (before)'));
for (let i = 0; i < 200; i++) host.append(document.createElement('span'));
Promise.resolve().then(() => log.push('promise B (after)'));
log.push('sync done');

setTimeout(() => console.log(log.join('\n')), 0);
// sync done
// promise A (before)
// observer: 200 records
// promise B (after)

Two hundred appends produce one callback. The callback sits between the promises exactly where its microtask was queued — at the first mutation.

Production-Safe Solution

Keep the microtask callback to bookkeeping, and move anything that needs geometry to a point where layout is already fresh. For the editor, that means counting words in the observer and positioning the toolbar in the next frame's ResizeObserver or requestAnimationFrame, reading layout exactly once.

TypeScript
interface EditorSync {
  root: HTMLElement;
  onCount: (words: number) => void;
  onPosition: (rect: DOMRect) => void;
}

export function syncEditor({ root, onCount, onPosition }: EditorSync): () => void {
  let frameScheduled = false;

  const mo = new MutationObserver(() => {
    // Microtask: cheap, layout-free work only.
    onCount(root.textContent?.trim().split(/\s+/).filter(Boolean).length ?? 0);

    // Defer geometry to the rendering steps, once per frame no matter how many records.
    if (frameScheduled) return;
    frameScheduled = true;
    requestAnimationFrame(() => {
      frameScheduled = false;
      const sel = document.getSelection();
      if (!sel || sel.rangeCount === 0) return;
      onPosition(sel.getRangeAt(0).getBoundingClientRect()); // one layout, in the frame
    });
  });

  mo.observe(root, { childList: true, characterData: true, subtree: true });
  return () => mo.disconnect();
}

The rAF read still triggers layout, but it is the layout the frame was going to do anyway — the style and layout that follow rAF are then already clean. The editor drops from two layouts per keystroke to one.

Layouts per Keystroke Before and After Deferring the ReadA bar chart. Reading geometry inside the MutationObserver callback costs two layouts per keystroke, one forced and one for the frame. Deferring the read to requestAnimationFrame costs one layout per keystroke. Counting words only, with no geometry, also costs one, the frame's own.Layout passes per keystroke in a 5,000-word documentread in the observer2 layouts, one forcedread deferred to rAF1 layout, the frame's ownword count only1 layout, no extra reads

Edge Cases: Frameworks, Shadow DOM and Long Checkpoints

Framework renders arrive as one batch. React, Vue and Svelte apply a render's DOM changes synchronously inside one task. Every change in that render lands in the same microtask delivery, so a MutationObserver sees a component's update as a single, internally consistent batch. The flip side is size: a large list re-render can produce thousands of records, and iterating them all with per-record work turns a cheap microtask into a long one.

The microtask checkpoint can itself become a long task. Because the checkpoint drains until the queue is empty, a callback that mutates the observed subtree schedules another delivery that runs before the browser gets a chance to render or handle input. Two observers that each react to the other's writes can ping-pong until the page freezes; unlike the resize loop, there is no depth rule to stop it.

Shadow roots are separate subtrees. subtree: true on a host element does not see mutations inside its shadow root. You must observe the shadow root itself, which is only possible for open roots or from inside the component. Records from each root still arrive in the same checkpoint.

Detached subtrees still deliver. Mutations inside a subtree that has been removed from the document are reported as long as the observer still targets a node in it. That is useful for editors that build content off-document, and a source of confusing records in components that keep working on a detached fragment after unmount.

A Checkpoint That Never EndsThree boxes in a cycle drawn left to right. Observer A normalises whitespace and writes to the subtree. That queues records for observer B, which re-applies a class and writes again. That queues records for observer A. Because each delivery happens in the same microtask checkpoint, rendering and input never get a turn.Observer A writesnormalises text in the subtreeObserver B writesre-applies a class to changed nodesRecords queued againsame checkpoint, no render, noinputBreak the cycle with takeRecords() after self-writes, or by comparing before writing so a second pass changes nothing.

Verification Steps

  • Record a trace while typing and look for a purple Layout block nested inside Run Microtasks — that is a forced layout from the observer.
  • Confirm it disappears after the change; Layout should only appear in the rendering steps.
  • Log records.length in the callback during a paste; one callback with many records confirms batching is working.
  • Hide the tab and mutate the DOM from the console; the observer still fires, but the rAF-deferred read waits.

Common Mistakes to Avoid

  • Assuming the callback is synchronous. Code right after a mutation cannot see the observer's side effects; they happen at the checkpoint.
  • Reading layout per record. A loop that calls getBoundingClientRect() for each of two hundred records forces one layout — then invalidates it if it writes — potentially two hundred times.
  • Mutating the observed subtree from the callback without a guard. It queues more records in the same checkpoint and can spin until the page hangs.
  • Using setTimeout to "wait for" the observer. It works only because tasks run after microtasks; say what you mean with queueMicrotask or restructure.

A Mutation Guard for Self-WritesA short code panel showing a boolean flag set before the callback writes to the observed subtree and cleared after, and a takeRecords call that discards the records the callback itself produced, so its own writes never schedule another delivery.Discard the records your own callback produced// inside the MutationObserver callbackapplyNormalisation(root); // writes to the observed subtreeobserver.takeRecords(); // drop the records those writes queued// the checkpoint now has nothing left to deliver for this observer

FAQ

Is MutationObserver synchronous?

No. It was designed to replace the synchronous mutation events, which fired on every change and were a major performance problem. MutationObserver batches records and delivers them at the next microtask checkpoint.

Why did my promise callback run before the observer?

Microtasks run in the order they were queued. The observer's microtask is queued at the first mutation; any promise resolved before that point runs first.

Can MutationObserver tell me an element's new size?

Not directly, and asking forces a synchronous layout because style and layout have not run yet. For size changes, observe the element with ResizeObserver, which reports after layout.

What does takeRecords do to timing?

It returns and clears the pending records synchronously, so the pending microtask delivers nothing for that observer. It is useful before disconnecting and for discarding records produced by the callback's own writes.


↑ Back to Rendering Pipeline & Observer Timing