Batching DOM updates
Batching DOM updates is the practice of doing all geometry reads together, then all writes together, and fitting the whole thing into a single frame. That way the browser recalculates layout once instead of dozens of times, and the interface stops stuttering on lists, scrolling and animations.
Theory
TL;DR
- All reads first (measure), then all writes (mutate), no more than once per frame.
- Reading
getBoundingClientRect(),offsetHeight,scrollToporgetComputedStyle()triggers a synchronous layout; interleaving reads and writes produces layout thrashing. requestAnimationFramecoalesces writes into one frame (about 16.6 ms at 60 FPS).- Build new nodes in a
DocumentFragmentor from a<template>and insert them in a single operation. - One
classList.add()is cheaper than a dozenstyle.*assignments. - Animate only
transformandopacity, bound the recalculation withcontainandcontent-visibility, and virtualize long lists.
Quick example
// Bad: write, read, write, read, every read forces a layout
el.style.width = '400px';
el.offsetHeight; // forced reflow
el.style.height = '200px';
el.getBoundingClientRect(); // forced reflow
// Good: all reads first, then all writes in one frame
const h = el.offsetHeight;
const r = el.getBoundingClientRect();
requestAnimationFrame(() => {
el.style.width = '400px';
el.style.height = '200px';
});Measure, then mutate
Geometry reads (getBoundingClientRect, offsetHeight, scrollTop, getComputedStyle) force the browser to recalculate layout synchronously, because it has to hand you an up to date value. Interleave reads and writes and you get layout thrashing: every loop iteration costs a full recalculation.
The right order:
// Collect all the geometry reads...
const rects = items.map((el) => el.getBoundingClientRect());
// ...and then apply the writes (styles, classes) in one batch
requestAnimationFrame(() => {
items.forEach((el, i) => {
el.style.transform = `translateY(${rects[i].top}px)`; // write only
});
});Batching through requestAnimationFrame collapses all the changes into one frame:
let pending = [];
function batchStyle(fn) {
pending.push(fn);
if (pending.length === 1) {
requestAnimationFrame(() => {
const jobs = pending;
pending = [];
jobs.forEach((job) => job());
});
}
}
// Usage
batchStyle(() => el.classList.add('active'));
batchStyle(() => (el.style.opacity = '1'));Build nodes outside the document
A DocumentFragment lives outside the document tree, so filling it costs nothing and the insertion causes a single reflow:
const frag = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li');
li.textContent = `Row ${i}`;
frag.appendChild(li);
}
list.appendChild(frag); // one reflow instead of 1000The same with a <template> when the row markup is described in HTML:
const tpl = document.getElementById('row-tpl');
const frag = document.createDocumentFragment();
data.forEach((d) => {
const node = tpl.content.cloneNode(true);
node.querySelector('.title').textContent = d.title;
frag.appendChild(node);
});
list.appendChild(frag);A third option, innerHTML, when it is safe: build a string and insert it once. Important: content coming from users must never be inserted this way, that is an XSS hole.
Fewer writes and a smaller DOM
A class instead of a dozen inline styles. One classList.add('expanded') is cheaper than 10 style.* assignments, because it is a single change for the style engine.
el.classList.add('expanded'); // all the properties are already described in CSSHide, change, show. Sometimes it pays to take a block out of the flow, apply a batch of edits and put it back.
container.style.display = 'none';
/* many DOM operations */
container.style.display = '';Use it carefully: it discards intermediate measurements, the scroll position and the focus inside the block.
Animate only transform and opacity. They do not cause a reflow, usually only a repaint and a composite, which is cheap.
.card {
will-change: transform, opacity;
}CSS containment and skipped rendering. content-visibility: auto lets the browser skip layout and paint for content that is off screen, and contain: layout paint limits the recalculation to the element's own box.
.section {
content-visibility: auto;
contain-intrinsic-size: 1000px; /* so the layout does not jump */
}Debounce and throttle events. Scroll, resize and typing can trigger hundreds of updates per second, so collect them into batches.
const onScroll = throttle(() => {
requestAnimationFrame(updateStickyHeader);
}, 100);Virtualize large lists. Render only the visible items (react-window, a virtual scroller or your own implementation). This cuts the DOM size radically, and with it the layout and paint work.
A ready made measure / mutate scheduler
const reads = [];
const writes = [];
export function read(fn) {
reads.push(fn);
schedule();
}
export function write(fn) {
writes.push(fn);
schedule();
}
let scheduled = false;
function schedule() {
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
// 1) all reads
let r;
while ((r = reads.shift())) r();
// 2) all writes
let w;
while ((w = writes.shift())) w();
scheduled = false;
});
}Usage:
read(() => {
boxRect = box.getBoundingClientRect();
});
write(() => {
box.style.transform = `translateY(${boxRect.top}px)`;
});Common mistakes
- Reading geometry inside a loop that immediately writes styles. This is layout thrashing, and it is the most expensive mistake on this list.
- Appending nodes one by one to
document.bodyinstead of collecting them in a fragment. - Assuming
requestAnimationFramespeeds up code by itself. It only moves the work into the right phase of the frame; if the callback still interleaves reads and writes, the thrashing stays. - Putting
will-changeon everything: every compositing layer costs memory, and redundant layers slow the page down. - Using
display: noneas a universal trick, forgetting that it resets scroll, focus and media playback state inside. - Inserting a user supplied string through
innerHTML: that is a direct XSS vulnerability, not an optimization. - Optimizing blind. First record in the Performance tab, look for long Layout and Recalculate Style entries, and only then change the code.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.