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?
answer
- the observer does not know who wrote
- your reaction is itself an observation
- stopping first empties the queue
- desired-state reactions terminate
- a flag cleared too early guards nothing
basics
~20 sThe 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.
solid answer
~50 sA `MutationObserver` does not exempt writes made from inside its own callback: appending into the observed subtree queues another `childList` record, the callback runs again, appends again, and the loop never settles. There are three honest fixes. **Disconnect around the write** — `mo.disconnect()`, mutate, then `mo.observe(...)` again; because `disconnect()` empties the record queue, your own writes leave nothing behind to re-trigger you. **Make the reaction idempotent** — check whether the wrapper already exists and return early, so the second pass produces no write and the loop terminates after one extra turn. **Narrow the registration** so your writes fall outside it: write into a node you do not observe, or use `attributeFilter` so only attributes you never set are recorded. A re-entrancy guard flag works too, but it only helps if the guarded write is genuinely one-shot.
code
javascript · 12 linesconst container = document.createElement('div');
document.body.append(container);
const mo = new MutationObserver(() => {
if (container.querySelector(':scope > .wrapper')) return; // idempotent: nothing to do
const wrapper = document.createElement('div');
wrapper.className = 'wrapper';
container.append(wrapper);
});
mo.observe(container, { childList: true, subtree: true });
container.append(document.createElement('p')); // one extra pass, then it settlesgo deeper
Understand that anything your callback writes into the observed subtree counts as a new mutation, so a callback that always writes will keep re-triggering itself.
Be able to name the concrete fixes and how each works: disconnect and re-observe, drain with takeRecords(), narrow the options, or make the reaction check the desired state first.
Show the tradeoff: disconnecting creates a window where other scripts' mutations are lost entirely, so on a shared DOM an idempotent reaction is the safer design, and explain how you would diagnose the loop from record volume and type.
Own the bigger call — whether reacting to foreign DOM mutations is an acceptable integration contract at all, given it is unversioned and breaks silently when the other team changes their markup.
## Why the loop exists at all `MutationObserver` reports mutations by *category and location*, not by author. There is no "ignore changes I made myself" flag in `MutationObserverInit`. So when a callback registered with `{ childList: true, subtree: true }` on `container` runs `container.append(wrapper)`, that append is an observed child-list mutation exactly like any other. A record is queued, the callback is invoked again, it appends again, and each turn produces the next turn's work. The page stays technically responsive between turns but the DOM grows without bound and the main thread never gets a quiet moment. The same shape appears with attributes, and there it is even easier to write by accident: an observer with `{ attributes: true }` that normalises a value by calling `el.setAttribute('data-state', normalise(el.dataset.state))` re-triggers itself on every pass, and only stops if `normalise` happens to be a fixed point. ## Fix 1 — disconnect, write, re-observe This is the pattern to reach for first because it is explicit and does not depend on the shape of the write: ```js const mo = new MutationObserver(() => { mo.disconnect(); container.append(document.createElement('span')); mo.observe(container, { childList: true, subtree: true }); }); mo.observe(container, { childList: true, subtree: true }); ``` It works because `disconnect()` both unregisters and **empties the record queue**, so the mutations you make while disconnected leave nothing pending. The cost is a real blind spot: any change another script makes to the subtree during your write is not just undelivered, it is unrecorded. In a page where a framework may be mutating the same container, that gap is a correctness risk, not just a theoretical one. If you want to stay registered instead, the equivalent is to write and then drop what you caused with `mo.takeRecords()` before returning. It has the same blind-spot problem in reverse: `takeRecords()` discards *everything* queued, including other scripts' mutations, not only yours. ## Fix 2 — make the reaction idempotent Often the better answer. Instead of "a mutation happened, so do the work", write "a mutation happened, so bring the DOM to the desired state if it is not already there": ```js const mo = new MutationObserver(() => { if (container.querySelector(':scope > .wrapper')) return; // already correct container.append(makeWrapper()); }); ``` Now the loop is self-limiting: the write queues one more callback, that pass finds nothing to do, and the cycle stops. Idempotence is also what makes the observer robust when *other* code makes the same change — it does not double-apply. The price is one extra callback turn and a DOM read per turn, which is usually irrelevant next to the correctness win. ## Fix 3 — narrow the registration Sometimes the loop is a symptom of an over-broad registration. If your reaction writes attributes, and you only care about child insertions, do not enable `attributes` at all. If you must observe attributes, use `attributeFilter: ['data-src']` so the `data-loaded` flag you set yourself is invisible to the observer. If your reaction can write into a sibling or a detached node instead of the observed subtree, the loop disappears by construction. ## The guard-flag variant, and its limits ```js let writing = false; const mo = new MutationObserver(() => { if (writing) return; writing = true; container.append(makeWrapper()); writing = false; // ← does not help }); ``` This one is a trap. The flag is reset before the callback returns, but the record your write queued is delivered *later*, when `writing` is already `false`. A guard only works if it is cleared after delivery of the records you caused — which in practice means clearing it from a queued task or, more simply, using `takeRecords()`/`disconnect()` instead. Interviewers like this detail because it separates people who have debugged the loop from people who have read about it. ## Diagnosing it in the wild Symptoms are a growing DOM, a callback whose record arrays are small but endless, and CPU that never drops to idle. In the debugger, log `records.length` and `records[0].type` on every invocation: a self-triggering loop shows a steady trickle of one or two records of the same type with the same `target`, unlike genuine app churn which is bursty and varied. Then ask the decisive question — does this callback write into the subtree it observes? If yes, apply one of the fixes above; if you cannot avoid writing there, idempotence is the fix that keeps working when a third script starts mutating the same container.
- Why does a boolean re-entrancy flag set and cleared inside the callback usually fail to stop the loop?Because the flag is cleared before the records your write produced are delivered. Delivery happens after the callback returns, so the next invocation sees the flag already `false` and does the work again. A guard only helps if it stays set until the records you caused have been consumed — which is precisely what `takeRecords()` or `disconnect()`/re-`observe()` achieve directly, without the extra state.
- What do you lose by disconnecting around your own writes?Visibility. While disconnected the observer records nothing, so mutations another script makes to the same subtree during that window are gone — not delayed, gone. On a page where a framework or a third-party widget also mutates the container, that can drop the very change you were watching for. An idempotent reaction avoids the window entirely, which is why it is usually the more robust fix.
- Does the same feedback risk exist when the callback only reads the DOM?No — reads never queue records, so a read-only callback cannot re-trigger itself no matter how broad the registration. The risk is specific to writes that land inside an observed node's watched categories. That is also why splitting a callback into "decide from records, then write outside the observed subtree" removes the problem structurally.
saying these in an interview costs you the question
- Assumes an observer ignores mutations made in its own callback
- Believes a flag cleared before returning prevents re-entry
- Calls disconnect() without ever re-observing, silently killing the feature
- Fixes the loop with a setTimeout delay instead of the cause
- Reacts unconditionally instead of checking the desired state first