How to Batch DOM Reads and Writes
This guide is part of Layout Thrashing & Forced Reflow, within Rendering & CSS Performance. Layout thrashing happens when code alternates between changing the DOM (writes) and measuring it (reads). Each read after a write forces the browser to compute layout synchronously. The fix is conceptually simple: do all the reads first, then all the writes, so layout runs at most once. In practice, reads and writes are spread across functions, components and libraries, and keeping them separated needs a pattern that survives as code grows.
This guide covers three levels: restructuring a single function into phases, coordinating reads and writes across modules with a scheduler, and avoiding reads entirely by caching geometry or using observers.
Rapid Diagnosis
- Find forced reflow warnings in a Performance trace and note the functions involved.
- Look for loops that both set styles and read geometry.
- Look for components that measure themselves in mount or update hooks, many at once.
- Check scroll and resize handlers for both reading and writing.
Root Cause Analysis
1. Natural code order. It is intuitive to measure an element and immediately adjust it.
2. Distributed responsibilities. Each component measures and adjusts itself, unaware of others doing the same.
3. Helper functions with hidden reads. Utilities measure inside otherwise write-only code.
4. Event handlers that do everything. One handler updates state, applies styles and measures the result.
Step-by-Step Resolution
1. Split one function into read and write phases
function syncLabelWidths(labels) {
// Read phase
const widths = labels.map((l) => l.scrollWidth);
const max = Math.max(...widths);
// Write phase
for (const l of labels) l.style.width = `${max}px`;
}
// trade-off: the read phase sees the layout from before any writes in this
// function, which is exactly what you want for equalising sizes.
2. Coordinate across modules with a frame scheduler
// Minimal read/write scheduler (the idea behind fastdom).
const reads = [], writes = [];
let scheduled = false;
function flush() {
scheduled = false;
reads.splice(0).forEach((fn) => fn()); // all reads first
writes.splice(0).forEach((fn) => fn()); // then all writes
}
function schedule() { if (!scheduled) { scheduled = true; requestAnimationFrame(flush); } }
export const measure = (fn) => { reads.push(fn); schedule(); };
export const mutate = (fn) => { writes.push(fn); schedule(); };
// trade-off: work is deferred to the next frame, adding up to ~16ms latency, and
// a write that triggers a new read must queue it for the following frame.
Components call measure() for reads and mutate() for writes; the scheduler guarantees reads run together before writes.
3. Cache geometry that rarely changes
If code repeatedly reads values that only change on resize (container widths, header height), read them once and update the cache from a ResizeObserver callback instead of reading on every use.
4. In frameworks, measure in one place
In React, read layout in a single useLayoutEffect at a parent that measures all children, rather than in each child. In Vue, batch measurements in nextTick callbacks. Avoid setting state from measurements in ways that trigger another render and another measurement per item.
Verification
Record the interaction again: Layout events inside script should be gone or reduced to one per task, and the forced reflow warnings should disappear. Check the task duration and the interaction's duration in the Interactions track. Confirm visual behaviour is unchanged — deferring writes to the next frame can occasionally show one frame of the old state, which may matter for drag interactions.
Worked Example: A Kanban Board
A project management app's Kanban board auto-sized card titles: each card component measured its title in a mount effect and adjusted the font size if it overflowed. Loading a board with 300 cards triggered 300 forced reflows in one task (520ms on a mid-range laptop), and moving a card re-ran the logic for every card in two columns. The team replaced per-card logic with a board-level effect that read all title overflow states in one pass, then applied font sizes in a second pass, and later replaced font fitting with CSS text-overflow and a two-line clamp. Board load dropped to 70ms of rendering, and card drag interactions stopped dropping frames.
Batching in Animation Loops
Animation loops in requestAnimationFrame are a common place for thrashing: each frame reads positions, computes new ones and writes them for many elements. Structure each frame strictly: read everything needed at the start of the callback (when layout is clean from the previous frame), compute, then write everything. Better still, animate transform values computed from your own state rather than reading positions back from the DOM each frame; your state is already the source of truth, so no read is needed at all. For physics or drag interactions, keep positions in JavaScript variables and only write transforms.
let x = 0, v = 0;
function frame(t) {
v += (target - x) * 0.1; v *= 0.8; x += v; // compute from state, no reads
el.style.transform = `translateX(${x}px)`; // single write
if (Math.abs(v) > 0.1) requestAnimationFrame(frame);
}
// trade-off: if layout changes elsewhere (resize), the cached target may be stale;
// update it from a ResizeObserver rather than reading inside the loop.
Common Mistakes
- Batching writes but leaving a read in the write loop. One stray read reintroduces thrashing.
- Nested schedulers. Reads scheduled from within writes run a frame later; design for that.
- Measuring in every component. Many self-measuring components thrash collectively even if each is fine alone.
- Reading back what you just wrote. Keep values in variables instead of re-reading the DOM.
Edge Cases
Writes that must be measured. Sometimes you must apply a style and measure the result (for example, the natural height of expanded content). Do all such writes, then a single read phase, then the final writes — two layouts instead of hundreds.
Synchronous user feedback. Deferring writes to the next frame is fine for most UI, but for pointer-following elements, write immediately and avoid reads entirely.
Third-party code. You cannot batch inside libraries; call them outside your write phase, or configure them to measure less often.
Server-side rendering. No layout exists on the server; guard layout reads so they only run in the browser after mount.
FAQ
What is fastdom?
A small library that batches DOM reads and writes into separate phases per animation frame, using measure() and mutate() calls. The pattern can be implemented in a few lines.
Does React batch DOM reads?
React batches DOM writes during commit, but reads in layout effects or event handlers are your responsibility. Multiple components reading in layout effects after writes can still thrash.
Is requestAnimationFrame enough to batch?
Only if all reads in the callback come before all writes. rAF schedules the timing; the order inside the callback determines whether layout is forced.
Should I use requestIdleCallback for writes?
No for visible updates: idle callbacks may run late. Use it for non-urgent background work that does not touch layout.
How do I batch in Vue?
Vue applies DOM updates asynchronously; use nextTick to read after updates, and group reads in one callback rather than many component-level ones.
Does batching help on small pages?
Less, because each forced layout is cheap. It matters on large DOMs and in loops, where costs multiply.
Can CSS replace my measure-and-adjust code?
Often. Grid with subgrid, container queries, line clamping and clamp() for font sizes remove many measurement scripts entirely, which is better than batching them.
Related
- Finding forced reflows in the Performance panel — locate the problem first.
- ResizeObserver vs resize and scroll handlers — read sizes without forcing layout.
- Animating only transform and opacity — animation without layout reads.