</> HTML5Advent
ENFRESDEITPT

// apis

What is MutationObserver? The third observer, and the queue that makes it usable

IntersectionObserver watches visibility, ResizeObserver watches size, MutationObserver watches the DOM itself. What it reports, why it replaced Mutation Events, and the method that reveals how delivery actually works.

An open spiral notebook with blank grid pages and a black pen resting on it, beside a passport and a smartphone on a wooden table

The platform has three observers, and they divide the work cleanly. IntersectionObserver tells you when an element enters the viewport. ResizeObserver tells you when it changes size. MutationObserver tells you when the DOM itself changes.

MDN puts it in one line: the interface "provides the ability to watch for changes being made to the DOM tree".

What it can watch

Three kinds of change, selected through the options you pass:

  • childList — nodes added or removed
  • attributes — an attribute changed on the observed element
  • characterData — the text content of a node changed

Plus subtree, which extends any of the above to every descendant rather than the target alone. That one word is the difference between watching a container and watching everything inside it.

const observer = new MutationObserver((records) =&gt; {
  for (const record of records) {
    if (record.type === 'childList') {
      console.log(record.addedNodes.length, 'node(s) added');
    }
  }
});

observer.observe(targetNode, {
  attributes: true,
  childList: true,
  subtree: true,
});

Why it replaced Mutation Events

MDN is explicit about the lineage: MutationObserver "is designed as a replacement for the older Mutation Events feature, which was part of the DOM3 Events specification".

Mutation Events fired synchronously, one event per change, in the middle of whatever was modifying the DOM. A script inserting a hundred nodes produced a hundred interruptions, and a handler that touched the DOM could trigger more events while still handling the first. They were removed for good reason.

The queue is the design

The interesting part is not the callback but what feeds it, and one method gives it away. takeRecords() "removes all pending notifications from the MutationObserver's notification queue and returns them in a new Array of MutationRecord objects".

A close-up of a paper notepad on a desk with a pen and a pair of glasses beside it

A pending notification queue means delivery is not immediate. Changes accumulate, and your callback receives an array of records rather than one call per change. That is precisely what makes the API usable where Mutation Events were not: a hundred insertions become one callback with a hundred records, and your code cannot interrupt the operation that caused them.

It also explains when takeRecords() earns its place. Before calling disconnect(), anything already queued but not yet delivered would be lost - draining the queue first lets you handle it.

Two things that catch people out

Your own writes are observed too. If the callback modifies the DOM inside the observed subtree, those modifications queue up and call you again. The usual remedy is a guard flag, or narrowing the options so your own writes fall outside what you watch.

Attribute records do not carry the new value by default. You get the attribute name, and attributeOldValue: true in the options if you want the previous one. The current value you read from the element yourself.

When to reach for it

MutationObserver is the right tool when the DOM is changed by something you do not control: a third-party widget, a CMS-injected block, an editor. It is the wrong tool for your own application state, where you already know when things change and observing them is a slower way of asking yourself a question you can answer directly.

And disconnect() matters more than it looks - MDN describes it as stopping notifications "until and unless observe() is called again". An observer on a subtree that outlives the component watching it is a leak that no one notices until the page has been open for an hour.

A notepad waiting to be read. takeRecords() does exactly that: it empties the observer's pending queue and hands you what has accumulated since the last delivery.