Call observer.takeRecords() immediately before observer.disconnect() and process what it returns, because disconnect() silently discards every record queued since the last delivery — and the same synchronous flush lets you pause around bulk edits or drop records caused by your own writes.

Problem / Scenario Context

An autosave feature for a rich-text editor observes the editor's DOM and marks the document dirty whenever it changes. When the user types a character and immediately clicks "Close", the close handler disconnects the observer and checks the dirty flag — which is still false. The last keystroke is lost. Reproducing it takes fast fingers; users with autofill or paste hit it constantly.

The character was recorded. It was waiting for the next microtask checkpoint, and disconnect() threw it away. The MutationObserver DOM Change Tracking topic describes the queue; this page is about emptying it deliberately.

Mechanics Explanation

Each MutationObserver has a record queue. Mutations append records to it; at the microtask checkpoint, the engine takes the queue's contents, empties it and invokes the callback. Between the mutation and the checkpoint, records sit in the queue.

Two methods act on that queue synchronously:

  • takeRecords() returns the queued records as an array and empties the queue. The callback will not be called for them.
  • disconnect() stops observation and empties the queue without returning anything. Queued records are gone.

So any code path that disconnects in the same task as a mutation — a close button that edits the DOM and then tears down, a component that unmounts right after an update, a test that asserts right after acting — loses the most recent changes unless it calls takeRecords() first.

The Record That disconnect() DropsA timeline within one task. A keystroke inserts a character, which queues a mutation record. Still in the same task, the close handler calls disconnect, which empties the queue. The microtask checkpoint arrives with nothing to deliver, so the dirty flag is never set. With takeRecords before disconnect, the handler receives the record synchronously and sets the flag.One task: keystroke, then closetaskinsert charclose handlerrecord queue1 recordemptied, lostcheckpointnothing0ms2ms4ms6ms8ms10msdisconnect()

Comparison Table: Queue Operations

Operation Returns records? Empties the queue? Stops observing? Callback called for queued records?
Wait for the checkpoint via callback yes no yes
takeRecords() yes yes no no
disconnect() no yes yes no — records discarded
takeRecords() then disconnect() yes yes yes no — you process them
observe() again on the same target no no no yes, later

Minimal Reproducible Example

TypeScript
let dirty = false;
const editor = document.querySelector<HTMLElement>('[contenteditable]')!;
const mo = new MutationObserver(() => { dirty = true; });
mo.observe(editor, { childList: true, characterData: true, subtree: true });

function closeEditor(): void {
  editor.append('!');            // a last-moment edit (or the user's last keystroke)
  mo.disconnect();               // queued record discarded
  console.log('dirty?', dirty);  // false — the change is lost
}
closeEditor();

Production-Safe Solution

Wrap the observer in a small class whose teardown always flushes, and give callers an explicit flush() for synchronous checkpoints of their own.

TypeScript
type RecordsHandler = (records: MutationRecord[]) => void;

export class FlushableObserver {
  #mo: MutationObserver;
  #target: Node;
  #options: MutationObserverInit;
  #handler: RecordsHandler;

  constructor(target: Node, options: MutationObserverInit, handler: RecordsHandler) {
    this.#target = target;
    this.#options = options;
    this.#handler = handler;
    this.#mo = new MutationObserver((records) => handler(records));
    this.#mo.observe(target, options);
  }

  /** Process anything queued right now, synchronously. */
  flush(): void {
    const pending = this.#mo.takeRecords();
    if (pending.length) this.#handler(pending);
  }

  /** Stop observing without losing the last changes. */
  disconnect(): void {
    this.flush();
    this.#mo.disconnect();
  }

  /** Run a bulk edit without observing it; optionally report it as one change. */
  pause<T>(edit: () => T, { report = false } = {}): T {
    this.flush();                          // earlier changes still count
    this.#mo.disconnect();
    try { return edit(); }
    finally {
      this.#mo.observe(this.#target, this.#options);
      if (report) this.#handler([]);       // signal "something changed" without per-node records
    }
  }
}

// Autosave that never loses the last keystroke:
let dirty = false;
const watcher = new FlushableObserver(editor, { childList: true, characterData: true, subtree: true },
  () => { dirty = true; });

function closeEditorSafely(): void {
  watcher.disconnect();                     // flushes first
  if (dirty) void save();
}

declare const editor: HTMLElement;
declare function save(): Promise<void>;

pause() covers the other common need: applying a large programmatic change — loading a document, normalising whitespace — without flooding the handler with records for edits the code made itself.

A Teardown That Keeps the Last ChangeFour steps. The user's last edit queues a record. The close handler calls the wrapper's disconnect. The wrapper calls takeRecords and passes the returned record to the handler, which sets the dirty flag. Only then does it call the native disconnect.1Last editA record is queued, waiting for the checkpoint.2disconnect() on thewrapperCalled in the same task, before any checkpoint.3takeRecords() → handlerThe pending record is processed synchronously; dirty = true.4Native disconnect()Queue is already empty, nothing is lost.

Ignoring Your Own Writes

A handler that normalises the DOM (merging adjacent text nodes, stripping unsupported tags) mutates the subtree it observes, which queues records that would call the handler again. takeRecords() right after the self-write discards exactly those records:

TypeScript
const mo = new MutationObserver((records) => {
  if (!needsNormalising(records)) return;
  normalise(editor);          // writes to the observed subtree
  mo.takeRecords();           // drop the records our own write produced
});

declare function needsNormalising(r: MutationRecord[]): boolean;
declare function normalise(el: HTMLElement): void;

Because JavaScript is single-threaded, nothing else can mutate the DOM between normalise returning and takeRecords() running, so this discards only the handler's own records. It is the MutationObserver equivalent of the loop guard ResizeObserver has built in — see MutationObserver microtask timing for why an unguarded self-write can spin.

Unguarded and Guarded Self-WritesTwo columns. An unguarded handler that normalises the DOM queues new records, is called again in the same checkpoint, and can repeat until the page hangs. A guarded handler calls takeRecords right after its own write, discarding those records, so the checkpoint ends after one pass.Unguarded self-writeHandler normalises the observed subtreeIts writes queue new recordsCalled again in the same checkpointCan spin until the page hangstakeRecords() after the writeHandler normalises oncetakeRecords() drops its own recordsCheckpoint ends after one pass

Flushing in Framework Teardown

Framework unmount hooks are the most common place where disconnect() runs in the same task as a final mutation — the framework removes the component's DOM, which is itself a mutation, and then runs cleanup. Each framework's hook is a natural home for the flush:

  • React — the cleanup function returned from useEffect or useLayoutEffect: call watcher.disconnect() on the wrapper, which flushes first.
  • VueonBeforeUnmount runs while the DOM is still intact, which is the better moment to flush than onUnmounted.
  • Svelte — an action's destroy or an $effect teardown.
  • Custom elementsdisconnectedCallback, bearing in mind it also runs on moves, as covered in disconnectedCallback observer cleanup.

In each case the handler that receives the flushed records should be safe to run during teardown: persisting a dirty flag or queuing a save is fine; updating component state that is about to be destroyed is not.

Verification Steps

  • Write a test that mutates and disconnects in the same task and assert the handler still saw the change.
  • Paste into the editor and close immediately in a real browser; the document must be marked dirty.
  • Load a large document through pause() and confirm the handler is not called per node.
  • Put a counter in a normalising handler and confirm it runs once per user edit, not repeatedly.
  • Check error monitoring for hung-page reports after adding self-writing handlers.

Common Mistakes to Avoid

  • Calling disconnect() alone in teardown. It discards queued records silently.
  • Calling takeRecords() and ignoring the result. That is the same as discarding them — only do it for your own writes.
  • Using setTimeout to "let the observer catch up" before disconnecting. It works by accident and adds a delay; flush explicitly.
  • Pausing without flushing first. Changes made just before the pause are lost.

FAQ

Does takeRecords trigger the callback?

No. It returns the queued records to the caller and empties the queue. The callback is not called for those records; the caller is responsible for them.

Is it safe to call takeRecords inside the callback?

Yes. Inside the callback it returns records queued since the delivery began — typically those produced by the callback's own writes — which is exactly what the self-write guard relies on.

Do records survive if I disconnect and observe again immediately?

No. disconnect empties the queue. Records queued before it are lost regardless of what happens afterwards, which is why pause flushes first.

Does this matter in tests?

Very much. A test that mutates the DOM and asserts synchronously sees no callback effects yet. Either await a microtask before asserting, or have the code under test expose a flush method that calls takeRecords.

Should every MutationObserver teardown call takeRecords?

Every teardown where the handler has side effects that matter — saving, syncing, analytics — should. For purely cosmetic observers whose effects would be thrown away with the component anyway, dropping the last records is harmless, but flushing costs almost nothing and removes a class of subtle bugs.

What happens to records queued while an observer is paused?

Nothing is queued while it is disconnected: mutations during the pause are simply not observed. That is why pause() in the wrapper flushes before disconnecting and optionally reports one synthetic change afterwards.

Is there an equivalent for ResizeObserver or IntersectionObserver?

IntersectionObserver has takeRecords, which returns pending entries computed but not yet delivered. ResizeObserver does not; its entries are gathered during the rendering steps and there is no queue to flush in between.


↑ Back to MutationObserver DOM Change Tracking