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
appendChildcalls 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
.thenfirst; 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.
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
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.
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.
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.
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.lengthin 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
setTimeoutto "wait for" the observer. It works only because tasks run after microtasks; say what you mean withqueueMicrotaskor restructure.
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.
Related
- Detecting Added and Removed Nodes with MutationObserver — reading childList records correctly
- Why Observer Callbacks Lag One Frame Behind — the rendering-step counterpart
- Batching DOM Reads and Writes in Observer Callbacks — avoiding the forced layout
↑ Back to Rendering Pipeline & Observer Timing