Mutation observer
Most of the DOM APIs you reach for are about making changes: you set textContent, you append a node, you flip an attribute. MutationObserver is the mirror image. It’s a built-in object that watches a DOM element and runs a callback whenever something inside it changes — a child added, an attribute flipped, text edited.
The power here is that the change doesn’t have to come from your own code. It can come from a library, a browser feature like contentEditable, or a third-party widget you have no control over. If it touches the DOM, an observer can catch it.
We’ll walk through the syntax first, then look at two real problems it solves cleanly.
Syntax
Using it is a two-step dance. First, create an observer and hand it a callback:
let observer = new MutationObserver(callback);
Nothing is being watched yet. The observer is just sitting there. You activate it by pointing it at a node with a config:
observer.observe(node, config);
The config object is where you declare what kinds of changes matter to you. It’s a set of boolean switches:
childList— direct children ofnodeare added or removed.subtree— extend the watch to every descendant, not just direct children.attributes— attributes ofnodechange.attributeFilter— an array of attribute names, so you only hear about the ones you list.characterData— the text data of a node (node.data) changes.
A couple more switches control whether the old value is included:
attributeOldValue— whentrue, the callback receives both the previous and new attribute value instead of just the new one. Requiresattributes.characterDataOldValue— whentrue, the callback receives both the previous and new text. RequirescharacterData.
What the callback receives
When a watched change happens, the callback fires with two arguments: a list of MutationRecord objects describing what changed, and the observer itself.
let observer = new MutationObserver((records, observerRef) => {
// records: array of MutationRecord
// observerRef: the same observer, handy inside the callback
});
Each MutationRecord is a read-only snapshot with these properties:
type— the kind of mutation, one of:"attributes"— an attribute was modified,"characterData"— text data changed (used for text nodes),"childList"— child elements were added or removed.
target— where the change happened: the element for"attributes", the text node for"characterData", or the parent element for a"childList"change.addedNodes/removedNodes— the nodes that came in or went out.previousSibling/nextSibling— the siblings flanking the added or removed nodes.attributeName/attributeNamespace— the name and (for XML) namespace of the changed attribute.oldValue— the previous value, present only for attribute or text changes, and only when you asked for it viaattributeOldValue/characterDataOldValue.
| type | target is | key fields |
|---|---|---|
| “childList” | parent element | addedNodes, removedNodes, prev/nextSibling |
| “attributes” | the element | attributeName, oldValue* |
| “characterData” | text node | oldValue* |
A worked example
Here’s a <div> marked contentEditable. That attribute makes the element focusable and lets the user type into it directly, so the browser mutates the DOM as edits happen — a perfect thing to observe.
<div contentEditable id="elem">Add a <b>task</b> below</div>
<script>
let observer = new MutationObserver(mutationRecords => {
console.log(mutationRecords); // log the changes
});
// observe everything except attributes
observer.observe(elem, {
childList: true, // observe direct children
subtree: true, // and lower descendants too
characterDataOldValue: true // pass old data to callback
});
</script>
Run this in the browser, click into the <div>, and change the text inside <b>task</b> — say, type an extra letter. The console shows a single record:
mutationRecords = [{
type: "characterData",
oldValue: "task",
target: <text node>,
// other properties empty
}];
The text node inside <b> changed, so you get one characterData record, complete with the old value because you asked for it.
Now do something bigger — select and delete the whole <b>task</b> element. A single user action can produce several records at once, because the browser may reshape the DOM in more than one way:
mutationRecords = [{
type: "childList",
target: <div#elem>,
removedNodes: [<b>],
nextSibling: <text node>,
previousSibling: <text node>
// other properties empty
}, {
type: "characterData"
target: <text node>
// ...mutation details depend on how the browser handles such removal
// it may coalesce two adjacent text nodes "Add a " and " below" into one node
// or it may leave them separate text nodes
}];
The takeaway: MutationObserver lets you react to any change within a DOM subtree, however that change was triggered.
You don’t have to take the console’s word for it. Edit the region below — type letters, delete words, hit Enter to add lines — and watch the observer report each MutationRecord live. Notice how a single edit can produce more than one record, and how characterData changes carry the old text because we opted in.
Usage for integration
When is watching the DOM actually useful? The first big case is dealing with code you don’t own.
Picture a third-party script that gives you something you want, but also drops in something you don’t — say, a promo popup:
<div class="promo">Buy-now popup</div>
The script offers no option to disable that part. You can’t edit it, and you can’t reliably delete the element on load because the script inserts it later, on its own schedule.
MutationObserver solves this. Watch the container, and the moment the unwanted element appears, remove it:
let observer = new MutationObserver(mutations => {
for (let mutation of mutations) {
for (let node of mutation.addedNodes) {
if (node.nodeType === 1 && node.matches?.('.promo')) {
node.remove();
}
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
Try it. The button below plays the part of a third-party script: it drops a genuine widget and an unwanted .promo banner into the container. The observer is already watching, so the popup never survives long enough to matter — it’s removed the instant it appears, while the real widget stays.
The same technique works for benign reasons too: a third-party script injects a widget and you want to notice, so you can resize a container, re-layout the page, or wire up your own handlers. Whenever something outside your control writes to the DOM and you need to respond, an observer gives you the hook.
Usage for architecture
The second case isn’t about foreign code at all. Sometimes an observer is just the cleaner design, even for code you fully own.
Say you’re building a site about programming. Articles carry code snippets, marked up like this:
...
<pre class="language-javascript"><code>
// here's the code
const price = 42;
</code></pre>
...
To make those readable, you run a syntax-highlighting library such as Prism.js. Prism scans a pre element and injects colored <span> tags around tokens — the same effect you see in the examples on this page. You trigger it by calling Prism.highlightElement(pre).
So when do you call it? For snippets present in the initial HTML, the answer is easy: wait until the DOM is ready (DOMContentLoaded, or a script at the end of the page), find every snippet, and highlight it.
// highlight all code snippets on the page
document.querySelectorAll('pre[class*="language"]').forEach(Prism.highlightElement);
Straightforward. Find the snippets, highlight them.
The problem with dynamic content
Now suppose you fetch article content from the server after the page has loaded. (Fetching is covered later in this course; for now, assume you get back some HTML and drop it in.)
let article = /* fetch new content from server */;
articleElem.innerHTML = article;
That new HTML can contain its own code snippets, and they arrived after your initial highlight pass ran. They’ll show up plain unless you highlight them too.
Where and when should the highlight call live for dynamically loaded content?
The obvious move is to bolt the call onto the loading code:
let article = /* fetch new content from server */;
articleElem.innerHTML = article;
let snippets = articleElem.querySelectorAll('pre[class*="language-"]');
snippets.forEach(Prism.highlightElement);
This works, but it doesn’t scale. You load content in many places — articles, quizzes, forum posts, comments — and now every one of them has to remember to call the highlighter. Miss one and its code renders unstyled.
It gets worse if a third-party module does the loading. Imagine a forum component someone else wrote that pulls in content on its own. You want its code snippets highlighted, but you can’t easily patch its internals — and nobody enjoys forking a dependency just to add a hook.
There’s a better option. Let one MutationObserver watch the page, notice any snippet that appears from any source, and highlight it. Highlighting becomes a single concern handled in a single place, decoupled from whoever inserted the content.
Dynamic highlight demo
Here’s the whole thing working. This code starts observing an element and highlights any snippet that lands inside it:
let observer = new MutationObserver(mutations => {
for(let mutation of mutations) {
// examine new nodes, is there anything to highlight?
for(let node of mutation.addedNodes) {
// we track only elements, skip other nodes (e.g. text nodes)
if (!(node instanceof HTMLElement)) continue;
// check the inserted element for being a code snippet
if (node.matches('pre[class*="language-"]')) {
Prism.highlightElement(node);
}
// or maybe there's a code snippet somewhere in its subtree?
for(let elem of node.querySelectorAll('pre[class*="language-"]')) {
Prism.highlightElement(elem);
}
}
}
});
let demoElem = document.getElementById('highlight-demo');
observer.observe(demoElem, {childList: true, subtree: true});
Two details in that loop are worth pausing on. First, addedNodes contains all inserted nodes, including whitespace text nodes between tags — the node instanceof HTMLElement guard filters those out. Second, an inserted node might be a snippet, or it might be a wrapper that contains snippets deeper down. That’s why the code checks both node.matches(...) and node.querySelectorAll(...).
Below is an element that some JavaScript fills using innerHTML:
<p id=“highlight-demo” style=“border: 1px solid #ddd”>A demo-element with id=“highlight-demo”, run the code above to observe it.</p>
Run the observer snippet first (it starts watching that element), then run the code below. You’ll see the observer catch the insertion and highlight the snippet automatically:
let demoElem = document.getElementById('highlight-demo');
// dynamically insert content with code snippets
demoElem.innerHTML = `A code snippet is below:
<pre class="language-javascript"><code> const price = 42; </code></pre>
<div>Another one:</div>
<div>
<pre class="language-css"><code>.badge { padding: 4px; } </code></pre>
</div>
`;
The demo below stands in for a real highlighter. Instead of Prism, decorate() just tags each snippet with a language badge and a styled background — but the wiring is identical. One observer watches the box; whichever button inserts content, the snippet comes out decorated. The second button inserts a <pre> wrapped in a <div>, so you can see the querySelectorAll branch reach a snippet that matches alone would miss.
Now you have one observer that keeps snippets highlighted no matter how they arrive. You can add or remove code anywhere in the watched subtree — or the entire document — and never think about highlighting again.
Additional methods
Watching isn’t forever. To stop:
observer.disconnect()— stops observing. The observer becomes inert again and its callback won’t fire until you callobserveafresh.
There’s a subtlety around timing. The callback runs asynchronously, batching mutations that happened since the last run. So at the instant you disconnect, some mutations may have occurred but not yet been delivered to the callback. To retrieve them:
observer.takeRecords()— returns the list of pending mutation records that happened but haven’t been handed to the callback yet, and clears that queue.
These pair up naturally:
// get a list of unprocessed mutations
// should be called before disconnecting,
// if you care about possibly unhandled recent mutations
let mutationRecords = observer.takeRecords();
// stop tracking changes
observer.disconnect();
...
Summary
MutationObserver reacts to changes in the DOM — attributes, text content, and the adding or removing of elements — regardless of what caused them.
Two situations make it shine:
- Integration. Watch for changes made by code you don’t own — a third-party script, a
contentEditableregion, an injected widget — and respond, whether that means cleaning up unwanted nodes or adapting your layout. - Architecture. Centralize a cross-cutting concern (like syntax highlighting) in one observer instead of scattering the same call across every place that loads content.
The config flags exist for efficiency, not capability. MutationObserver can track any change; you narrow the config so the browser only wakes your callback for the changes you actually care about.