Web Platform · DOM and Events

MutationObserver Records, Batching, and Microtask Delivery

Observers accumulate a record per mutation and deliver them once per microtask checkpoint, which coalesces bursts into a single callback and makes delivery timing a question about the event loop.

The predecessor of MutationObserver was a set of DOM mutation events such as DOMNodeInserted and DOMAttrModified. They fired synchronously, once per mutation, through the same capture-and-bubble machinery as user input. A library that rewrote a subtree produced thousands of dispatches, each with a path to walk and each able to run arbitrary code in the middle of the operation that caused it. The observer API replaced them by making the notification asynchronous and coarse: mutations are recorded as data, and the data is handed over once, later, in a batch.

Registration is a subscription with a filter

observe(target, options) registers the observer on a node. The options dictionary selects which mutation categories are interesting: childList for insertion and removal, attributes for attribute changes, characterData for text and comment edits. subtree extends the subscription to all descendants rather than the node alone. attributeOldValue and characterDataOldValue request that the previous value be copied into the record, and attributeFilter restricts attribute observation to a list of names.

The options are validated strictly. Requesting attributeOldValue without attributes, or characterDataOldValue without characterData, throws a TypeError rather than silently enabling both. That strictness is useful: the omitted flag is nearly always a mistake.

A single observer can hold several registrations. Observing ten different nodes with one observer produces one callback and one queue, not ten. Registrations are matched during mutation by walking up from the changed node, which is why subtree costs a lookup along the ancestor chain of every mutation rather than a sweep of the tree.

A record is a description, not the mutation

Each recorded change becomes a MutationRecord with type set to childList, attributes, or characterData, a target, and the fields appropriate to the type. A childList record carries addedNodes and removedNodes collections together with previousSibling and nextSibling, which locate the change precisely enough to reconstruct order. An attributes record carries attributeName, attributeNamespace, and oldValue when requested. A characterData record carries oldValue and points at the text node.

The record does not carry the new value. That is a deliberate omission: the current state is always readable from the target, and copying it into every record would duplicate data that may be superseded before delivery. Code that needs the new value reads it at callback time, which means it observes the final state, not the intermediate states. Only oldValue is preserved, and only when asked for.

One mutation produces one record per affected parent. Removing a node from one parent and inserting it into another produces a removal record on the old parent and an insertion record on the new one, in that order. Setting innerHTML on a parent, by contrast, produces a single childList record describing the whole replacement, because the replacement runs as one “replace all” operation rather than as a loop of removals and appends.

Delivery waits for a microtask checkpoint

Records accumulate in a per-observer queue. The first record queued schedules a mutation observer microtask, and if one is already scheduled nothing more happens. At the next microtask checkpoint, after the currently executing script has finished and its promise callbacks have drained, every observer with a non-empty queue is notified in the order the observers were created.

This gives the API its central property: a synchronous burst of mutations produces a single callback containing all the records. Writing a list of a thousand items into a fragment and appending the fragment generates one record on the parent. Appending a thousand nodes one at a time generates a thousand records, but still one callback, and the callback sees the tree in its final state.

const observer = new MutationObserver((records) => {
  for (const record of records) {
    if (record.type !== 'childList') continue;
    for (const node of record.addedNodes) {
      if (node.nodeType === Node.ELEMENT_NODE) hydrate(node);
    }
  }
});

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

// a batch of writes: one callback, many records
for (const row of rows) list.append(renderRow(row));

Because the checkpoint precedes the rendering update, observer callbacks run before style and layout for that frame. Work done there can still influence the upcoming frame: adding a class in an observer callback changes the style that will be computed for the same rendering opportunity. That makes observers a legitimate place to patch markup before paint, and an easy place to create layout thrash by reading geometry.

Observers that measure geometry rather than structure deliver at a different point in the frame, as resize and intersection observers describes.

takeRecords() drains the queue synchronously and returns it, leaving nothing for the pending callback. It exists for the case where code is about to dispose of the observed region and cannot wait for a checkpoint, and for tests that want deterministic sequencing.

Mutations inside the callback arrive later

The microtask that notifies observers clears the queued flag before running callbacks. A mutation made inside an observer callback therefore queues new records and schedules another microtask, which runs after the current one finishes. Observer callbacks can chain in this way indefinitely, and the platform imposes no limit on the chain. Each link is a fresh microtask, and the checkpoint drains the queue completely before the browser renders or runs another task, so an unbounded chain blocks the page just as an infinite synchronous loop would.

The same mechanism means observer callbacks always run before the next rendering opportunity but after the synchronous code that caused the mutation. An event handler that changes the DOM, then reads a value, then returns, will have finished before any observer sees the change. Code that needs the observer to have run must await a microtask, not a timeout.

Which changes are invisible

Observation is attached to nodes, not to the document. Removing an observed element from its parent is a mutation on the parent; if the parent is not observed, no record is produced for the removal, even though the node the observer watches has just left the tree. A subscription with subtree: true on an ancestor does see it, because the ancestor’s child list changed.

Changes that do not alter the tree or the observed data produce nothing. Reassigning an expando property on an element object is invisible to an observer, because no node or attribute changed. Changing a class through the classList API is an attribute mutation and is recorded. A shadow tree is a separate tree, so an observer on the host with subtree: true does not see changes inside it. Mutating the contents of a canvas produces no DOM mutation at all, since the canvas bitmap is not part of the tree.

Records queued before a node is removed are still delivered. The queue is a list of records that already exist, and delivery does not re-check whether their targets are still connected. This is why an observer callback must treat record.target as possibly detached, and why a handler that assumes isConnected is true will occasionally be wrong during teardown.

Disconnecting without dropping the queue

disconnect() removes every registration and discards the record queue. Records that were already queued are lost, not delivered, so code that wants to process pending work before tearing down calls takeRecords() first and handles the result itself. The order matters: after disconnect() the queue is empty and the returned array from a later takeRecords() call is empty too.

Calling observe() again on a node that is already observed replaces the options in place rather than adding a second registration. Nothing is replayed: an attribute changed while attributes was off is not reported when the flag is later turned on, because the change happened before the option existed. Re-observation changes what is recorded from that point forward only.

Cost and the observation footprint

Every observed mutation allocates a record and appends it to a list. Observing a large editable region with characterData and subtree turns every keystroke into at least one record, and a rich-text editor that rewrites nodes can produce a record per node. The allocation is the cheap part; the expensive part is the callback that has to reason about an arbitrarily long list.

Two habits keep the cost bounded. Observe the narrowest node that can change, which shortens the ancestor walk every mutation performs and keeps unrelated subtrees out of the queue. Then narrow the flags to the mutation types actually handled, which keeps the callback’s own loop over records short and avoids allocating old values that are never read. Live collections and static snapshots covers the other half of the picture: what reads see while these records are still pending.

References

  1. DOM Standard: Mutation observers — WHATWG
  2. MutationObserver — MDN Web Docs
  3. MutationEvent — MDN Web Docs