skip to content

MutationObserver

You will learn how to watch the DOM for structural or attribute changes asynchronously, and why it replaced the old mutation events. Interviewers ask this for third-party widget integration and legacy-DOM instrumentation.

on this pageshow

questions

6

You call `observer.observe(el, { childList: true })` on a MutationObserver. Which changes under `el` will fire the callback, which will be ignored, and what happens if you pass `{}` instead?

level: middleimportance: must knowfreq 62%

answer

  1. options are a whitelist, nothing default-on
  2. three categories, one modifier
  3. subtree is not a category
  4. empty options is not "observe everything"
  5. text edits live on the text node

basics

~20 s

childList: true reports only direct children of el being added or removed. Attribute edits, in-place text changes, and anything deeper in the tree are ignored unless you also pass attributes, characterData or subtree. Calling observe with {} throws a TypeError.

solid answer

~40 s

`observe(el, { childList: true })` registers exactly one category of change on exactly one node: children being **added to or removed from `el` itself**. Setting an attribute on `el`, editing an existing text node's data, or appending a node three levels down produce no records. To widen it you opt in explicitly: `attributes: true` for attribute changes, `characterData: true` for in-place text edits, and `subtree: true` to apply whichever categories you enabled to every descendant as well. The options object is a whitelist, not a filter — nothing is on by default. If none of `childList`, `attributes` or `characterData` ends up true, `observe()` throws a `TypeError`, so `observe(el, {})` and `observe(el, { subtree: true })` both fail immediately rather than silently observing nothing.

code

javascript · 13 lines
javascript
const mo = new MutationObserver((records) => {
  for (const r of records) console.log(r.type, r.target.nodeName);
});

const list = document.createElement('ul');
document.body.append(list);
mo.observe(list, { childList: true });

const li = document.createElement('li');
li.textContent = 'one';
list.append(li);                 // record: childList on UL
li.setAttribute('data-x', '1');  // no record: attributes not enabled
li.firstChild.data = 'two';      // no record: characterData + subtree not enabled

go deeper

for a junior

Know that a MutationObserver watches nothing until observe(target, options) is called, and that you must name what you want: childList, attributes or characterData.

for a middle

Be ready to explain each category precisely, why subtree is a modifier rather than a category, why empty options throw a TypeError, and why a text edit may or may not surface depending on how it was written.

for a senior

Show that you choose the narrowest registration that answers the question, and can explain the cost and record volume that a broad childList+subtree+attributes registration imposes on a busy page.

for a principal

Own the guidance for when DOM observation is the right integration seam at all versus owning the mutation path, and set the team convention for narrow targets, attributeFilter, and mandatory teardown.

## What observe() actually registers A `MutationObserver` is created with a callback and watches nothing until you register it: ```js const mo = new MutationObserver((records, observer) => { /* ... */ }); mo.observe(el, { childList: true }); ``` `observe(target, options)` creates a registration on `target` that says which *categories* of change should produce `MutationRecord` objects. The options object is a whitelist: every category you do not name is off. One observer may register on many nodes; every record from every registration arrives at the same callback, and you tell them apart with `record.type` and `record.target`. ## The three categories **`childList`** — child nodes of the observed node being inserted or removed. That covers `append`, `prepend`, `remove`, `replaceChildren`, `insertBefore`, and assignments to `innerHTML` or `textContent` (which remove the old children and insert new ones). The record carries `addedNodes`, `removedNodes`, `previousSibling` and `nextSibling`, with `target` set to the parent whose child list changed. **`attributes`** — an attribute set or removed on an observed element. The record carries `attributeName` and `attributeNamespace`, with `target` set to the element. Note that the record tells you *which* attribute changed, not the new value — you read that back off `target` yourself. **`characterData`** — the data of a `CharacterData` node (a `Text` or `Comment` node) changing in place, as in `node.data = 'x'`. The record's `target` is the text node itself, not its parent element. ## subtree widens whatever you enabled `subtree: true` is a modifier, not a category. It means "apply the categories I enabled to this node and every descendant". `{ childList: true, subtree: true }` therefore reports insertions and removals anywhere below the target, with `target` naming the specific parent involved. `{ subtree: true }` alone still enables nothing, which is why it throws. ## At least one category is mandatory The DOM specification requires that `childList`, `attributes` or `characterData` be true, and `observe()` throws a `TypeError` otherwise. Two conveniences soften that rule: if you pass `attributeOldValue` or `attributeFilter` without mentioning `attributes`, `attributes` is treated as true; likewise `characterDataOldValue` implies `characterData`. But explicitly combining `attributes: false` with `attributeFilter`, or `characterData: false` with `characterDataOldValue`, is a `TypeError` — the spec refuses contradictory options rather than guessing. ## The text-node trap The most common surprise is observing an element for text changes: ```js mo.observe(p, { characterData: true }); // never fires p.firstChild.data = 'updated'; ``` The mutation happens on the `Text` node, which is a *child* of `p`, so the registration on `p` does not cover it. Either observe the text node directly, or use `{ characterData: true, subtree: true }` on `p`. Confusingly, `p.textContent = 'updated'` *does* fire an observer with `{ childList: true }`, because that assignment replaces the child text node rather than editing it — same visible outcome, different mutation category. ## Re-registering and stacking options Calling `observe()` a second time for the same node with the same observer does not add a second registration: it **replaces** the options for that node. So this is a bug: ```js mo.observe(el, { childList: true }); mo.observe(el, { attributes: true }); // childList is now OFF for el ``` If you want both, ask for both in one call: `mo.observe(el, { childList: true, attributes: true })`. ## Delivery is asynchronous Records are queued and handed to your callback later as an array, never synchronously during the DOM write. Code that mutates and then immediately inspects a counter the callback increments will see the old value. Reach for `takeRecords()` when you need whatever is queued right now. ## Practical shape Pick the narrowest options that answer your question. `{ childList: true }` on a specific list container is cheap and precise; `{ childList: true, subtree: true, attributes: true }` on `document.body` sees every change in the application and pays for all of them. When you only care about one attribute, add `attributeFilter: ['data-state']` so unrelated attribute writes never allocate a record at all.

  • If you call observe() twice on the same element with different options, does the observer end up watching the union of both?
    No. A second `observe()` call for the same node with the same observer replaces the previous registration's options rather than merging them, so the earlier categories are switched off. Ask for everything you need in a single call: `mo.observe(el, { childList: true, attributes: true, subtree: true })`. Registering the *same* observer on a *different* node does add a registration — one observer can watch many targets, and its callback receives records from all of them.
  • An element's text is updated and your { childList: true } observer fires, even though nobody added or removed an element. Why?
    Assigning to `textContent` (or `innerHTML`) does not edit the existing text in place — it removes the node's current children and inserts a fresh text node. That is a child-list mutation, so it produces a record with populated `removedNodes` and `addedNodes`. Editing `node.firstChild.data` instead mutates the text node itself and needs `characterData` plus `subtree` to be seen.
  • How do you tell which of several observed elements a record came from?
    Read `record.target`. Each `MutationRecord` names the node the mutation happened on: the parent element for `childList`, the element for `attributes`, and the `CharacterData` node itself for `characterData`. Combined with `record.type`, that is enough to route records from a single observer registered on many nodes. The callback's second argument is the observer, which is handy for disconnecting from inside the callback.

Think of observe() as subscribing to specific mailing lists rather than to "all mail": you get exactly the lists you tick, and ticking none is rejected as a mistake rather than treated as ticking all.

saying these in an interview costs you the question

  • Assumes subtree is enabled by default
  • Thinks childList also reports attribute changes
  • Expects observe(el, {}) to observe everything
  • Believes the callback runs synchronously with the DOM write
  • Watches an element for characterData without subtree
  • Calls observe() repeatedly expecting options to accumulate

context

open as a page

In a MutationObserver callback, every MutationRecord with type 'attributes' has oldValue === null. What did the observe() call leave out, and how do you also stop unrelated attributes from generating records?

level: juniorimportance: should knowfreq 38%

basics

~20 s

The observe() options omitted attributeOldValue: true, so the browser does not record previous values. Add it to populate oldValue, and add attributeFilter with an array of attribute names to record only the attributes you care about.

open as a page

What does MutationObserver's takeRecords() return, and what happens to records that were queued but not yet delivered when you call disconnect()?

level: middleimportance: should knowfreq 42%

basics

~10 s

takeRecords() synchronously returns the observer's queued but undelivered MutationRecords and empties the queue, so the callback will not receive them again. disconnect() stops all observation and also empties that queue, silently discarding anything pending.

open as a page

A MutationObserver watching a container with { childList: true, subtree: true } appends a wrapper element inside that same container from its own callback, and the callback then runs over and over. Why, and how do you break the cycle?

level: seniorimportance: should knowfreq 46%

basics

~20 s

The callback's own DOM write is itself an observed mutation, so it queues a new record that re-invokes the callback, which writes again. Break it by disconnecting before writing and re-observing after, by making the write idempotent, or by narrowing the registration so your own changes are not observed.

open as a page

An analytics script registers one MutationObserver on document.body with { childList: true, subtree: true, attributes: true }, and the app becomes janky during large list renders. Why is that registration expensive, and how would you cut the cost?

level: seniorimportance: should knowfreq 33%

basics

~20 s

Every node insertion, removal and attribute write anywhere in the page allocates a MutationRecord and enlarges the batch handed to one main-thread callback, so a render touching thousands of nodes produces thousands of records. Narrow the target, drop unneeded categories, add attributeFilter, and keep the callback cheap.

open as a page

Legacy DOM mutation events such as DOMNodeInserted, DOMSubtreeModified and DOMAttrModified were deprecated in favour of MutationObserver. What was wrong with them?

level: middleimportance: nice to knowfreq 20%

basics

~20 s

Mutation events fired synchronously during each individual DOM change and propagated through the tree like any other event, so every insertion paid dispatch cost and handlers could mutate the DOM re-entrantly mid-operation. MutationObserver replaced them with asynchronous, batched record delivery.

open as a page