Mutation Observer Β· Astro Tech Blog

Mutation Observer

MutationObserver lets you watch for changes in the DOM β€” elements added or removed, attributes modified, text content changed.

Why Use MutationObserver?

Before MutationObserver, you had to poll the DOM or use hacky DOMSubtreeModified events (deprecated, slow). MutationObserver is:

  • Efficient β€” async, batched, no polling
  • Precise β€” tells you exactly what changed
  • Well-supported β€” all modern browsers

Basic Usage

const observer = new MutationObserver(function(mutations) {
  mutations.forEach(function(mutation) {
    console.log(mutation.type, mutation);
  });
});

observer.observe(targetElement, {
  childList: true,    // watch for child additions/removals
  attributes: true,   // watch for attribute changes
  subtree: true,      // watch entire subtree
  characterData: true // watch text changes
});

// When done:
observer.disconnect();

Watching Child Elements

Demo: Watching Child Additions/Removals
HTML
<div id='mut-demo' style='padding:12px;border:2px solid #6366f1;border-radius:8px;'>
<p id='mut-target'>Watch list:</p>
<ul id='mut-list'>
<li>Existing item</li>
</ul>
</div>
<button id='add-mut-item'>Add Item</button>
<button id='remove-mut-item'>Remove Last</button>
<pre id='mut-log' style='background:#f1f5f9;padding:8px;border-radius:4px;'></pre>
JavaScript
const list = document.getElementById('mut-list');
const log = document.getElementById('mut-log');
let itemCount = 1;

const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(m) {
if (m.type === 'childList') {
m.addedNodes.forEach(node => {
if (node.nodeType === 1) log.textContent += 'Added: ' + node.textContent + '\\n';
});
m.removedNodes.forEach(node => {
if (node.nodeType === 1) log.textContent += 'Removed: ' + node.textContent + '\\n';
});
}
});
});

observer.observe(list, { childList: true });

document.getElementById('add-mut-item').onclick = function() {
itemCount++;
const li = document.createElement('li');
li.textContent = 'Item ' + itemCount;
list.appendChild(li);
};

document.getElementById('remove-mut-item').onclick = function() {
if (list.lastElementChild) {
list.lastElementChild.remove();
}
};
Live Output Window

Watching Attribute Changes

Demo: Watching Attributes
HTML
<div id='attr-demo'>
<div id='attr-target' class='box' data-count='0' style='padding:16px;background:#eef2ff;border:2px solid #6366f1;border-radius:8px;text-align:center;'>
Watch my attributes change
</div>
<div style='margin-top:8px;display:flex;gap:8px;flex-wrap:wrap;'>
<button id='toggle-class'>Toggle Class</button>
<button id='change-style'>Change Style</button>
<button id='inc-data'>Increment data-count</button>
</div>
<pre id='attr-log' style='background:#f1f5f9;padding:8px;border-radius:4px;'></pre>
</div>
JavaScript
const target = document.getElementById('attr-target');
const log = document.getElementById('attr-log');

const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(m) {
if (m.type === 'attributes') {
log.textContent =
'Attribute changed: ' + m.attributeName + '\\n' +
'  oldValue: ' + m.oldValue + '\\n' +
'  newValue: ' + target.getAttribute(m.attributeName);
}
});
});

observer.observe(target, {
attributes: true,
attributeOldValue: true,
attributeFilter: ['class', 'style', 'data-count'] // only watch these
});

document.getElementById('toggle-class').onclick = function() {
target.classList.toggle('active');
};
document.getElementById('change-style').onclick = function() {
target.style.background = target.style.background === '#fef9c3' ? '#eef2ff' : '#fef9c3';
};
document.getElementById('inc-data').onclick = function() {
const count = parseInt(target.dataset.count) + 1;
target.dataset.count = count;
};
Live Output Window

Watching Text Changes

Demo: Watching Text Content
HTML
<div id='text-demo'>
<p id='text-target'>Edit this text</p>
<button id='change-text'>Change Text</button>
<pre id='text-log' style='background:#f1f5f9;padding:8px;border-radius:4px;'></pre>
</div>
JavaScript
const target = document.getElementById('text-target');
const log = document.getElementById('text-log');

const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(m) {
if (m.type === 'characterData') {
log.textContent = 'Text changed to:' + m.target.textContent;
}
});
});

observer.observe(target, {
characterData: true,
subtree: true
});

document.getElementById('change-text').onclick = function() {
const texts = ['Hello World', 'DOM updated!', 'MutationObserver rocks', 'Edit this text'];
target.textContent = texts[Math.floor(Math.random() * texts.length)];
};
Live Output Window

Configuration Options

OptionTypeDefaultDescription
childListbooleanfalseWatch direct child additions/removals
attributesbooleanfalseWatch attribute changes
characterDatabooleanfalseWatch text content changes
subtreebooleanfalseWatch entire subtree (not just direct children)
attributeOldValuebooleanfalseRecord previous attribute value
characterDataOldValuebooleanfalseRecord previous text value
attributeFilterstring[]allOnly watch these attributes

MutationRecord Properties

Each mutation in the callback has:

PropertyWhat it contains
type"childList", "attributes", or "characterData"
targetThe node that changed
addedNodesNodes added (for childList)
removedNodesNodes removed (for childList)
previousSibling / nextSiblingSibling info (for childList)
attributeNameName of changed attribute
oldValuePrevious value (if oldValue option set)

Disconnecting

Always disconnect when you’re done:

observer.disconnect(); // stop observing

Practical: Detect Element Added

Demo: Detect New Elements
HTML
<div id='detect-demo'>
<div id='detect-container' style='padding:12px;background:#f1f5f9;border-radius:8px;min-height:60px;'>
<span class='detect-item' style='padding:4px 8px;background:#eef2ff;border-radius:4px;margin:2px;display:inline-block;'>Existing</span>
</div>
<button id='add-detect'>Add Span</button>
<pre id='detect-log' style='background:#f1f5f9;padding:8px;border-radius:4px;'></pre>
</div>
JavaScript
const container = document.getElementById('detect-container');
const log = document.getElementById('detect-log');

const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(m) {
m.addedNodes.forEach(function(node) {
if (node.nodeType === 1 && node.matches('.detect-item')) {
log.textContent = 'New .detect-item added:' + node.textContent;
}
});
});
});

observer.observe(container, { childList: true });

document.getElementById('add-detect').onclick = function() {
const span = document.createElement('span');
span.className = 'detect-item';
span.style.cssText = 'padding:4px 8px;background:#c7d2fe;border-radius:4px;margin:2px;display:inline-block;';
span.textContent = 'Item ' + (container.children.length + 1);
container.appendChild(span);
};
Live Output Window

Key Takeaways

  • MutationObserver watches for DOM changes β€” additions, removals, attributes, text
  • Always pass subtree: true if you want to watch descendants too
  • Use attributeFilter to watch specific attributes only
  • The observer callback receives an array of MutationRecords (batched)
  • Always call .disconnect() when the observer is no longer needed (e.g., on component unmount)
  • Much better performance than deprecated DOMSubtreeModified or polling