Observing changes with MutationObserver
Watch for added nodes, attribute changes and text edits, understand the record batching, and avoid the observer loop.
Starting an observer
const target = document.querySelector('#results');
const observer = new MutationObserver((records) => {
for (const record of records) {
if (record.type === 'childList') {
for (const node of record.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
console.log('added', node.className);
}
}
for (const node of record.removedNodes) {
node.isConnected; // false once detached
}
}
if (record.type === 'attributes') {
console.log(record.attributeName, record.oldValue, record.target);
}
if (record.type === 'characterData') {
console.log('text changed in', record.target.parentNode);
}
}
});
observer.observe(target, {
childList: true,
subtree: true, // include descendants, not just direct children
attributes: true,
attributeFilter: ['class', 'data-state'], // limit what wakes you up
attributeOldValue: true,
characterData: true,
});- Records are batched into a microtask; one callback can describe several changes.
- Without
subtree: trueyou only see direct children of the target. attributeFilteris the difference between a useful observer and one that fires on every hover style change.attributeOldValuerequiresattributes: true, andcharacterDataneedssubtree: trueto be useful on a container.
⚠️
An observer that mutates what it observes calls itself indefinitely. If your callback writes to the same subtree it watches, either disconnect first and re-observe afterwards, or guard with a flag that suppresses the next batch.
Breaking the loop
// wrong: the callback keeps retriggering itself
const bad = new MutationObserver(() => {
document.body.setAttribute('data-height', String(document.body.offsetHeight));
});
bad.observe(document.body, { attributes: true, attributeFilter: ['data-height'] });
// right: ignore records this observer caused
let writing = false;
const good = new MutationObserver((records) => {
if (writing) return;
writing = true;
document.body.setAttribute('data-height', String(document.body.offsetHeight));
queueMicrotask(() => { writing = false; });
});
good.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style', 'class'],
});// disconnecting and reconnecting around a bulk change
observer.disconnect();
container.replaceChildren(...buildAll());
observer.observe(container, { childList: true, subtree: true });
// or drain the queue without a callback
const pending = observer.takeRecords();
observer.disconnect();| Option / method | Purpose |
|---|---|
childList | Nodes added or removed from the target |
subtree | Extend the watch to all descendants |
attributes + attributeFilter | Watch named attributes only |
characterData | Watch text node content |
takeRecords() | Drain pending records now, without waiting |
disconnect() | Stop observing, discard pending records |
What it is genuinely for
// enhance markup that third-party code injects
const enhancer = new MutationObserver((records) => {
for (const record of records) {
for (const node of record.addedNodes) {
if (node.nodeType !== Node.ELEMENT_NODE) continue;
node.querySelectorAll('[data-tooltip]').forEach(attachTooltip);
if (node.matches('[data-tooltip]')) attachTooltip(node);
}
}
});
enhancer.observe(document.querySelector('#chat'), { childList: true, subtree: true });- Integrating with markup produced by a widget you do not control, where there is no event to listen for.
- Detecting an undesired change - a production guard that reports when something rewrites a container it should not.
- Serialising state to storage when a subtree changes, using a debounced callback.
- Auto-sizing a textarea after any content change, which no input event covers.
// debounced save driven by DOM changes
let timer;
const saver = new MutationObserver(() => {
clearTimeout(timer);
timer = setTimeout(() => save(editor.innerHTML), 500);
});
saver.observe(editor, { childList: true, subtree: true, characterData: true });The honest caveat: in your own code, prefer an explicit call at the point of change. An observer is a listener for changes you did not make. Using it to route your own updates turns a direct call into an asynchronous hop that is harder to follow and much harder to test.
FAQ
Does MutationObserver fire once per change?
No. Records are queued and delivered together in a microtask, so one callback can contain many records. Handle the array rather than assuming a single change, and expect the count to differ between browsers and between debug and production builds.
Can it observe a shadow root?
Yes - call
observe() on the shadow root rather than the host element. Observing the host does not cross the shadow boundary, so changes inside the shadow tree are invisible to it.Related
Templates, cloning and efficient rendering Web Components: custom elements and shadow DOM
Last refreshed 2026-09-18.