Skip to main content

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, scrollTop or getComputedStyle() triggers a synchronous layout; interleaving reads and writes produces layout thrashing.
  • requestAnimationFrame coalesces writes into one frame (about 16.6 ms at 60 FPS).
  • Build new nodes in a DocumentFragment or from a <template> and insert them in a single operation.
  • One classList.add() is cheaper than a dozen style.* assignments.
  • Animate only transform and opacity, bound the recalculation with contain and content-visibility, and virtualize long lists.

Quick example

javascript
// 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:

javascript
// 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:

javascript
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:

javascript
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 1000

The same with a <template> when the row markup is described in HTML:

javascript
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.

javascript
el.classList.add('expanded'); // all the properties are already described in CSS

Hide, change, show. Sometimes it pays to take a block out of the flow, apply a batch of edits and put it back.

javascript
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.

css
.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.

css
.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.

javascript
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

javascript
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:

javascript
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.body instead of collecting them in a fragment.
  • Assuming requestAnimationFrame speeds 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-change on everything: every compositing layer costs memory, and redundant layers slow the page down.
  • Using display: none as 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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.