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:

  1. At mutation time. For each DOM mutation, the engine walks the node's inclusive ancestors looking for registered observers whose options match. A subtree: true registration 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.
  2. Record allocation. Each matching mutation allocates a MutationRecord (and, for childList, NodeLists of added and removed nodes). attributeOldValue and characterDataOldValue add string copies.
  3. 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.

Cost Added to One 6,000-Cell Table RenderA bar chart of main-thread time added to a large table re-render by a document-wide MutationObserver. Engine bookkeeping for record creation added about four milliseconds. A callback that ran querySelector for each of twelve thousand records added about ninety. A callback that deduplicated parents and checked only element nodes added about seven. Narrowing the observed root to the widget's mount point removed the cost entirely for the table's renders.One table re-render, 12,000 records, mid-range laptopengine bookkeeping only~4 msper-record querySelector~90 msdeduplicated batch~7 msnarrow root, table not observed~0 ms

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

TypeScript
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

TypeScript
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.

Deduplicating a Large Record BatchFour boxes. Twelve thousand records arrive in one delivery. Only added element nodes that are still connected are collected into a set. Elements whose ancestor is also in the set are dropped, leaving the top-most new subtrees. One query per top-level subtree finds the matching elements.12,000 recordsone table re-renderAdded elements onlyconnected, elementnodesTop-most subtreesusually one tbodyOne query eachmatches found once

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:

TypeScript
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.

Enqueue in the Microtask, Process in TasksA timeline comparing two approaches after a render task. Processing everything in the observer callback extends the render task by eighty milliseconds. Enqueueing in the callback adds under a millisecond, and the processing runs afterwards in eight-millisecond slices with gaps where input can be handled.After a framework render that adds 3,000 nodesin callbackrenderMO processes allenqueue + drainrenderdraindraindrain0ms20ms40ms60ms80ms100ms120ms

Verification Steps

  • Record a trace of a large render and check the time under Run Microtasks attributable to your callback.
  • Log records.length per 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 document with 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.


↑ Back to MutationObserver DOM Change Tracking