subtree: true makes the engine check your observer on every mutation anywhere below the target — usually cheap per mutation, but large framework renders can then deliver thousands of records in one microtask; observe the narrowest root that works, filter options tightly, and process records in bounded, deduplicated batches.
Problem / Scenario Context
A third-party chat widget injects itself into pages by watching document.documentElement with { childList: true, subtree: true, attributes: true, characterData: true } so it can re-attach when single-page apps re-render. On one customer's React dashboard, a table re-render replaces 6,000 cells; the widget's callback receives 12,000 records in one delivery, iterates them with querySelector calls, and adds a 90 ms block to every table update. The dashboard team only discovers it because their interaction latency doubled after adding the widget.
Wide observation is sometimes necessary. Unbounded processing never is. The MutationObserver DOM Change Tracking topic introduces the options; this page is about their cost.
Mechanics Explanation
Costs arise in three places:
- At mutation time. For each DOM mutation, the engine walks the node's inclusive ancestors looking for registered observers whose options match. A
subtree: trueregistration high in the tree matches almost everything. The per-mutation check is small, but it is paid on every mutation by every script on the page, including the framework's own renders. - Record allocation. Each matching mutation allocates a
MutationRecord(and, forchildList,NodeLists of added and removed nodes).attributeOldValueandcharacterDataOldValueadd string copies. - In the callback. The callback runs at the microtask checkpoint of the task that mutated the DOM, so its time is added to that task — usually a framework render or an event handler. Iterating thousands of records with per-record DOM queries is where most of the real cost comes from.
The engine cost is proportional to the number of mutations; the callback cost to the number of records and what you do per record. The latter is fully under your control.
Comparison Table: Narrowing Strategies
| Strategy | Reduces engine cost? | Reduces records? | Trade-off |
|---|---|---|---|
Observe a narrow root instead of document |
yes | yes | must know where changes happen |
Drop attributes/characterData if unused |
yes | yes | none |
attributeFilter instead of attributes: true |
yes | yes | must list names |
Drop subtree, observe several specific parents |
yes | yes | more registrations |
| Deduplicate records in the callback | no | no — but less work per record | small code |
| Yield between chunks of records | no | no — spreads work | callback becomes async |
Minimal Reproducible Example
const mo = new MutationObserver((records) => {
for (const r of records) {
for (const n of r.addedNodes) {
if ((n as Element).querySelector?.('[data-chat-anchor]')) reattach(); // per node, per record
}
}
});
mo.observe(document.documentElement, { childList: true, subtree: true, attributes: true, characterData: true });
declare function reattach(): void;
Record a trace while the dashboard re-renders its table: the table's task grows by the callback's time under Run Microtasks.
Production-Safe Solution
interface WatchOptions {
root: Element; // as narrow as possible
selector: string; // what we are waiting for
onFound: (el: Element) => void;
}
export function watchForSelector({ root, selector, onFound }: WatchOptions): () => void {
const seen = new WeakSet<Element>();
const scan = (candidates: Iterable<Element>): void => {
for (const c of candidates) {
if (c.matches(selector) && !seen.has(c)) { seen.add(c); onFound(c); }
// One querySelectorAll per *top-level added subtree*, not per descendant record.
for (const m of c.querySelectorAll(selector)) if (!seen.has(m)) { seen.add(m); onFound(m); }
}
};
const mo = new MutationObserver((records) => {
// 1. Collect unique, still-connected, top-most added elements.
const added = new Set<Element>();
for (const r of records) {
for (const n of r.addedNodes) {
if (n.nodeType === Node.ELEMENT_NODE && (n as Element).isConnected) added.add(n as Element);
}
}
// 2. Drop elements whose ancestor is also in the set — scanning the ancestor covers them.
const tops = [...added].filter((el) => {
for (let p = el.parentElement; p && p !== root; p = p.parentElement) if (added.has(p)) return false;
return true;
});
scan(tops);
});
mo.observe(root, { childList: true, subtree: true }); // no attributes, no characterData
scan([root]); // existing matches
return () => mo.disconnect();
}
For the chat widget, the narrowest workable root is the element the host page gave it, with only childList observation. If it genuinely must observe the whole document, the deduplication in steps 1 and 2 turns 12,000 records into a handful of top-level subtrees to scan — for a table re-render, typically just the new tbody.
When Batches Are Still Large
Some workloads really do need to process every added node — a syntax highlighter tagging every new code token, a translation tool rewriting every new text node. For those, split the work across tasks so the framework render that caused it is not held hostage:
const queue: Element[] = [];
let draining = false;
const mo = new MutationObserver((records) => {
for (const r of records) r.addedNodes.forEach((n) => n.nodeType === 1 && queue.push(n as Element));
if (!draining) { draining = true; void drain(); }
});
async function drain(): Promise<void> {
let start = performance.now();
while (queue.length) {
const el = queue.shift()!;
if (el.isConnected) process(el);
if (performance.now() - start > 8) { await new Promise((r) => setTimeout(r)); start = performance.now(); }
}
draining = false;
}
declare function process(el: Element): void;
The observer callback now only enqueues, which is cheap and keeps the mutating task short; processing happens in later tasks with room for input between them. The general technique is in yielding from observer callbacks.
Verification Steps
- Record a trace of a large render and check the time under Run Microtasks attributable to your callback.
- Log
records.lengthper delivery to see the real batch sizes on production pages. - Remove unused options (
attributes,characterData) and confirm record counts drop. - Measure interaction latency of the host page's heaviest interaction before and after adding your observer.
- Test with a page that renders tens of thousands of nodes to find your worst case.
Common Mistakes to Avoid
- Observing
documentwith every option "to be safe". Each option multiplies records. - Per-record DOM queries. Deduplicate to top-level subtrees first.
- Processing detached nodes. Records can describe nodes already removed again; check
isConnected. - Heavy work inside the callback. It lengthens someone else's task — often the host page's interaction.
FAQ
Is subtree: true expensive by itself?
The per-mutation matching cost is small. The expense comes from the number of records it produces on busy pages and from what the callback does with them. Narrowing options and deduplicating records removes most of it.
Why does my callback's time appear inside a click handler's task?
Because MutationObserver delivers at the microtask checkpoint of the task that mutated the DOM. If a click handler caused the render, your callback runs before that task ends, adding to the interaction's processing time.
Should third-party scripts observe the whole document?
Only when there is no narrower option, and then with minimal options and bounded work. A widget that is given a mount element should observe that element, not the page.
Do removed nodes also need processing?
Only if you hold state for them. Use removedNodes to release per-element state, or store that state in a WeakMap so removed elements are released automatically.
Can I pause observation during a known bulk update?
Yes: disconnect before the bulk operation and observe again afterwards, then scan the affected region once. Call takeRecords before disconnecting if you must not miss earlier changes.
Related
- Watching Attribute Changes with attributeFilter — narrowing attribute records
- Using takeRecords Before Disconnect — pausing without losing records
- MutationObserver vs IntersectionObserver: When to Use Each — choosing the right observer
↑ Back to MutationObserver DOM Change Tracking