Skip to main content

MutationObserver

MutationObserver is a special object that watches an element and reports when something inside it has changed: structure, attributes or text. It works asynchronously, does not block the UI, and invokes a callback when changes happen in the node being observed.

Theory

TL;DR

  • MutationObserver watches DOM changes and lets you react to them in real time.
  • You create it with a callback and start it with observer.observe(target, options).
  • The options switch on the change types: childList, attributes, characterData, plus subtree for the whole subtree.
  • The callback receives an array of MutationRecord objects, one record per change.
  • It runs asynchronously through the microtask queue and delivers changes in a batch, so it is cheap.
  • Control: observe(), disconnect(), takeRecords().
  • It replaces the deprecated DOMNodeInserted and DOMSubtreeModified events.

Quick example

javascript
// 1. Create the observer const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { console.log('Change type:', mutation.type); }); }); // 2. The element we are going to watch const target = document.querySelector('#app'); // 3. Observation options observer.observe(target, { childList: true, // watch adding and removing child elements attributes: true, // watch attribute changes subtree: true // watch everything inside the element });

Now, if you add a new element to #app or change an attribute, a message with the change type appears in the console.

A live example

javascript
<div id="box">Hello!</div> <button id="btn">Change</button> <script> const box = document.getElementById('box'); const btn = document.getElementById('btn'); const observer = new MutationObserver((mutations) => { mutations.forEach((m) => console.log(m)); }); observer.observe(box, { childList: true, attributes: true, characterData: true }); btn.addEventListener('click', () => { box.textContent = 'Text changed!'; box.setAttribute('data-status', 'updated'); }); </script>

On click the console shows MutationRecord objects that carry information about:

  • the change type (attributes, childList, characterData);
  • which attribute changed;
  • the old and the new value, if that was enabled.

The observe() options

javascript
observer.observe(target, { childList: true, // adding and removing child elements attributes: true, // attribute changes characterData: true, // text changes inside nodes subtree: true, // watch descendants (the whole tree) attributeFilter: ['class', 'style'], // watch only these attributes attributeOldValue: true, // keep the previous attribute value characterDataOldValue: true // keep the previous text value });

If none of childList, attributes or characterData is enabled, the browser throws: the observer has nothing to watch.

What a MutationRecord contains

Every item of the mutations array is a MutationRecord object with these properties:

PropertyDescription
typechange type (attributes, childList, characterData)
targetthe element where the change happened
addedNodesadded elements
removedNodesremoved elements
attributeNamename of the changed attribute
oldValueprevious value of the attribute or the text

Controlling the observer

To stop watching:

javascript
observer.disconnect();

To drain the changes collected so far and, if needed, start watching again:

javascript
observer.takeRecords(); // returns the accumulated changes observer.observe(target, { childList: true }); // can be switched on again

takeRecords() hands back the records that have not reached the callback yet and clears the queue. That is useful right before disconnect(), so nothing is lost.

Practical scenarios

A very common case is catching new elements inserted by a JS framework (React, Vue, or a third party script):

javascript
const container = document.querySelector('#feed'); const observer = new MutationObserver((mutations) => { mutations.forEach((m) => { m.addedNodes.forEach((node) => { if (node.nodeType === 1 && node.matches('.post')) { console.log('A new post appeared:', node.textContent); } }); }); }); observer.observe(container, { childList: true, subtree: true });

Now a dynamically added <div class="post">...</div> triggers your JS immediately.

The second typical scenario is reacting automatically to a class change:

javascript
const box = document.querySelector('#box'); const observer = new MutationObserver((entries) => { for (const mutation of entries) { if (mutation.attributeName === 'class') { console.log('Class changed to:', box.className); } } }); observer.observe(box, { attributes: true });

This is handy for tracking state changes, for example during animations or toggle effects.

Performance and summary

  • MutationObserver works asynchronously, changes are batched and delivered together rather than per character or per pixel.
  • That makes it very efficient even with a lot of observations.
  • Still, do not abuse subtree: true on the whole document, it can get expensive.
What it doesMutationObserver
TracksDOM changes (attributes, text, adding and removing elements)
Replacement forThe old DOMNodeInserted, DOMSubtreeModified events
RunsAsynchronously, through the microtask queue
Used forDynamic UI, integrations, watching React or Vue, animations, custom components
Controlobserve(), disconnect(), takeRecords()

Common mistakes

  • Calling observe() without any of the childList, attributes, characterData flags. The browser throws a TypeError, because there is nothing to observe.
  • Expecting the callback to run synchronously. Right after element.setAttribute(...) the callback has not fired yet, changes arrive on a microtask. If you need the result immediately, use takeRecords().
  • Mutating the DOM inside the callback without a guard. Your own changes come back to the observer and an infinite loop is easy to create. The fix: disconnect() temporarily, mutate, then observe() again.
  • Putting subtree: true on document.body for the sake of one button. The observer will wake the callback on every tiny page change. Narrow the target and add an attributeFilter.
  • Forgetting disconnect(). The observer keeps references to the node and the callback, so tearing down a component without disconnecting leaks memory.
  • Confusing it with IntersectionObserver or ResizeObserver. The first watches element visibility, the second its size, while MutationObserver only watches DOM structure and attributes.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.