MutationObserver batches records per microtask checkpoint, so a single burst of DOM changes is already one callback — but a stream of changes across many tasks (live feeds, typing, polling) still produces one callback per task; collect records into a set of dirty targets and process them once per animation frame, or once per idle period for non-visual work.
Problem / Scenario Context
A trading dashboard updates prices by writing text into hundreds of cells from a WebSocket, one message at a time, dozens of messages per second. A MutationObserver watches the table to flash changed cells and re-sort the table when a price crosses a threshold. Each WebSocket message arrives in its own task, so the observer runs its callback — including a full re-sort — dozens of times per second, and the table stutters.
The observer's built-in batching does not help because the changes are spread across tasks. The Callback Throttling & Debouncing topic covers throttling in general; this page covers MutationObserver specifically, building on MutationObserver microtask timing.
Mechanics Explanation
The mutation observer microtask is queued at the first mutation in a task and delivered at that task's microtask checkpoint. So:
- Many mutations in one task → one callback with many records. Already batched.
- One mutation in each of many tasks → many callbacks, one or a few records each. No batching.
Work in the callback that is expensive and idempotent — re-sorting, recomputing layout-dependent state, updating derived counters — only needs to run once for the latest state, not once per change. The standard technique is to separate collecting (cheap, in the callback) from processing (expensive, scheduled):
- Frame batching (
requestAnimationFrame): process at most once per frame, right before rendering. Right for visual updates. - Microtask batching (
queueMicrotask): process once at the end of the current checkpoint. Useful when several observers or sources contribute changes within one task; it does not reduce the per-task rate. - Idle batching (
requestIdleCallback, orscheduler.postTaskbackground): for non-visual work such as persistence or analytics.
Deduplicating by target turns "500 records" into "37 changed cells", which is often the bigger win.
Comparison Table: Batching Strategies
| Strategy | Max runs per second | Latency added | Good for |
|---|---|---|---|
| Process in callback | one per task (unbounded) | none | cheap per-record work |
queueMicrotask batch |
one per task | none | merging several observers in one task |
requestAnimationFrame batch |
display refresh rate | up to one frame | visual updates |
| Time throttle (e.g. 100 ms) | 10 | up to 100 ms | coarse recalculation |
| Idle batch | when idle | variable | persistence, analytics |
Minimal Reproducible Example
const table = document.querySelector('#prices')!;
new MutationObserver((records) => {
for (const r of records) flash(r.target.parentElement!);
resortTable(table); // expensive, once per task
}).observe(table, { characterData: true, subtree: true, childList: true });
socket.onmessage = (ev) => applyTick(JSON.parse(ev.data)); // one DOM write per message
declare const socket: WebSocket;
declare function flash(el: Element): void;
declare function resortTable(t: Element): void;
declare function applyTick(t: unknown): void;
With 60 messages per second, the table re-sorts 60 times per second — often more than once per frame.
Production-Safe Solution
type FrameProcessor = (dirty: Set<Element>) => void;
export function frameBatchedObserver(
root: Node,
options: MutationObserverInit,
process: FrameProcessor,
): { flush: () => void; disconnect: () => void } {
const dirty = new Set<Element>();
let scheduled = 0;
const run = (): void => {
scheduled = 0;
if (!dirty.size) return;
const batch = new Set(dirty);
dirty.clear();
process(batch);
};
const collect = (records: MutationRecord[]): void => {
for (const r of records) {
// characterData targets are text nodes: attribute the change to their element.
const el = r.target.nodeType === Node.TEXT_NODE ? r.target.parentElement : (r.target as Element);
if (el?.isConnected) dirty.add(el);
}
if (!scheduled && dirty.size) scheduled = requestAnimationFrame(run);
};
const mo = new MutationObserver(collect);
mo.observe(root, options);
return {
flush(): void { // synchronous, e.g. before teardown or in tests
collect(mo.takeRecords());
cancelAnimationFrame(scheduled);
run();
},
disconnect(): void { this.flush(); mo.disconnect(); },
};
}
// Usage: flash each changed cell once per frame, re-sort at most once per frame.
const prices = frameBatchedObserver(table, { characterData: true, subtree: true }, (cells) => {
cells.forEach((c) => flash(c));
if ([...cells].some(crossesThreshold)) resortTable(table);
});
declare function crossesThreshold(cell: Element): boolean;
The callback now only adds elements to a Set — constant time per record — and schedules one frame callback. Sixty messages in a second still produce sixty tiny callbacks, but the re-sort runs at most once per frame, only when needed, and each changed cell flashes once per frame even if it changed three times. The flush method gives teardown and tests a synchronous way to process pending changes, using the pattern from using takeRecords before disconnect.
Choosing the Batch Boundary
The right boundary depends on who consumes the result:
- Pixels — flashes, re-sorts, layout adjustments — want the frame boundary: anything faster is wasted because only one frame is painted.
- Other code in the same task — a second observer or a framework hook that must see a consistent summary — wants the microtask boundary.
- Storage and network — autosave, sync, telemetry — want an idle or time boundary: saving a draft sixty times per second helps nobody, and a 500 ms debounce plus a flush on page hide is plenty.
A single observer can feed several consumers with different boundaries: collect into one dirty set, and let each consumer schedule its own pass. Keep each consumer's processing idempotent so that processing "the latest state" is always correct regardless of how many changes were coalesced.
Verification Steps
- Count processing runs per second with a counter; they should not exceed the display rate.
- Record a trace under a high message rate and confirm short observer callbacks followed by one processing task per frame.
- Throttle the CPU and confirm the table still updates smoothly, just less often.
- Tear down during a burst and confirm
flushprocesses pending changes. - Check correctness: final prices and order must match a non-batched reference.
Common Mistakes to Avoid
- Assuming MutationObserver already throttles. It batches per task, not per time.
- Microtask batching for cross-task streams. It does not reduce the rate.
- Processing records instead of state. Coalesce to targets and read their current state.
- Forgetting disconnected targets. A cell removed before the frame should be skipped.
FAQ
Doesn't MutationObserver already batch?
Yes, within a task: all records produced before the microtask checkpoint arrive in one callback. Changes spread across many tasks each produce their own callback, which is where throttling is needed.
Why use a Set of elements instead of an array of records?
Many records often target the same element. A set keeps one entry per element, and processing reads the element's current state, which already reflects every coalesced change.
Is requestAnimationFrame safe when the tab is hidden?
rAF callbacks do not run in hidden tabs, so processing pauses and the dirty set grows until the tab is visible. For visual work that is ideal. For work that must continue in the background, use a timer or postTask instead.
How do I test frame-batched code?
Call the flush method after mutating, which processes synchronously via takeRecords. That keeps tests fast and independent of animation frames.
What if processing takes longer than a frame?
Then batching alone is not enough. Reduce the work — sort incrementally instead of fully — or split it across frames with scheduler.yield so input can run between chunks.
Can I throttle the observer itself rather than its processing?
No. There is no rate option on MutationObserver. Disconnecting and reconnecting to skip changes loses records; batching the processing is the correct approach.
Related
- MutationObserver Performance with subtree: true — reducing record volume at the source
- When Threshold Arrays Cost More Than They Save — the IntersectionObserver side of throttling
- Yielding from Observer Callbacks with scheduler.yield — when a single pass is too long
↑ Back to Callback Throttling & Debouncing for Observer APIs