skip to content

A script makes 500 DOM changes in one synchronous loop while a MutationObserver is watching. The observer's callback runs once with 500 records rather than 500 times. Why does it behave that way, and what does that imply for code written inside the callback?

level: seniorimportance: should knowfreq 28%

answer

  1. queued records, one notification
  2. microtask, not per-change callback
  3. callback sees final DOM state
  4. your own writes are recorded too
  5. takeRecords drains without calling back

basics

~20 s

MutationObserver delivers records as a microtask, not synchronously per mutation. Mutations accumulate in the observer's record queue during the synchronous run and are handed to the callback in one batch at the microtask checkpoint, so the callback sees the final state.

solid answer

~50 s

Mutation records are queued, not dispatched. Each change appends a record to the observer's queue and asks the runtime to schedule a single notification microtask; if one is already pending, no second one is scheduled. The synchronous loop therefore runs to completion, and only at the microtask checkpoint — after your code's current run finishes, before the next task — is the callback invoked once with the whole array of records. Two consequences for the callback body: it must treat records as a *log* of what happened rather than a live signal, because the DOM it now reads is the final state, not the state at each record; and any DOM changes it makes itself are recorded too, and are delivered in the same drain, so a callback that reacts by mutating what it observes can loop indefinitely. `observer.takeRecords()` drains the pending queue without invoking the callback, which is how you avoid reacting to your own writes.

code

javascript · 17 lines
javascript
const target = document.createElement('div');
const observer = new MutationObserver((records, obs) => {
  console.log('callback runs once with', records.length, 'records');

  // Our own writes would be recorded and delivered in the same drain.
  target.setAttribute('data-processed', 'true');
  obs.takeRecords(); // discard the echo instead of re-triggering
});

observer.observe(target, { attributes: true });

for (let i = 0; i < 500; i++) {
  target.setAttribute('data-i', String(i));
}
console.log('loop finished first');
// loop finished first
// callback runs once with 500 records

go deeper

for a junior

Know that a MutationObserver callback is asynchronous and receives an array of records rather than firing once per change. Do not expect it to run in the middle of your loop.

for a middle

Explain the mechanism: each mutation appends a record and schedules at most one notification microtask, so the callback is invoked once at the checkpoint with the whole batch, after your synchronous code finishes.

for a senior

Show the consequences you have hit in production — the DOM read inside the callback is the final state rather than each record's moment, and callback-made mutations feed back within the same drain unless you call takeRecords(). Say how you would diagnose both symptoms.

for a principal

Own the design rationale and its cost: batched microtask delivery buys a consistent, non-re-entrant view at the price of putting the whole batch's work inside the current turn, which shapes how you budget observer callbacks in a large application.

## Delivery is batched by design `MutationObserver` was specified deliberately to *not* call back per change. Where a naive synchronous notification would fire your callback 500 times in the middle of your own loop — re-entering your code while the DOM is half-updated — the observer instead appends a **mutation record** to a queue and arranges for a single notification to run as a microtask. Additional mutations in the same run append more records; they do not schedule additional notifications, because one is already pending. When the microtask finally runs, every observer with a non-empty record queue has its callback invoked once, with its records as the first argument and the observer itself as the second. So the timing is exactly the timing of any other microtask: after the current script or task has run to completion and the call stack is empty, at the microtask checkpoint, before the runtime moves on to the next task. ## Consequence 1: records are history, the DOM is present Inside the callback, the DOM is in its **final** state. Record number 3 may say an attribute changed from `a` to `b`, but by the time you read that element the attribute may be `z` because records 4 through 500 also touched it. Code that reads the live DOM per record and assumes it corresponds to that record's moment is subtly wrong, and it is wrong only under batching — which means it passes every single-mutation test and fails on the loop. The correct discipline is to decide what a record means from the record itself (`type`, `target`, `attributeName`, `oldValue`, `addedNodes`, `removedNodes`) and then either reconcile against the current DOM knowingly, or collapse the batch: derive the set of affected targets, then compute once per target rather than once per record. On a 500-record batch that also turns quadratic work into linear work. ## Consequence 2: your own writes come back to you Mutations made *inside* the callback are themselves recorded. Because the callback runs during a microtask drain, the resulting notification is enqueued into the same drain and runs before the runtime regains control. An observer that reacts to a change by making a similar change therefore re-triggers itself immediately, with no task boundary in between — a feedback loop that never yields. Two standard defences. `observer.takeRecords()` returns the pending records and empties the queue *without* invoking the callback, so calling it right after you finish your own writes discards the echo. Alternatively `disconnect()` before writing and `observe()` again afterwards, accepting that you are blind for that window. Which you pick depends on whether losing genuinely-external mutations during the write is acceptable. ## Consequence 3: cost lands inside the current turn Because delivery is a microtask, the callback's work is part of the *current* turn, not a later one. A batch of 500 records handled with an expensive per-record computation extends that turn directly — the runtime does not get control back until the drain, including your callback, is finished. Cheap per-record work with a single reconciliation pass at the end is not merely tidier; it is what keeps the turn short. ## How this shows up as a bug report The usual report is "my observer only fires once" or "my counter is wrong". The developer wrote `records => count += 1` and expected the number of mutations, or wrote per-record DOM reads and got values that disagree with the record. Both are the same root cause: assuming synchronous, per-change delivery. The diagnostic question to ask is whether the mutations happen inside one synchronous run — if they do, one batched callback is the specified behaviour, and the fix is in the callback, not the observation. The mirror-image report is a page that becomes unresponsive as soon as a particular observer is connected, with the stack showing the callback repeatedly. That is the self-feeding loop, and `takeRecords()` after your own writes is the usual repair. ## Putting it together in an answer Say first *what* the mechanism is — records queue, one notification microtask, callback invoked once at the checkpoint with the whole array. Then give the two consequences that matter in real code: the DOM you read in the callback is the final state rather than the per-record state, and callback-made mutations feed back within the same drain unless you drain them with `takeRecords()`. Finishing with the performance note — batch work lands inside the current turn, so collapse per-record work — is what makes it a senior answer rather than a definition.

  • What does observer.takeRecords() return, and when would you call it?
    It returns the observer's pending mutation records and empties the queue without invoking the callback. You call it when you are about to make, or have just made, DOM changes you do not want to react to — typically writes performed inside the callback itself — so the echo is discarded instead of triggering the callback again in the same drain.
  • If the callback itself mutates the observed subtree, when does the next notification run?
    In the same microtask drain. The mutation queues a record and schedules a notification microtask, which is appended to the queue currently being drained, so it runs before the runtime takes another task. That is why a callback that unconditionally reacts by mutating what it observes never hands control back.
  • Why did the design choose batched microtask delivery instead of a synchronous callback per mutation?
    Synchronous delivery would re-enter application code in the middle of its own DOM edits, with the tree half-updated and the callback able to mutate it further — the re-entrancy problem earlier mutation-event designs suffered from. Deferring to the checkpoint means the callback always sees a consistent, finished state and runs once per batch.

saying these in an interview costs you the question

  • Expects one callback invocation per individual mutation
  • Reads the live DOM per record assuming it matches that record
  • Thinks records are delivered in a later task or timer
  • Reacts by mutating the observed node without draining records
  • Believes disconnect() flushes pending records to the callback

context