Watching the DOM for changes
MutationObserver reports DOM changes — added or removed nodes, attribute edits, and text-content changes — asynchronously and in batches, replacing the older (and synchronous) Mutation Events API. Because it is asynchronous and batched, it is far cheaper for performance-sensitive work like live component trees, rich-text editors, or accessibility tools that need to react to external DOM manipulation. The tool’s entire behaviour is driven by the options object you pass to observe(), and the results arrive as MutationRecord entries in your callback. This reference documents every option and every record property, and validates your config against the rule that at least one of childList, attributes, or characterData must be enabled.
How it works
You create an observer with a callback, then call observe(target, options):
const mo = new MutationObserver((records) => {
for (const r of records) {
if (r.type === "childList") console.log(r.addedNodes, r.removedNodes);
if (r.type === "attributes") console.log(r.attributeName, r.oldValue);
}
});
mo.observe(node, {
childList: true,
attributes: true,
attributeOldValue: true,
subtree: true,
});
At least one of childList, attributes, or characterData must be true — if none is set, observe() throws a TypeError. subtree extends whichever flags you enabled to the entire descendant tree. The *OldValue flags and attributeFilter implicitly activate their related primary option. Callbacks run as microtasks after the current synchronous task finishes, delivering a coalesced batch — multiple mutations from one task arrive together.
All options at a glance
| Option | What it watches | Notes |
|---|---|---|
childList | Nodes added or removed as direct children | Required if watching DOM structure |
attributes | Any attribute changed on the target | Set attributeFilter to narrow scope |
characterData | Text-node content changes | Target must be a text node or use subtree |
subtree | Widens any above flag to all descendants | Cannot be used alone |
attributeOldValue | Captures attribute’s previous value | Implies attributes: true |
characterDataOldValue | Captures text’s previous value | Implies characterData: true |
attributeFilter | Restricts to named attributes only | Array of strings, e.g. ["class","style"] |
Common patterns and pitfalls
Watching a component tree: { childList: true, subtree: true } reports any node added or removed anywhere under the root, which is the right config for tracking framework-rendered output.
Watching a specific attribute: { attributes: true, attributeFilter: ["aria-expanded"] } fires only when aria-expanded changes, filtering out class and style churn that would otherwise flood the callback.
Feedback loops: mutating the DOM inside the callback can re-trigger the observer before it finishes. Guard with a flag or disconnect, mutate, then re-observe.
Cleanup: call mo.disconnect() when the observer is no longer needed. To drain pending records before disconnecting, call mo.takeRecords() first — records returned this way will not appear in the callback.
Invalid configs to avoid: { subtree: true } alone throws. So does any config where all three primary flags (childList, attributes, characterData) are absent or false, even if other flags are set.