MutationObserver
Watch DOM mutations without polling — observe options, batching, microtask delivery, and safe patterns for widgets and analytics.
- javascript
- mutationobserver
You need to know when the DOM changes — a third-party script injects a node, a framework rewrites a subtree, user content appears. Polling innerHTML is wasteful. MutationObserver delivers batched records of what changed, as microtasks, after the current stack clears.
API sketch
const observer = new MutationObserver((mutations, obs) => {
for (const m of mutations) {
if (m.type === 'childList') {
m.addedNodes.forEach((n) => {
if (n.nodeType === Node.ELEMENT_NODE) {
// handle new elements
}
});
}
if (m.type === 'attributes') {
console.log(m.attributeName, m.oldValue);
}
}
});
observer.observe(document.getElementById('root'), {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class', 'data-state'],
attributeOldValue: true,
characterData: false,
});
// later
observer.disconnect();
You must call observe with at least one of childList, attributes, or characterData true. Empty options throw.
What you get in a record
| Field | Meaning |
|---|---|
type |
'childList', 'attributes', 'characterData' |
target |
Node that was observed / whose children changed |
addedNodes / removedNodes |
NodeLists for childList |
attributeName |
For attributes |
oldValue |
Only if you opted into old value flags |
Records are batched. Many DOM writes in one turn → one callback with many records, not one callback per write.
Microtask timing
MutationObserver callbacks are queued as microtasks (similar priority family as promises). That means:
el.appendChild(child);
Promise.resolve().then(() => console.log('promise'));
// observer runs in microtask checkpoint — order vs other microtasks is defined by when they were scheduled
console.log('sync done');
Don’t assume observer runs before every .then; assume “soon after this task, before paint / next macrotask,” and write idempotent handlers.
Practical patterns
React to third-party DOM
function whenAppears(selector, root = document.body) {
const existing = root.querySelector(selector);
if (existing) return Promise.resolve(existing);
return new Promise((resolve) => {
const obs = new MutationObserver(() => {
const el = root.querySelector(selector);
if (el) {
obs.disconnect();
resolve(el);
}
});
obs.observe(root, { childList: true, subtree: true });
});
}
Avoid feedback loops
If your callback mutates the same tree you observe, you can thrash:
// BAD without guards
observer.observe(box, { childList: true, subtree: true });
// callback always appends a node → infinite mutation storm
Disconnect around self-writes, filter by source, or observe a narrower subtree.
Performance
subtree: true on document.body with attributes: true is expensive on busy pages. Prefer:
- Smallest root that works
attributeFilter- Disconnect when idle
Not for layout
MutationObserver does not tell you size/visibility. Use ResizeObserver / IntersectionObserver for those.
Interview answer (out loud)
“MutationObserver batches DOM change records and delivers them asynchronously as microtasks. You observe a node with flags for childList, attributes, or characterData, process added/removed nodes, and always disconnect when done. Avoid observing the whole document with every option, and guard against mutating the observed tree inside the callback.”
Further reading
Related
Related guides
- AbortControllerCancel fetch and other async work with AbortController and AbortSignal — timeouts, race conditions, and cleanup when components unmount.
- Array find, some, every, includesShort-circuiting array predicates: find, findIndex, some, every, and includes — when to use each and common interview traps.
- Array map, filter, reducemap, filter, and reduce as the core transform toolkit — immutability, chaining costs, reduce patterns, and when a plain loop is clearer.
- Arrow Functions Deep DiveLexical this, no arguments object, no construct, concise bodies — when arrows help and when methods and generators need classic functions.
- Async Iteration and for await...ofAsync iterables, for await...of, and streaming data — how async iterators differ from Promise.all and when to use each.