MutationObserver is the browser's native way of watching the DOM itself change shape — nodes appearing and disappearing, attributes flipping, text being rewritten — without resorting to the deprecated, synchronous Mutation Events. It complements IntersectionObserver and ResizeObserver: where those two track where an element is and how big it is, MutationObserver tracks what changed structurally inside a subtree. This guide covers the full observe() options schema, the MutationRecord shape, batching semantics, configuration variants, and the gotchas that trip up most production implementations.

Concept Framing

Before MutationObserver shipped, developers who needed to react to DOM changes relied on Mutation Events (DOMNodeInserted, DOMSubtreeModified, and friends) — a set of synchronous DOM events fired individually for every single mutation, in the middle of the mutation itself. A script that appended 500 list items could trigger 500 synchronous event dispatches, each one capable of triggering further mutations, each one blocking the thread until every listener returned. Browsers eventually deprecated the entire family because of the performance and reentrancy hazards it created.

MutationObserver replaces that model with asynchronous, batched delivery. Instead of firing your callback once per mutation, the browser accumulates every qualifying mutation that occurs during the current synchronous task into an ordered list of MutationRecord objects, and delivers the entire list to your callback exactly once, in a microtask queued after the current task completes — before the next paint, but after your synchronous JavaScript finishes running. This means a loop that mutates the DOM a thousand times in a row still produces exactly one callback invocation, with a thousand-entry records array.

The diagram below shows three different mutation types occurring synchronously, being buffered into a single queue, and then crossing the microtask boundary together:

MutationObserver Batching: Synchronous Mutations to Microtask Callback Three synchronous DOM mutations — a childList change, an attribute change, and a characterData change — each generate a MutationRecord that is pushed onto a single queue during the current task. After the task completes, the browser schedules one microtask that delivers the entire batch of records to the MutationObserver callback in one call. Current task (synchronous) childList node appended attributes class changed characterData text node edited MutationRecord queue 3 records buffered takeRecords() drains this queue synchronously microtask boundary callback(records, observer) receives all 3 records in one call

Three practical consequences follow from this model. First, your callback must always iterate the records array — never assume exactly one mutation happened. Second, because delivery is deferred to a microtask, the DOM may have changed again by the time your callback runs, so record.target and record.addedNodes reflect the state at the moment of mutation, not necessarily the current state. Third, calling observer.takeRecords() synchronously drains and returns the pending queue without waiting for the microtask — useful immediately before disconnect() to avoid losing a final batch.

Spec / Signature Reference Table

MutationObserver is constructed with a callback, then configured per target through observe(target, options). Every flag in the options object is opt-in — omitting a category means the browser does no extra bookkeeping for it, so scope options tightly.

observe() option Type Default Effect
childList boolean false Watch for direct child nodes being added or removed
attributes boolean false Watch for attribute value changes on the target
attributeFilter string[] (all attributes) Restrict attributes watching to a named allowlist, e.g. ['class', 'data-state']
attributeOldValue boolean false Capture the attribute's previous value in MutationRecord.oldValue
characterData boolean false Watch for text content changes on Text/Comment/ProcessingInstruction nodes
characterDataOldValue boolean false Capture the previous text in MutationRecord.oldValue
subtree boolean false Extend all of the above watches to every descendant, not just direct children/attributes of the target

Every notification arrives as a MutationRecord. The table below covers the fields your callback reads most often:

MutationRecord field Type Populated when Notes
type "childList" | "attributes" | "characterData" Always Determines which other fields are meaningful
target Node Always The node the mutation occurred on (container for childList, the element for attributes, the text node for characterData)
addedNodes NodeList type === "childList" Nodes inserted since the previous record; empty NodeList otherwise
removedNodes NodeList type === "childList" Nodes removed since the previous record
previousSibling / nextSibling Node | null type === "childList" Position context for the added/removed nodes at mutation time
attributeName string | null type === "attributes" The changed attribute's local name
attributeNamespace string | null type === "attributes" Namespace URI, if the attribute is namespaced (e.g. SVG/XLink)
oldValue string | null attributeOldValue or characterDataOldValue requested Previous attribute value or previous text content

Step-by-Step Implementation

Step 1 — Guard SSR and create the observer

TypeScript
type MutationHandler = (records: MutationRecord[], observer: MutationObserver) => void;

function createMutationWatcher(handler: MutationHandler): MutationObserver | null {
  // SSR guard — MutationObserver does not exist in Node.js
  if (typeof MutationObserver === 'undefined') return null;
  return new MutationObserver(handler);
}
JavaScript
// Plain JS equivalent
function createMutationWatcher(handler) {
  if (typeof MutationObserver === 'undefined') return null;
  return new MutationObserver(handler);
}

Step 2 — Call observe() with an explicit, narrow options object

Request only what you need. Watching childList and attributes together on a large subtree multiplies the number of records the browser must construct on every keystroke or animation frame that touches the DOM.

TypeScript
interface WatchOptions {
  childList?: boolean;
  attributes?: boolean;
  attributeFilter?: string[];
  attributeOldValue?: boolean;
  characterData?: boolean;
  characterDataOldValue?: boolean;
  subtree?: boolean;
}

function watchContainer(
  target: Node,
  observer: MutationObserver,
  options: WatchOptions
): void {
  observer.observe(target, options);
}

// Watch a list container for added/removed rows and a data-state attribute
const listContainer = document.querySelector<HTMLElement>('#item-list')!;
const watcher = createMutationWatcher(handleListMutations);
watcher?.observe(listContainer, {
  childList: true,
  attributes: true,
  attributeFilter: ['data-state'],
  subtree: false,
});

Step 3 — Branch on record.type inside the callback

TypeScript
function handleListMutations(records: MutationRecord[]): void {
  for (const record of records) {
    switch (record.type) {
      case 'childList':
        record.addedNodes.forEach((node) => {
          if (node.nodeType === Node.ELEMENT_NODE) {
            onRowAdded(node as Element);
          }
        });
        record.removedNodes.forEach((node) => {
          if (node.nodeType === Node.ELEMENT_NODE) {
            onRowRemoved(node as Element);
          }
        });
        break;
      case 'attributes':
        if (record.attributeName === 'data-state') {
          onStateAttributeChanged(record.target as Element, record.oldValue);
        }
        break;
      case 'characterData':
        onTextChanged(record.target as CharacterData, record.oldValue);
        break;
    }
  }
}

Step 4 — Flush pending records before teardown

takeRecords() synchronously returns and clears any records the browser has queued but not yet delivered. Call it immediately before disconnect() if your teardown logic depends on processing a final, in-flight batch (for example, persisting the last known DOM state before a route change unmounts the container).

TypeScript
function teardownWatcher(observer: MutationObserver, handler: MutationHandler): void {
  const pending = observer.takeRecords();
  if (pending.length > 0) handler(pending, observer);
  observer.disconnect();
}

Step 5 — Disconnect in the framework's unmount hook

disconnect() stops the observer and discards any records still queued for delivery that you didn't flush with takeRecords(). Always pair every observe() call with a corresponding disconnect() — see Observer Lifecycle & Memory Management for the retention chains that build up when this is skipped.

TypeScript
// React
useEffect(() => {
  const watcher = createMutationWatcher(handleListMutations);
  watcher?.observe(listRef.current!, { childList: true, subtree: true });
  return () => watcher?.disconnect();
}, []);

Configuration Variants

The options object shape doesn't change, but the combinations you choose define an entirely different observation profile. Pick the narrowest variant that satisfies the use case:

Variant Options Use case
Structural only { childList: true } Reacting to rows being appended/removed from a direct-child list
Structural, deep { childList: true, subtree: true } A container whose items are nested inside wrapper elements several levels down
Attribute allowlist { attributes: true, attributeFilter: ['class', 'aria-expanded'] } Watching a specific state-carrying attribute without noise from unrelated attribute churn
Attribute with history { attributes: true, attributeOldValue: true } Undo/redo systems or audit logs that need the previous attribute value
Text-only, deep { characterData: true, subtree: true } A rich-text or live-editing surface where only text edits inside nested Text nodes matter
Full surveillance { childList: true, attributes: true, characterData: true, subtree: true } Diffing/undo engines and DOM-to-virtual-DOM reconciliation shims that must reconstruct the entire change set

The "full surveillance" variant is the most expensive by a wide margin — every insertion, removal, attribute write, and text edit anywhere below the target produces a record. Reserve it for tooling contexts (editors, diagnostics panels) rather than always-on production UI code.

Edge Cases & Gotchas

subtree: true performance cost

subtree: true is the single biggest lever on record volume. Attaching it to document.body means every mutation anywhere on the page — including ones made by third-party scripts, ad iframes' parent-visible DOM, or unrelated components — gets recorded and delivered to your callback. Scope the observed target to the smallest container that actually needs watching, and prefer re-attaching a fresh, narrowly-scoped observer over widening an existing one.

characterData needs the actual text node

characterData: true only fires for mutations on Text, Comment, and ProcessingInstruction nodes — never on the Element that contains them. If you observe a <div> with characterData: true but not subtree: true, you will never see edits to the text inside it, because the text lives on a child Text node, not on the <div> itself. Pair characterData with subtree: true when observing an element ancestor, or observe the Text node directly if you already have a reference to it.

attributeOldValue and characterDataOldValue are opt-in

MutationRecord.oldValue is null unless you explicitly request it. Passing attributes: true alone gives you the mutation's occurrence and the new attribute name, but not its previous value — you must add attributeOldValue: true as well. The same pairing rule applies to characterData and characterDataOldValue. Forgetting the oldValue flag is the most common reason an undo/redo implementation silently breaks.

Reentrancy — mutating the DOM inside your own callback

Because delivery happens in a microtask after your synchronous code finishes, it's easy to write a callback that mutates the observed subtree (for example, normalizing whitespace or auto-formatting attributes), which schedules another batch of records for the next microtask tick. This is safe as long as your handler is idempotent — it should recognize the state it just produced and not loop indefinitely. A common defensive pattern is toggling a isApplyingFix flag around your own writes and skipping records that match your own signature.

Combining with IntersectionObserver / ResizeObserver for dynamic nodes

MutationObserver is frequently the missing piece when IntersectionObserver or ResizeObserver need to track elements that don't exist yet. Since both of those observers only report on elements you've explicitly called observe() on, a container that receives new children at runtime (infinite scroll, dynamically rendered widgets) needs a childList watcher to detect the new node and call intersectionObserver.observe(newNode) on it — and, symmetrically, to call unobserve()/cleanup when the node is removed. The detecting added and removed nodes with MutationObserver guide walks through this exact bridging pattern in full.

When NOT to use MutationObserver

MutationObserver reports DOM structure, attributes, and character data — nothing about rendered geometry. It will not fire when an element's visual size changes due to CSS (flex reflow, font loading, media queries) without a corresponding attribute mutation; use ResizeObserver for that. It will not fire on scroll-driven visibility changes; use IntersectionObserver. And it is a poor substitute for framework-level reactivity — if you're already inside React, Vue, or Angular and control the render path, prefer the framework's own change-detection or effect system over polling the rendered DOM with a mutation watcher.

Framework Integration Patterns

React — a typed hook

TypeScript
import { useEffect, useRef } from 'react';

function useMutationObserver(
  target: React.RefObject<Element>,
  options: MutationObserverInit,
  callback: MutationCallback
): void {
  useEffect(() => {
    if (!target.current || typeof MutationObserver === 'undefined') return;
    const observer = new MutationObserver(callback);
    observer.observe(target.current, options);
    return () => observer.disconnect();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [target.current]);
}

Vue 3 — a composable

TypeScript
import { onMounted, onUnmounted, Ref } from 'vue';

export function useMutationWatcher(
  targetRef: Ref<Element | null>,
  options: MutationObserverInit,
  callback: MutationCallback
): void {
  let observer: MutationObserver | null = null;

  onMounted(() => {
    if (!targetRef.value || typeof MutationObserver === 'undefined') return;
    observer = new MutationObserver(callback);
    observer.observe(targetRef.value, options);
  });

  onUnmounted(() => observer?.disconnect());
}

Angular — a directive

TypeScript
import { Directive, ElementRef, Input, OnDestroy, OnInit } from '@angular/core';

@Directive({ selector: '[appMutationWatch]', standalone: true })
export class MutationWatchDirective implements OnInit, OnDestroy {
  @Input() appMutationWatchOptions: MutationObserverInit = { childList: true };
  private observer: MutationObserver | null = null;

  constructor(private el: ElementRef<Element>) {}

  ngOnInit(): void {
    this.observer = new MutationObserver((records) => this.handleRecords(records));
    this.observer.observe(this.el.nativeElement, this.appMutationWatchOptions);
  }

  private handleRecords(records: MutationRecord[]): void {
    records.forEach((record) => {
      if (record.type === 'childList') {
        // react to added/removed nodes
      }
    });
  }

  ngOnDestroy(): void {
    this.observer?.disconnect();
  }
}

Debugging Checklist

Callback never fires:

  • Confirm at least one of childList, attributes, or characterData is set to true in the options passed to observe() — an empty options object throws a TypeError in some engines and silently does nothing in others.
  • If watching text edits, confirm subtree: true is set when the target is an ancestor Element rather than the Text node itself.
  • Check that observe() was called on a node still attached to the document at call time — mutations on a detached subtree that is later attached elsewhere are still tracked, but a target replaced entirely (rather than mutated) severs the observation.

Missing oldValue:

  • Verify attributeOldValue: true (for attributes) or characterDataOldValue: true (for text) was passed explicitly — these are never inferred from attributes/characterData alone.

Records arriving later than expected:

  • Remember delivery is microtask-scheduled after the current synchronous task. If you need the pending batch immediately (e.g., right before disconnect()), call takeRecords() rather than waiting for the callback.

Too many records / performance regressions:

  • Audit for subtree: true attached to a large or high-churn container. Narrow the target, or split one wide observer into several narrowly-scoped ones only where needed.
  • Check attributeFilter is set when only specific attributes matter — omitting it means every attribute write on the target generates a record, including ones set by browser extensions or third-party scripts.

Reproduction script:

TypeScript
// Paste in DevTools console to inspect live mutation records
const debugObserver = new MutationObserver((records) => {
  console.table(records.map((r) => ({
    type: r.type,
    target: (r.target as Element).tagName ?? r.target.nodeName,
    added: r.addedNodes.length,
    removed: r.removedNodes.length,
    attribute: r.attributeName,
    oldValue: r.oldValue,
  })));
});
debugObserver.observe(document.querySelector('#your-container')!, {
  childList: true,
  attributes: true,
  attributeOldValue: true,
  characterData: true,
  characterDataOldValue: true,
  subtree: true,
});

Chrome DevTools also exposes a built-in alternative for one-off inspection: right-click any element in the Elements panel and choose Break on → Subtree modifications, Attribute modifications, or Node removal to pause script execution the instant a matching mutation occurs, without writing any observer code at all.

Frequently Asked Questions

Why does my MutationObserver callback fire only once even though I made several DOM changes?

MutationObserver batches every mutation that happens within the same synchronous task into one array of MutationRecord objects, then delivers that whole array to your callback in a single microtask after the task finishes. This is by design — it avoids running your callback dozens of times during a loop that appends many nodes. Iterate the records array; each entry describes one discrete mutation.

Do I need attributeOldValue to see what an attribute changed from?

Yes. By default MutationRecord.oldValue is null for attribute mutations. You must explicitly pass both attributes: true and attributeOldValue: true to observe(); only then does oldValue contain the attribute's previous string value. The same rule applies to text content: characterData must be paired with characterDataOldValue to capture the prior text.

Why isn't characterData working when I observe an element?

characterData only reports mutations on Text, Comment, and ProcessingInstruction nodes — never on Element nodes, because elements don't hold character data directly. To catch text edits inside an element's children, observe an ancestor element with both characterData: true and subtree: true, which extends the character-data watch down into every descendant text node.

Is subtree: true expensive for MutationObserver?

It can be. subtree: true tells the browser to watch every descendant of the target, not just its direct children, so a large or frequently-updated container generates a proportionally larger MutationRecord volume. On hot paths — virtualized lists, live-editing surfaces — scope the observed target as narrowly as possible instead of attaching one subtree observer to document.body.

Should I use MutationObserver to detect an element's size changing?

No. MutationObserver reports DOM tree structure, attribute, and text changes — it has no concept of rendered dimensions and will not fire when CSS causes a resize without an attribute change. Use ResizeObserver for content box or border box dimension tracking; reserve MutationObserver for structural and attribute changes.


↑ Back to Core Observer Fundamentals & Browser APIs