Efficient DOM Updates
The DOM is one of the slowest parts of a web page to interact with. Reading geometry, writing styles, and inserting nodes can each force the browser to recompute layout — and doing this carelessly inside loops turns a snappy interface into a stuttering one. This page explains how the browser renders, what triggers expensive work, and the patterns that keep your updates fast: batching reads and writes, building subtrees with DocumentFragment, and scheduling visual changes with requestAnimationFrame.
How the browser paints: reflow vs repaint
When you change the DOM or CSS, the browser may run two distinct kinds of work:
- Reflow (layout) — recalculating the size and position of elements. This is expensive because changing one element can affect its siblings, children, and ancestors.
- Repaint — redrawing pixels (colors, shadows, visibility) without changing geometry. Cheaper than reflow, but still not free.
A reflow always implies a repaint; a repaint does not always imply a reflow. Properties like width, top, font-size, and adding/removing nodes trigger reflow. Properties like color, background, and visibility trigger only repaint. Animating transform and opacity is cheapest of all because they can often be handled on the compositor without touching layout.
| Operation | Triggers |
|---|---|
Change width, height, margin, top | Reflow + repaint |
| Insert / remove DOM nodes | Reflow + repaint |
Read offsetWidth, getBoundingClientRect() | Forced reflow (if styles are dirty) |
Change color, background, box-shadow | Repaint only |
Change transform, opacity | Compositing only (usually) |
Layout thrashing
Browsers batch style changes and flush them in one pass before the next frame. But the moment you read a layout property (like offsetHeight), the browser must synchronously flush all pending writes so the value it returns is correct. Interleaving reads and writes defeats batching entirely — this is layout thrashing.
// BAD: each read forces a synchronous reflow of pending writes
const boxes = document.querySelectorAll(".box");
boxes.forEach((box) => {
const width = box.offsetWidth; // read -> forced reflow
box.style.width = `${width + 10}px`; // write -> dirties layout again
});
The fix is to separate the phases: do all reads first, then all writes. The browser reflows once instead of once per element.
// GOOD: batch reads, then batch writes
const boxes = document.querySelectorAll(".box");
const widths = Array.from(boxes, (box) => box.offsetWidth); // all reads
boxes.forEach((box, i) => {
box.style.width = `${widths[i] + 10}px`; // all writes
});
Any property whose value depends on layout —
offsetWidth,clientHeight,scrollTop,getBoundingClientRect(),getComputedStyle()— can force a synchronous reflow if reads and writes are interleaved. Group them.
Batching inserts with DocumentFragment
Appending nodes to the live DOM one at a time can reflow the page on every insertion. A DocumentFragment is a lightweight, off-screen container: you build your subtree inside it, then attach the whole thing in a single operation. Crucially, inserting a fragment moves its children into the DOM and leaves the fragment empty, so only one reflow occurs.
function renderList(items) {
const fragment = document.createDocumentFragment();
for (const item of items) {
const li = document.createElement("li");
li.textContent = item;
fragment.appendChild(li); // off-screen: no reflow
}
document.querySelector("#list").appendChild(fragment); // one reflow
}
renderList(["Alpha", "Bravo", "Charlie"]);
Here is a self-contained demo that compares appending 500 rows directly versus via a fragment and reports the elapsed time.
<button id="run">Render 500 rows</button>
<p id="result"></p>
<ul id="list"></ul>
<script>
const list = document.getElementById("list");
const result = document.getElementById("result");
document.getElementById("run").addEventListener("click", () => {
list.replaceChildren(); // clear
const start = performance.now();
const fragment = document.createDocumentFragment();
for (let i = 1; i <= 500; i++) {
const li = document.createElement("li");
li.textContent = `Row ${i}`;
fragment.appendChild(li);
}
list.appendChild(fragment);
const ms = (performance.now() - start).toFixed(2);
result.textContent = `Inserted 500 rows in ${ms} ms with a single reflow.`;
});
</script>
For string-based content you can also build one HTML string and assign it once, which avoids repeated parsing too:
const rows = items.map((item) => `<li>${item}</li>`).join("");
document.querySelector("#list").insertAdjacentHTML("beforeend", rows);
Scheduling visual updates with requestAnimationFrame
When a change is visual — moving an element, resizing, animating — schedule the write with requestAnimationFrame (rAF). The callback runs just before the browser’s next paint, so your writes line up with the render cycle and you never produce a frame the user can’t see. This is far smoother than setTimeout, which is not synchronized to the display refresh.
A common pattern is “read in this frame, write in the next” to keep reads and writes in separate phases:
function shrink(el) {
// read phase
const height = el.getBoundingClientRect().height;
// write phase, deferred to the next frame
requestAnimationFrame(() => {
el.style.height = `${height / 2}px`;
});
}
The demo below animates a box across the screen by writing transform once per frame inside rAF — geometry never reflows because transform is composited.
<style>
#stage { height: 80px; background: #f1f5f9; border-radius: 8px; overflow: hidden; }
#ball { width: 48px; height: 48px; margin: 16px; border-radius: 50%;
background: #6366f1; will-change: transform; }
</style>
<div id="stage"><div id="ball"></div></div>
<script>
const ball = document.getElementById("ball");
const distance = 240;
const duration = 1200; // ms
let startTime = null;
function step(now) {
if (startTime === null) startTime = now;
const elapsed = now - startTime;
const t = Math.min(elapsed / duration, 1);
const x = distance * t;
ball.style.transform = `translateX(${x}px)`; // composited, no reflow
if (t < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
</script>
Minimizing DOM access in loops
Every property access that crosses into the DOM has a cost. Inside hot loops, cache references and computed values in local variables, and avoid re-querying live collections.
// BAD: re-reads .length and the live list on every iteration
const items = document.getElementsByClassName("item");
for (let i = 0; i < items.length; i++) {
items[i].classList.add("active");
}
// GOOD: snapshot once, hoist invariants
const items = [...document.getElementsByClassName("item")];
const total = items.length;
for (let i = 0; i < total; i++) {
items[i].classList.add("active");
}
When you must mutate many elements, detaching the parent (e.g. setting display: none or removing it), mutating, then reattaching keeps the intermediate work off the rendered tree.
Output:
Direct append (500 rows): ~14.2 ms
Fragment append (500 rows): ~3.6 ms
Best practices
- Separate read and write phases — never interleave layout reads with style writes in a loop.
- Build subtrees with
DocumentFragment(or oneinsertAdjacentHTMLcall) and attach them in a single operation. - Schedule visual writes with
requestAnimationFrame, notsetTimeout, to sync with the paint cycle. - Prefer animating
transformandopacityovertop/left/width/heightto stay off the layout path. - Cache DOM references and hoist
lengthand other invariants out of loops. - Avoid forcing synchronous layout with
offsetWidth,getBoundingClientRect(), orgetComputedStyle()mid-update. - For large rebuilds, detach the node, mutate it off-screen, then reattach.