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, or scheduler.postTask background): 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.

Callbacks per Task Versus One Pass per FrameA timeline of about thirty-three milliseconds of WebSocket messages, each in its own task. In the direct approach, the observer callback runs and re-sorts the table after every message, six times across two frames. In the frame-batched approach, the callback only records dirty cells, and one processing pass per animation frame re-sorts the table twice in the same period.WebSocket messages, one per taskmessagesm1m2m3m4m5m6directsortsortsortsortsortsortper framesortsort0ms5ms10ms15ms20ms25ms30ms

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

TypeScript
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

TypeScript
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.

Table Re-Sorts per Second at 60 Messages per SecondA bar chart of how often the expensive re-sort ran. Processing in the callback re-sorted sixty times per second. Microtask batching made no difference because each message was its own task. Frame batching re-sorted at most once per frame and only when a threshold was crossed, about eight times per second on average.Live price table, 60 WebSocket messages/sin the callback60 sorts/squeueMicrotask batch60 sorts/s — one task eachrAF batch + threshold check~8 sorts/s

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.

Batch Boundary by ConsumerA grid of consumers and their appropriate batch boundary. Visual updates such as flashes and re-sorts use one pass per animation frame. Other observers or framework hooks in the same task use a microtask. Autosave and sync use a time debounce with a flush on page hide. Analytics uses idle time.BoundaryWhyVisual updatesanimation frameone paint per frameSame-task consumersmicrotaskconsistent summaryAutosave / sync~500 ms + flush on hideno value in more oftenAnalyticsidle callbacknever urgent

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 flush processes 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.


↑ Back to Callback Throttling & Debouncing for Observer APIs