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
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
requestAnimationFramemeans 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()andsetAttribute()dirty layout exactly like inline styles. A read after a class toggle forces a flush just asstyle.widthdoes. - 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.
scrollTopassignment 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.
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.
Related
- DOM Impact & Reflow Optimization — the parent guide to reflow cost in charts.
- Reducing layout thrashing in real-time charts — this batching discipline applied to a streaming update loop.
- Measuring text width to avoid label collisions — the same read-then-write rule for SVG text measurement.