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:
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
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);
}
// 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.
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
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).
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.
// 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
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
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
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, orcharacterDatais set totruein the options passed toobserve()— an empty options object throws aTypeErrorin some engines and silently does nothing in others. - If watching text edits, confirm
subtree: trueis set when the target is an ancestorElementrather than theTextnode 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) orcharacterDataOldValue: true(for text) was passed explicitly — these are never inferred fromattributes/characterDataalone.
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()), calltakeRecords()rather than waiting for the callback.
Too many records / performance regressions:
- Audit for
subtree: trueattached to a large or high-churn container. Narrow the target, or split one wide observer into several narrowly-scoped ones only where needed. - Check
attributeFilteris 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:
// 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.