TL;DR / MutationObserver watches DOM changes through a microtask-based callback that batches mutation records, but broad observation scopes (especially
subtree: true) can generate massive record arrays and block the main thread.
How It Works
┌──────────┐ ┌──────────────────┐ config:
│ DOM Tree │─────────→│ MutationObserver │ childList: true
└──────────┘ └──────────────────┘ subtree: true
│ attributes: true
│
┌┘
│
↓
┌────────────────────────────────┐
│ MutationRecord[] │
│ (batched) │
└────────────────────────────────┘
│
│
│
↓
┌────────────────────────────────┐
│ Microtask callback │
│ fires │
└────────────────────────────────┘
MutationObserver replaced the deprecated Mutation Events (DOMNodeInserted, DOMSubtreeModified, etc.) which fired synchronously during DOM mutations and could not be batched. Every single attribute change, node insertion, or text modification triggered a synchronous event handler, making them catastrophically expensive for any code that performed bulk DOM manipulation.
The Microtask Delivery Model
MutationObserver callbacks are delivered as microtasks -- they run after the current JavaScript execution context completes but before the browser yields to the rendering pipeline or task queue. This means if you insert 1000 nodes in a for loop, the observer callback fires once after the loop completes, receiving an array of 1000 MutationRecord objects. This batching is the fundamental performance advantage over Mutation Events.
Each MutationRecord contains: type (childList, attributes, or characterData), target (the node that changed), addedNodes and removedNodes (NodeLists for childList mutations), previousSibling and nextSibling (for childList), attributeName and attributeNamespace (for attribute mutations), oldValue (if attributeOldValue or characterDataOldValue was set in the config).
Configuration and Scope
The observe() method takes a target node and a config object. The three mutation types are childList (node additions/removals), attributes (attribute changes), and characterData (text content changes). At least one must be true. The subtree flag extends observation to all descendants of the target, not just its direct children.
The subtree flag is where the performance implications explode. Observing document.body with { childList: true, subtree: true } means every node addition or removal anywhere in the document body generates a MutationRecord. A single framework re-render that touches 500 elements produces 500 records. Each record holds references to DOM nodes (addedNodes, removedNodes, target), keeping those nodes in memory until the records are processed and garbage collected.
The Cost Model
The cost of MutationObserver breaks down into three parts. First, the browser must track which mutations match which observers -- this is internal bookkeeping proportional to the number of active observers and the breadth of their subtree scopes. Second, constructing MutationRecord objects has allocation cost proportional to the number of mutations. Third, processing the record array in your callback is your own code's cost.
The attributeFilter option is a critical optimization. Instead of { attributes: true, subtree: true } (which records every attribute change on every descendant), you can specify { attributes: true, attributeFilter: ['class', 'data-state'] } to only track specific attributes. This dramatically reduces the number of records generated, because the browser skips the record creation entirely for non-matching attributes rather than filtering post-hoc.
Setting attributeOldValue: true or characterDataOldValue: true increases memory pressure because the browser must snapshot string values before each mutation. For frequently-changing attributes (like style during animations), this means allocating a new string copy on every frame.
Mutation Records and Memory
MutationRecords hold strong references to DOM nodes. If you observe a subtree and thousands of nodes are added and removed, the record array keeps all those nodes alive in memory until the callback processes and discards the records. For long-lived observers on dynamic pages, this can cause memory pressure spikes. Calling observer.takeRecords() lets you manually drain the queue without waiting for the microtask, which is useful if you need to clear records before triggering more mutations.
Interaction with Frameworks
Modern frameworks like React do not use MutationObserver internally -- they have their own virtual DOM diffing. However, third-party code that observes the DOM (analytics scripts, accessibility tools, browser extensions) often uses broad MutationObservers. Multiple observers on the same subtree multiply the record creation cost. If an analytics script observes document.body with subtree: true and your accessibility tool does the same, every DOM change generates records for both.
disconnect() stops all observation immediately. There is no unobserve() for individual nodes -- you either disconnect the entire observer or keep it running. To change what you are observing, disconnect and re-observe.
Gotchas
subtree: trueondocument.bodygenerates records for every DOM mutation on the page -- framework renders, third-party scripts, and dynamic content all produce records. Scope your observation as narrowly as possible.- MutationRecords hold strong references to added/removed nodes -- if thousands of nodes are removed and you do not process records quickly, those nodes remain in memory until the callback runs and the records are garbage collected.
- Callbacks fire as microtasks, not tasks -- they run before
requestAnimationFrame, beforesetTimeout, and before the browser paints. Long-running callbacks directly delay rendering. - There is no
unobserve()method -- you can onlydisconnect()the entire observer. To stop watching one node while continuing to watch another, you must disconnect and re-observe all remaining targets. - Modifying the DOM inside a MutationObserver callback generates new records -- these are queued and delivered in the next microtask checkpoint, not appended to the current batch. This can create rapid cascading callback chains.