Read/Write Batching Pattern
The Fundamental Rule
If you take one thing away from this entire module, make it this. The browser's layout engine has one critical optimization: it defers layout recalculation until the result is actually needed. Every DOM write (style change) marks layout as "dirty." The recalculation only happens when something reads a geometry property, or when the browser is ready to paint.
The rule is dead simple:
1. Batch all reads together (one layout calculation)
2. Batch all writes together (layout marked dirty once)
3. Never interleave reads and writes
Sounds simple, right? It is. And yet, violating this rule is the single most common performance bug in DOM-heavy applications.
Think of a whiteboard in a meeting room. Reading the whiteboard (checking what is written) is free — you just look. Writing on the whiteboard is also cheap. But imagine a rule: "After every write, the entire whiteboard must be photographed and archived before anyone can read it." Now, alternating between reading and writing means photographing the whiteboard after every single write. Batch all your reads first, then do all your writes — one photo at the end.
The Problem: Interleaved Reads and Writes
// ❌ Interleaved — each read forces a synchronous layout
function layoutCards(cards) {
cards.forEach(card => {
const width = card.offsetWidth; // READ → forces layout
card.style.padding = '10px'; // WRITE → invalidates layout
const height = card.offsetHeight; // READ → forces layout AGAIN
card.style.margin = '5px'; // WRITE → invalidates layout
});
}
For 100 cards, this causes ~200 forced layout recalculations. Each recalculation may involve thousands of DOM nodes.
// ✅ Batched — two layout calculations total (at most)
function layoutCards(cards) {
// Phase 1: Read everything
const measurements = cards.map(card => ({
width: card.offsetWidth,
height: card.offsetHeight,
}));
// Phase 2: Write everything
cards.forEach((card, i) => {
card.style.padding = '10px';
card.style.margin = '5px';
});
}
The fastdom Pattern
When reads and writes come from different parts of the application (different components, different event handlers), manual batching is hard. The fastdom pattern provides a coordination layer:
class DOMScheduler {
private reads: Array<() => void> = [];
private writes: Array<() => void> = [];
private scheduled = false;
measure(fn: () => void): void {
this.reads.push(fn);
this.schedule();
}
mutate(fn: () => void): void {
this.writes.push(fn);
this.schedule();
}
private schedule(): void {
if (this.scheduled) return;
this.scheduled = true;
requestAnimationFrame(() => {
// Execute ALL reads first
const readBatch = this.reads.splice(0);
readBatch.forEach(fn => fn());
// Then ALL writes
const writeBatch = this.writes.splice(0);
writeBatch.forEach(fn => fn());
this.scheduled = false;
});
}
}
const dom = new DOMScheduler();
Usage Across Components
// Component A (navbar)
dom.measure(() => {
const navHeight = navbar.offsetHeight;
dom.mutate(() => {
content.style.paddingTop = navHeight + 'px';
});
});
// Component B (sidebar)
dom.measure(() => {
const sidebarWidth = sidebar.offsetWidth;
dom.mutate(() => {
main.style.marginLeft = sidebarWidth + 'px';
});
});
// Component C (footer)
dom.measure(() => {
const footerTop = footer.getBoundingClientRect().top;
dom.mutate(() => {
footer.style.position = footerTop < window.innerHeight ? 'fixed' : 'relative';
});
});
All three components' reads execute together (one layout calculation), then all writes execute together (one dirty-layout mark). The rAF callback ensures this happens once per frame, regardless of how many components schedule reads and writes.
Nested Read-in-Write: The Tricky Case
Here is where things get messy. Sometimes a write depends on a read that depends on an earlier write:
// Need: set element A's width, then read A's height (which depends on the new width),
// then set element B's height based on A's new height.
// ❌ Naive — forces layout in the middle
function syncElements(a, b) {
a.style.width = '300px'; // WRITE
const newHeight = a.offsetHeight; // READ → forced layout
b.style.height = newHeight + 'px'; // WRITE
}
The fastdom pattern handles this by allowing writes to schedule new reads:
// ✅ Two-frame approach — frame 1: write, frame 2: read + write
dom.mutate(() => {
a.style.width = '300px';
});
// Next frame: read the result and write
dom.measure(() => {
const newHeight = a.offsetHeight;
dom.mutate(() => {
b.style.height = newHeight + 'px';
});
});
This adds one frame of latency (the write and dependent read happen in separate frames), but avoids forced layout. For most UI, a one-frame delay (16.67ms) is imperceptible.
When one-frame latency matters
In drag operations and real-time interactions, a one-frame delay between read and write creates visible lag. For these cases, a single forced layout is acceptable — the cost of one layout calculation is far less than the visual artifact of lagging one frame behind the pointer. The rule is: avoid forced layouts in loops and high-frequency operations. A single forced layout in a drag handler (once per frame) is fine.
Virtual DOM: Batching by Architecture
If you are thinking "this seems like a lot of manual work," you are right. And this is exactly the problem the virtual DOM solves. It abstracts away the read/write batching problem entirely:
// React component — no manual DOM reads or writes
function Card({ width }: { width: number }) {
return (
<div style={{ width, padding: 10, margin: 5 }}>
<p>Content</p>
</div>
);
}
React collects all component render outputs (virtual DOM), diffs them, and applies the minimum necessary DOM writes in a single batch. Since React controls when DOM writes happen, it never interleaves reads and writes.
Component renders (virtual DOM):
Card 1: { width: 300, padding: 10 }
Card 2: { width: 250, padding: 10 }
Card 3: { width: 350, padding: 10 }
React's commit phase (all writes, no reads):
card1.style.width = '300px'
card2.style.width = '250px'
card3.style.width = '350px'
React's Automatic Batching
React 18+ batches state updates automatically, including those in promises, timeouts, and event handlers:
function handleClick() {
// React 18+: All three updates produce a single re-render
setCount(c => c + 1);
setFlag(f => !f);
setName('updated');
// → One render, one commit, one batch of DOM writes
}
// Even in async contexts:
async function handleSubmit() {
const data = await fetchData();
// React 18+: Still batched into a single render
setData(data);
setLoading(false);
setError(null);
}
Pre-React 18, state updates in promises and timeouts were NOT batched — each setState caused a separate render and DOM write cycle. React 18's automatic batching eliminates an entire class of accidental layout thrashing.
In rare cases where you need a state update to flush immediately (e.g., reading from the DOM right after a state change), use flushSync:
import { flushSync } from 'react-dom';
flushSync(() => setHeight(newHeight));
// DOM is updated here — safe to read
const rect = element.getBoundingClientRect();This is intentionally verbose to discourage casual use. You almost never need it.
Measuring Improvement with the Performance API
PerformanceObserver for Long Tasks
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.warn(`Long task: ${entry.duration.toFixed(1)}ms`, entry);
}
});
observer.observe({ type: 'longtask', buffered: true });
Manual Timing Comparison
function measureInterleaved(elements) {
const start = performance.now();
elements.forEach(el => {
const h = el.offsetHeight; // READ
el.style.padding = '10px'; // WRITE
});
const time = performance.now() - start;
console.log(`Interleaved: ${time.toFixed(2)}ms`);
}
function measureBatched(elements) {
const start = performance.now();
const heights = elements.map(el => el.offsetHeight); // ALL READS
elements.forEach(el => el.style.padding = '10px'); // ALL WRITES
const time = performance.now() - start;
console.log(`Batched: ${time.toFixed(2)}ms`);
}
// With 500 elements:
// Interleaved: ~120ms
// Batched: ~2ms (60x faster)
Performance Panel Analysis
Record both approaches in the Performance panel. The interleaved version shows:
- Dozens of purple "Layout" bars clustered together in one frame
- "Forced reflow" annotations linking each layout to the triggering code
- Total layout time dominating the frame
The batched version shows:
- One or zero "Layout" bars (the browser's natural layout pass before paint)
- No forced reflow annotations
- Total layout time minimal
Beyond DOM: The Pattern in Other Contexts
Turns out, this pattern is not unique to the DOM. The read/write batching principle appears everywhere in systems programming:
- Database transactions: Batch reads, then batch writes. Interleaving causes lock contention.
- GPU rendering: Upload all textures (writes), then draw calls (reads from GPU). Interleaving causes pipeline stalls.
- File I/O: Read all needed files, process in memory, then write results. Interleaving random reads and writes thrashes disk caches.
- React state: Collect all state changes in an event handler (writes to state), then reconcile once (read the diff). Interleaving with flushSync is the anti-pattern.
The principle is universal: systems that cache intermediate state perform poorly when the cache is repeatedly invalidated and rebuilt.
- 1The fundamental rule: batch all DOM reads first, then all DOM writes. Never interleave.
- 2The fastdom pattern uses requestAnimationFrame to coordinate reads and writes from decoupled components into batched phases.
- 3The virtual DOM (React, Vue, etc.) naturally avoids layout thrashing by separating diff computation (pure) from DOM writes (batched commit).
- 4React 18+ automatically batches all state updates, including in promises and timeouts. Pre-React 18 did not batch async updates.
- 5A single forced layout in a focused interaction (drag handler, once per frame) is acceptable. Forced layouts in loops are never acceptable.
- 6Use the Performance panel's 'Forced reflow' annotations to find interleaved read-write patterns.
- 7Measure with performance.now() before and after. Batching typically shows 10-100x improvement on interleaved patterns.
Q: Your teammate's code reads offsetHeight and sets style.height for 200 elements in a loop. The frame takes 400ms. How do you fix it?
A strong answer refactors to batch reads and writes: first pass reads all offsetHeight values into an array, second pass applies all style.height changes. Mentions the fastdom pattern for cases where reads/writes come from separate modules. Explains why the interleaving is expensive: each style write invalidates layout, each subsequent offsetHeight forces a full synchronous layout recalculation — O(n) layout passes instead of O(1). Bonus: mention that in React, this pattern is avoided architecturally (virtual DOM separates computation from commit), and that CSS containment reduces the cost of any remaining forced layouts by limiting their scope.