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.
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
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.
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.
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:
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.
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
useEffectoruseLayoutEffect: callwatcher.disconnect()on the wrapper, which flushes first. - Vue —
onBeforeUnmountruns while the DOM is still intact, which is the better moment to flush thanonUnmounted. - Svelte — an action's
destroyor an$effectteardown. - Custom elements —
disconnectedCallback, 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
setTimeoutto "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.
Related
- MutationObserver Microtask Timing Explained — when records are delivered
- Unobserve vs Disconnect: When to Use Each — teardown across observer types
- Testing MutationObserver-Driven Code — flushing in tests
↑ Back to MutationObserver DOM Change Tracking