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?
answer
- one record per mutation, not per batch
- subtree on body means the whole app
- class and style churn constantly
- filter at registration, not in the callback
- records pin the nodes they name
basics
~20 sEvery 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.
solid answer
~50 sA page-wide registration with `subtree: true` makes every DOM change in the application observable, so a render that inserts 5,000 rows and sets attributes on each one allocates a record per mutation and hands one huge array to a single callback that runs on the main thread. The record objects also hold references to `addedNodes` and `removedNodes`, so a big batch is a memory spike as well as a CPU one. The fixes are all about recording less: observe the specific containers you care about rather than `document.body`, enable only the categories you use (dropping `attributes` alone often removes most of the volume), pass `attributeFilter: ['data-id']` so unrelated attribute writes never allocate, and make the callback do near-zero work per record — filter fast, batch what you keep, and never re-query the DOM per record.
code
javascript · 19 linesconst feed = document.querySelector('#feed');
const seen = new Set();
const mo = new MutationObserver((records) => {
for (const r of records) {
if (r.type !== 'childList') continue; // cheapest test first
for (const node of r.addedNodes) {
if (node.nodeType !== Node.ELEMENT_NODE) continue;
const id = node.getAttribute('data-impression-id');
if (id) seen.add(id);
}
}
// act once per batch, not once per record
if (seen.size) flush(seen);
});
function flush(ids) { ids.clear(); }
mo.observe(feed, { childList: true, subtree: true, attributeFilter: ['data-impression-id'] });go deeper
Know that observing document.body with subtree means every DOM change in the page reaches your callback, and that narrowing the target is the first thing to try.
Explain that a record is allocated per mutation, that attributes plus subtree is usually the noisiest combination, and that attributeFilter suppresses records before they are created.
Separate recording cost from callback cost, show how you would confirm which dominates in a profile, and restructure the callback to do batch-level rather than per-record work.
Weigh the whole approach: observing foreign DOM is an unversioned contract with unbounded cost driven by another team's render volume, so decide when to negotiate an explicit signal instead.
## Where the cost actually lands There are three separate costs in a broad `MutationObserver` registration, and a good answer separates them. **Recording.** Every mutation that matches a registration allocates a `MutationRecord`. That object is small, but a framework render that inserts a few thousand nodes and sets a couple of attributes on each produces tens of thousands of them, all during a stretch of the main thread that was already busy. This work is not attributable to your callback in a profile — it shows up inside the DOM operations themselves. **Delivery and your callback.** The queued records are handed to one callback as one array. If your callback does anything per record — a `closest()` walk, a `matches()` test, a `getBoundingClientRect()` — you have multiplied that cost by the batch size and put it all on the main thread in one uninterruptible run. A callback that costs 20 microseconds per record is invisible at 10 records and a 200 ms long task at 10,000. **Retention.** `childList` records hold strong references to their `addedNodes` and `removedNodes` node lists. A large batch therefore pins a large amount of DOM — including detached nodes that would otherwise have been collected — for as long as the array is alive. Stashing records "to process later" turns a CPU spike into a memory one. ## What each option costs you `subtree: true` is the multiplier: it turns "changes to this node" into "changes anywhere below this node". On `document.body` that is the whole application. `attributes: true` combined with `subtree` is usually the single worst contributor on a modern app, because framework rendering and CSS-driven state constantly rewrite `class` and `style`. If you actually care about one data attribute, `attributeFilter: ['data-widget-id']` filters at queue time — the records for other attributes are never allocated at all, which is categorically cheaper than skipping them in your callback. `characterData: true` with `subtree` is similarly noisy on text-heavy pages and is rarely what an integration actually needs. ## The narrowing ladder Work down this list in order: 1. **Observe a smaller target.** One observer per widget root, registered when the widget mounts, instead of one page-wide observer. This is the biggest win and it is usually available — a script that claims it needs the whole body often only needs a container it can find. 2. **Drop categories.** Ask what question the observer answers. "Did a node with this class appear?" needs only `childList` plus `subtree`. Nothing else. 3. **Filter attributes.** If you keep `attributes`, always pair it with `attributeFilter` unless you genuinely want every attribute. 4. **Cheapen the callback.** Do the cheapest discriminating test first — `record.type`, then a `nodeType`/`tagName` check — before anything that touches layout or walks the tree. Collect what survives into a set and act once per batch rather than once per record. 5. **Bound the work.** If a batch can legitimately be enormous, cap what you process per turn and continue later, or de-duplicate by `record.target` so a node mutated 50 times is handled once. ## Interviewing well on this The strong answer names the multiplier explicitly — records are per mutation, and `subtree` on `body` means every mutation in the app — then separates the recording cost from the callback cost, because the fixes differ: narrowing the registration reduces recording, and restructuring the callback reduces delivery cost. It also acknowledges what you cannot fix: you do not control how much the application mutates, so an observer over foreign DOM is inherently at the mercy of someone else's render volume. That is a real argument for negotiating an explicit signal — a custom event, a callback, a data attribute written once — instead of observing everything and inferring. ## A worked narrowing ```js // before: everything, everywhere mo.observe(document.body, { childList: true, subtree: true, attributes: true }); // after: one container, one category, one attribute mo.observe(document.querySelector('#feed'), { childList: true, subtree: true, attributeFilter: ['data-impression-id'], }); ``` The second registration answers the same product question — "which feed items appeared, and what are their ids" — while ignoring every mutation outside `#feed` and every attribute except one. And it must still be disconnected when the feed is torn down, or the callback keeps running for a component that no longer exists.
- Why is attributeFilter cheaper than checking record.attributeName inside the callback?Because the filter is applied when the mutation is queued. Attributes outside the list never allocate a `MutationRecord` and never enlarge the array your callback iterates, so you save the allocation, the delivery, and the per-record branch. Checking inside the callback pays all three costs and only then discards the record — on a page whose framework rewrites `class` on every render, that difference is most of the observer's total cost.
- Your callback receives one batch with 40,000 records. What do you do about it?De-duplicate and bound. Collapse records by `record.target` so a node mutated many times is handled once, keep only the cheapest discriminating fields, and do the expensive work once per batch instead of once per record. If the surviving work is still large, process a slice and continue in a later turn rather than blocking the main thread. And release the array promptly — records hold their added and removed nodes alive.
- Is there a version of this observer that does not run on the main thread?No. `MutationObserver` is not exposed to workers and its callback always runs on the thread that owns the document, so every mitigation is about recording fewer mutations and doing less work per record. If the derived data is genuinely expensive to compute, the realistic split is to extract minimal facts in the callback and post those to a worker for processing.
saying these in an interview costs you the question
- Thinks one observer on body is free because it is a single object
- Assumes records are batched into a fixed small number
- Filters unwanted attributes in the callback rather than at registration
- Runs getBoundingClientRect or closest() once per record
- Keeps record arrays around and pins detached nodes in memory