skip to content

What does MutationObserver's takeRecords() return, and what happens to records that were queued but not yet delivered when you call disconnect()?

level: middleimportance: should knowfreq 42%

answer

  1. the observer has a queue between deliveries
  2. draining is synchronous and destructive
  3. stopping is not the same as flushing
  4. teardown can lose the last batch
  5. records keep removed nodes alive

basics

~10 s

takeRecords() synchronously returns the observer's queued but undelivered MutationRecords and empties the queue, so the callback will not receive them again. disconnect() stops all observation and also empties that queue, silently discarding anything pending.

solid answer

~40 s

An observer keeps a record queue between deliveries. `takeRecords()` returns that queue as an array **right now** and clears it, which is how you inspect pending mutations without waiting for the callback — and how you deliberately drop mutations you caused yourself. `disconnect()` removes every registration the observer has and *also* empties the record queue, so pending records are thrown away rather than delivered one last time. That is the trap: if teardown matters, call `handle(mo.takeRecords())` before `mo.disconnect()` so no final batch is lost. The same pairing is the standard way to make an observer that writes to its own observed subtree safe: disconnect, mutate, then re-`observe()` — the disconnect leaves nothing queued to re-trigger you.

code

javascript · 14 lines
javascript
const root = document.createElement('div');
document.body.append(root);

function handle(records) {
  for (const r of records) console.log(r.type, r.addedNodes.length);
}

const mo = new MutationObserver(handle);
mo.observe(root, { childList: true, subtree: true });

root.append(document.createElement('span')); // queues a record

handle(mo.takeRecords()); // drain it now, synchronously
mo.disconnect();          // stop; nothing pending is lost

go deeper

for a junior

Know the two control methods: takeRecords() hands you the pending records immediately, and disconnect() stops the observer entirely.

for a middle

Explain that both empty the queue, that disconnect() therefore discards pending records rather than flushing them, and that the observer can be re-registered afterwards.

for a senior

Demonstrate the teardown discipline: flush before disconnect when the last batch matters, disconnect on unmount to stop the callback closure retaining state, and avoid holding record arrays that pin detached nodes.

for a principal

Set the convention for observer ownership — who registers, who disconnects, and how that is enforced in reviews — so long-lived pages do not accumulate orphaned observers nobody remembers registering.

## The observer's queue Between deliveries, a `MutationObserver` accumulates `MutationRecord` objects in a private queue. Your callback normally sees that queue as its first argument, and the queue is emptied when the callback is invoked. Two methods let you control the queue directly. ## takeRecords() `observer.takeRecords()` returns the currently queued records as an array and empties the queue synchronously. Two things follow. First, whatever it returns will **not** be handed to your callback later — you have taken them. Second, it is the only way to read pending mutations at a moment of your choosing rather than when the browser decides to deliver them. Typical uses: - **Flush before teardown.** You are unmounting a widget and want the last batch processed. - **Discard your own writes.** You are about to mutate the observed subtree from your own code and do not want records for those writes. - **Read at a synchronisation point.** You need the current mutation state at the end of a synchronous routine rather than after delivery. ```js function handle(records) { for (const r of records) console.log(r.type, r.target.nodeName); } const mo = new MutationObserver(handle); mo.observe(root, { childList: true, subtree: true }); // later, on teardown: handle(mo.takeRecords()); // process what is still queued mo.disconnect(); // then stop ``` ## disconnect() `observer.disconnect()` removes every registration the observer holds — all targets at once; there is no per-target `unobserve()` on `MutationObserver`. It also empties the record queue. That second half is what people forget: pending records are discarded, not flushed. If you disconnect at the end of a synchronous block that mutated the DOM, the callback for those mutations never runs. After disconnecting you can register again with `observe()`; the observer object is reusable and there is no need to construct a new one. ## Why the pairing matters for correctness The classic use is an observer that must react by writing to the very subtree it is watching. Because the write queues new records, the callback would run again. Disconnecting first is the clean way out: ```js const mo = new MutationObserver(() => { mo.disconnect(); normalise(container); // our own DOM writes mo.observe(container, { childList: true, subtree: true }); }); mo.observe(container, { childList: true, subtree: true }); ``` Because `disconnect()` empties the queue, no records from `normalise()` survive to re-trigger the callback. If instead you kept observing and only re-registered options, you would want `mo.takeRecords()` after the write to drop what you just caused. ## Why the pairing matters for teardown An observer registered by a component that is being removed should always be disconnected. Leaving it registered means the callback keeps running for changes nobody cares about, and the callback closure keeps alive whatever it captured — caches, DOM references, the component instance. That is the ordinary reason MutationObserver shows up in leak reviews: not the observer itself, but the closure and the record array it feeds. Records are worth a thought too. A `childList` record holds strong references to its `addedNodes` and `removedNodes`. If you take a large batch of records and keep the array around — stashing it for later processing, or holding it in a paused queue — you keep every removed node alive with it. Process and release, or copy out only the fields you need. ## The shape of a good answer "`takeRecords()` drains the pending queue synchronously and returns it; those records will not be delivered again. `disconnect()` unregisters everything and also empties the queue, so pending records are lost — flush with `takeRecords()` first if the last batch matters. And I use disconnect-mutate-reobserve when the callback has to write into the subtree it watches."

  • MutationObserver has no unobserve(). How do you stop watching one element while continuing to watch others?
    You cannot do it per target on a single observer — `disconnect()` is all-or-nothing. The practical patterns are to keep one observer per logical target so disconnecting is precise, or to re-register: `disconnect()` and then call `observe()` again for the targets you still care about. Ignoring unwanted records inside the callback works but keeps paying the record-allocation cost.
  • After disconnect(), can the same observer be used again?
    Yes. `disconnect()` clears the registrations and the record queue but does not invalidate the observer, so calling `observe()` again reactivates it with whatever options you pass. That reusability is what makes the disconnect-mutate-reobserve pattern cheap — you are not constructing a new observer and a new callback closure on every callback invocation.

saying these in an interview costs you the question

  • Assumes disconnect() delivers a final batch first
  • Thinks takeRecords() copies the queue without clearing it
  • Expects an unobserve() method for a single target
  • Never disconnects when the owning component is removed
  • Stashes large record arrays and keeps detached nodes alive

context