Batching DOM Reads and Writes to Avoid Thrashing

Your chart update loop touches the DOM in a tight loop — read a position, set a style, read the next, set the next — and the frame time balloons even though the work looks trivial.

The root cause is forced synchronous layout: the browser keeps a “dirty” flag on layout and only recomputes it when you ask a question whose answer depends on layout. Reading offsetWidth or getBoundingClientRect() after you have written a style forces the browser to flush the pending layout right then, mid-loop, so an interleaved read/write sequence recomputes layout on every iteration instead of once at the end. This guide is part of DOM Impact & Reflow Optimization, and it is the mechanism behind its companion on reducing layout thrashing in real-time charts: separate every read from every write so layout is computed once per frame, not once per element.

Diagnostic checklist

Interleaved reads and writes versus batched phases The top timeline alternates read, write, read, write and forces a layout after each write. The bottom timeline groups all reads then all writes, forcing layout only once. Interleaved: layout flushed every write read write read write layout layout N writes → N layouts Batched: read phase, then write phase read read write write one layout N writes → 1 layout
Every read after a write flushes pending layout. Group all reads first, then all writes, and the browser recomputes layout once at the next paint instead of once per element.
Batched vs interleaved layout reads Reflow counts when reads and writes are batched versus interleaved. forced reflowsbatched read4batched write5interleaved read62interleaved write63
Why dom reads and writes to avoid thrashing pays off: batching reads and writes (green/blue) keeps forced reflows low; interleaving them (red) multiplies layout work.

Broken vs fixed

// ❌ BROKEN: reads and writes interleave, forcing layout on every iteration.
function alignBars(bars: HTMLElement[]): void {
  for (const bar of bars) {
    const width = bar.offsetWidth;        // READ → flushes layout dirtied by prior write
    bar.style.width = `${width * 1.5}px`;  // WRITE → dirties layout again
    // Next iteration's offsetWidth read flushes it once more: O(n) forced layouts.
  }
}
// ✅ FIXED: FastDOM-style separation — measure everything, then mutate everything.
function alignBarsBatched(bars: HTMLElement[]): void {
  // READ phase: collect every layout value up front, no writes in between.
  const widths: number[] = bars.map((bar) => bar.offsetWidth);
  // PERF: layout is now clean; the write phase never triggers a mid-loop flush.
  // WRITE phase: apply all mutations; layout stays dirty until the next paint.
  bars.forEach((bar, i) => {
    bar.style.width = `${widths[i] * 1.5}px`;
  });
  // A11Y: if these bars encode data, update their aria-valuenow in the write phase too,
  // so assistive tech reads the new values without a separate layout-touching pass.
}

The whole fix is captured in one rule: never read a layout property after a write in the same synchronous run. In the broken version each offsetWidth read forces the browser to resolve the layout that the previous style.width write invalidated, so the cost is one full layout per bar. In the fixed version the reads all happen while layout is already clean, and the writes all happen afterwards without any read demanding a fresh answer, so the browser coalesces every mutation into a single layout at the next paint. The asymptotic difference is O(n) forced layouts versus O(1).

Step-by-step fix

// A minimal FastDOM: queue reads and writes, flush both in one animation frame.
type Task = () => void;

class DomScheduler {
  private reads: Task[] = [];
  private writes: Task[] = [];
  private scheduled = false;

  measure(task: Task): void { this.reads.push(task); this.schedule(); }
  mutate(task: Task): void { this.writes.push(task); this.schedule(); }

  private schedule(): void {
    if (this.scheduled) return;
    this.scheduled = true;
    requestAnimationFrame(() => {
      const reads = this.reads; const writes = this.writes;
      this.reads = []; this.writes = []; this.scheduled = false;
      for (const r of reads) r();   // all reads first: layout resolved at most once
      for (const w of writes) w();  // then all writes: no read forces a re-flush
    });
  }
}

Verification

// Count forced layouts by patching the getter; assert it stays constant with n.
function countForcedLayouts(run: () => void): number {
  let count = 0;
  const proto = HTMLElement.prototype as unknown as { offsetWidth: number };
  const original = Object.getOwnPropertyDescriptor(proto, "offsetWidth")!;
  Object.defineProperty(proto, "offsetWidth", {
    get(this: HTMLElement): number { count++; return original.get!.call(this); },
    configurable: true,
  });
  run();
  Object.defineProperty(proto, "offsetWidth", original);
  console.assert(count <= 1 || count === /* one per element in read phase */ count, "reads batched");
  return count;
}

In DevTools, record a Performance profile of the update and look at the “Layout” track: the batched version shows a single Layout entry per frame, while the interleaved version shows a staircase of them. The purple “Recalculate Style” and “Layout” bars should collapse from many-per-frame to one-per-frame after the split.

Why the read/write boundary is the whole game

Browsers are lazy about layout on purpose. When you write element.style.width, the engine does not immediately recompute geometry — it marks the layout tree dirty and moves on, expecting to batch that work until it is actually needed, which is normally just before the next paint. This laziness is what makes a hundred style writes in a row cheap: they all dirty the same tree and it is resolved once. The trap is any API that needs an up-to-date answer — offsetWidth, offsetTop, getBoundingClientRect(), getComputedStyle(), scrollHeight, and their kin — because to answer honestly the browser must flush the pending layout synchronously, on the spot. Put one of those after a write and you have cancelled the laziness for that element and everything above it in the tree.

This is why the fix is structural rather than a micro-optimization. You are not making layout faster; you are making it happen the number of times it fundamentally must (once, at paint) instead of a number of times that scales with your loop. The broader thrashing guide shows this at the scale of a streaming chart, where a data tick that appends points and immediately measures them can force a layout on every incoming datum; batching the appends into a write phase and the measurements into a read phase turns a per-datum reflow into a per-frame one. The same discipline underlies SVG label measurement, where reading getComputedTextLength() in a write loop is the exact same anti-pattern wearing different clothes.

Edge cases & gotchas

  • getBoundingClientRect() is a read, not a write. It looks passive but it forces layout to answer. Treat every geometry query as a read that must sit in the read phase, before any style mutation.
  • Reading your own writes across frames. Deferring writes into requestAnimationFrame means a value you write this frame is not measurable until layout resolves; if you need the post-write geometry, read it in the next frame’s read phase, not synchronously.
  • Class changes are writes too. classList.add() and setAttribute() dirty layout exactly like inline styles. A read after a class toggle forces a flush just as style.width does.
  • Third-party code hidden in the loop. A charting or tooltip library called inside your loop may read layout internally, silently re-introducing the interleave. Profile the real call, not just your own lines.
  • scrollTop assignment triggers layout. Setting scroll position is a write that also reads current layout to clamp the value, so a scroll adjustment mid-loop can force a flush even without an explicit geometry read.
  • Over-deferring breaks input latency. Pushing everything into rAF adds up to a frame of delay; for a direct response to a click or keypress, do the minimal synchronous write and batch only the bulk work, so the interface still feels immediate.
SVG vs Canvas render-cost crossover Frame cost against element count for a DOM-node SVG chart and a single-element Canvas. element count (×1k)frame costSVG (DOM nodes)Canvas (1 element)
The render-cost crossover underlying dom reads and writes to avoid thrashing: SVG cost climbs with node count while Canvas stays flat, which is why heavy scenes switch engines.

Frequently Asked Questions

What is layout thrashing and what causes it?

Layout thrashing is the repeated forced recomputation of layout within a single frame, caused by interleaving DOM writes with reads of layout-dependent properties. When you write a style the browser marks layout dirty but defers recomputing it; when you then read offsetWidth or getBoundingClientRect() it must flush that pending layout synchronously to give an accurate answer. Doing this in a loop forces one layout per iteration instead of one per frame.

How does the FastDOM pattern prevent forced synchronous layout?

FastDOM separates DOM access into two queues — reads and writes — and flushes them in order inside a single requestAnimationFrame callback: every queued read runs first while layout is clean, then every queued write runs together. Because no read follows a write within the flush, the browser never has to resolve layout mid-batch, so the whole update triggers at most one layout just before paint.

Which DOM properties force a synchronous layout when read?

The geometry and box-metric properties: offsetWidth, offsetHeight, offsetTop, offsetLeft, clientWidth, clientHeight, scrollWidth, scrollHeight, scrollTop, getBoundingClientRect(), and getComputedStyle() for computed layout values. Reading any of them after a style or class mutation flushes the pending layout. Group these reads before any writes so the flush happens at most once.