Performance Optimization in DOM Manipulation

Comprehensive analysis of DocumentFragment, batch updates, and implementing virtual DOM concepts from scratch

Photo by Mufid Majnun on Unsplash

DOM work is expensive. Each time JavaScript touches the DOM, the browser may need to recalculate styles, compute layout, and repaint pixels. Do this naively in tight loops and you’ll get jank, frame drops, and battery drain.

In this article, you’ll learn:

  • The real cost model behind DOM mutations.
  • How to use DocumentFragment and templates to reduce reflows.
  • Practical batching techniques: read/write separation, requestAnimationFrame, microtasks, and idle callbacks.
  • A minimal “from scratch” virtual DOM (VDOM) with keyed diffing so you can see how libraries reduce DOM churn.
  • Concrete examples with expected outcomes and why they’re faster.

The DOM Cost Model (Why naïve code is slow)

Crossing the JS ↔ DOM boundary is slow because:

  • Style recalculation: Changes can invalidate computed styles.
  • Layout (reflow): Reading layout (offsetHeight, getBoundingClientRect) can force the browser to flush pending changes.
  • Paint/composite: Visual changes may trigger repaint and compositing.

Layout Thrashing

A classic anti-pattern is read → write → read → write inside a loop. Reads force layout; writes invalidate it; then the next read forces layout again. Result: dozens or hundreds of forced synchronous layouts per frame.

Fix: batch all reads first, then all writes.

DocumentFragment & Template Batching

A DocumentFragment is a lightweight container for nodes. Appending children to a fragment doesn’t touch the live document; appending the fragment to the DOM moves all its children in one operation.

Example A — Build a 10,000-row list

Naïve (slow)

const ul = document.querySelector('#list');
for (let i = 0; i < 10_000; i++) {
const li = document.createElement('li');
li.textContent = `Row ${i}`;
ul.appendChild(li); // 10,000 DOM insertions
}

Optimized with DocumentFragment

const ul = document.querySelector('#list');
const frag = document.createDocumentFragment();

for (let i = 0; i < 10_000; i++) {
const li = document.createElement('li');
li.textContent = `Row ${i}`;
frag.appendChild(li); // zero cost to live DOM
}
ul.appendChild(frag); // 1 DOM insertion

Even Faster for Static Content: innerHTML

const ul = document.querySelector('#list');
ul.innerHTML = Array.from({ length: 10_000 }, (_, i) => `<li>Row ${i}</li>`).join('');

Result explained:

  • DocumentFragment turns 10,000 insertions into 1, drastically reducing style/layout work.
  • innerHTML can be fastest for purely static markup because the browser’s HTML parser and node creation are highly optimized in native code. Use it when you don’t need to preserve existing nodes or event listeners.

Example B — Using <template> for batch cloning

<template id="row-tpl">
<li class="row"><span class="label"></span></li>
</template>
const ul = document.querySelector('#list');
const tpl = document.getElementById('row-tpl');
const frag = document.createDocumentFragment();

for (let i = 0; i < 5000; i++) {
const clone = tpl.content.firstElementChild.cloneNode(true);
clone.querySelector('.label').textContent = `Row ${i}`;
frag.appendChild(clone);
}
ul.appendChild(frag);

<template> avoids parsing strings repeatedly and gives you structured nodes you can mutate before a single append to the DOM.

Batch Updates: Read/Write Separation & Frame Scheduling

Read/Write Separation (prevent layout thrashing)

Bad

const items = document.querySelectorAll('.card');
for (const el of items) {
const h = el.offsetHeight; // read (forces layout)
el.style.height = (h + 10) + 'px'; // write (invalidates layout)
}

Good

const items = document.querySelectorAll('.card');

// batch reads
const heights = [];
for (const el of items) {
heights.push(el.offsetHeight);
}
// batch writes
for (let i = 0; i < items.length; i++) {
items[i].style.height = (heights[i] + 10) + 'px';
}

The “bad” version can cause N forced layouts. The “good” version usually causes 1 layout, then batched writes.

requestAnimationFrame (rAF) for visual updates

Use rAF to run DOM writes right before paint, coalescing multiple changes into the next frame.

let pending = false;
const queue = [];

function scheduleWrite(fn) {
queue.push(fn);
if (!pending) {
pending = true;
requestAnimationFrame(() => {
for (const task of queue) task();
queue.length = 0;
pending = false;
});
}
}
// elsewhere:
scheduleWrite(() => progressBar.style.width = '60%');
scheduleWrite(() => spinner.classList.add('hidden'));
// Both run together before the next paint.

Multiple UI writes collected in the same frame reduce paint/layout churn and avoid mid-frame jank.

Microtasks for logical batching (queueMicrotask / resolved Promise)

Use microtasks to batch state work (not heavy DOM writes) so you collapse multiple synchronous calls into a single flush.

let microtaskScheduled = false;
const pendingStateUpdates = [];

export function setState(partial) {
pendingStateUpdates.push(partial);
if (!microtaskScheduled) {
microtaskScheduled = true;
queueMicrotask(() => {
const merged = Object.assign({}, ...pendingStateUpdates);
pendingStateUpdates.length = 0;
microtaskScheduled = false;
applyState(merged); // do minimal DOM here, or defer to rAF
});
}
}

If setState is called 100 times synchronously, you’ll process them once in the same microtask tick.

requestIdleCallback for non-urgent DOM

For low-priority work (e.g., pre-rendering offscreen sections), schedule it when the browser is idle:

requestIdleCallback((deadline) => {
while (deadline.timeRemaining() > 0 && tasks.length) {
const task = tasks.shift();
task(); // e.g., build nodes in a DocumentFragment
}
});

Idle time is used without affecting responsiveness; users get smooth interactions.

A Minimal Virtual DOM From Scratch

A Virtual DOM is an in-memory tree (plain JS objects). You render once to real DOM, then diff a new VDOM against the previous one and apply the minimal DOM mutations (“patching”).

Core types and helpers

// VNode shape: { type, props, children, key?, el? }

function h(type, props, ...children) {
props = props || {};
const flatChildren = children.flat();
return { type, props, children: flatChildren, key: props.key };
}

function setProp(el, name, value) {
if (name === 'className') el.setAttribute('class', value ?? '');
else if (name.startsWith('on') && typeof value === 'function') {
const evt = name.slice(2).toLowerCase();
el.addEventListener(evt, value);
} else if (value == null || value === false) {
el.removeAttribute(name);
} else {
el.setAttribute(name, value);
}
}

function createElement(vnode) {
if (typeof vnode === 'string' || typeof vnode === 'number') {
const el = document.createTextNode(String(vnode));
vnode.el = el;
return el;
}
const el = document.createElement(vnode.type);
for (const [k, v] of Object.entries(vnode.props || {})) setProp(el, k, v);
for (const child of vnode.children) el.appendChild(createElement(child));
vnode.el = el;
return el;
}

Diff & Patch (props + keyed children)

This is a tiny (and intentionally simplified) keyed diff. Keys let us move DOM nodes instead of destroying/remounting them.

function patch(parent, oldVNode, newVNode) {
if (!oldVNode) {
// mount
const el = createElement(newVNode);
parent.appendChild(el);
return newVNode;
}

if (!newVNode) {
// unmount
parent.removeChild(oldVNode.el);
return null;
}
// text nodes
if (typeof oldVNode === 'string' || typeof oldVNode === 'number' ||
typeof newVNode === 'string' || typeof newVNode === 'number') {
if (String(oldVNode) !== String(newVNode)) {
const el = createElement(newVNode);
parent.replaceChild(el, oldVNode.el);
return newVNode;
} else {
newVNode.el = oldVNode.el;
return newVNode;
}
}
// different element types
if (oldVNode.type !== newVNode.type) {
const el = createElement(newVNode);
parent.replaceChild(el, oldVNode.el);
return newVNode;
}
// same type: patch in place
const el = (newVNode.el = oldVNode.el);
// patch props
const oldProps = oldVNode.props || {};
const newProps = newVNode.props || {};
// remove stale
for (const k of Object.keys(oldProps)) {
if (!(k in newProps)) setProp(el, k, null);
}
// add/update
for (const [k, v] of Object.entries(newProps)) {
if (oldProps[k] !== v) setProp(el, k, v);
}
// patch children (keyed)
patchChildrenKeyed(el, oldVNode.children, newVNode.children);
return newVNode;
}

function patchChildrenKeyed(parent, oldCh, newCh) {
const oldKeyToIdx = new Map();
const oldEls = [];
for (let i = 0; i < oldCh.length; i++) {
const c = oldCh[i];
oldKeyToIdx.set(c.key ?? i, i);
oldEls[i] = c.el;
}
const usedOld = new Set();
let lastPlacedIndex = 0;
for (let i = 0; i < newCh.length; i++) {
const newVNode = newCh[i];
const key = newVNode.key ?? i;
const oldIdx = oldKeyToIdx.get(key);
if (oldIdx == null) {
// new node
const el = createElement(newVNode);
const refNode = parent.childNodes[i] || null;
parent.insertBefore(el, refNode);
} else {
// patch in place, may need to move
const oldVNode = oldCh[oldIdx];
patch(parent, oldVNode, newVNode);
usedOld.add(oldIdx);
if (oldIdx < lastPlacedIndex) {
// move forward to current position
parent.insertBefore(newVNode.el, parent.childNodes[i] || null);
} else {
lastPlacedIndex = oldIdx;
}
}
}
// remove nodes not used
for (let i = 0; i < oldCh.length; i++) {
if (!usedOld.has(i)) parent.removeChild(oldCh[i].el);
}
}

Using the mini VDOM

// Initial render
let state = { items: [1,2,3,4], selected: 2 };

function view(s) {
return h('ul', { className: 'list' },
s.items.map(n =>
h('li',
{ key: n, className: n === s.selected ? 'row selected' : 'row' },
`Row ${n}`
)
)
);
}
let oldTree = view(state);
const root = document.getElementById('root');
patch(root, null, oldTree); // mount
// Update: re-order and change selection
state = { items: [4,1,3,2,5], selected: 5 };
const newTree = view(state);
oldTree = patch(root, oldTree, newTree);
  • Keys ensure existing <li> nodes are moved instead of destroyed, preserving focus and any local state you might have attached.
  • The diff computes minimal edits: props updates (class change on selected item), insert one new item (5), and move items to match the new order.
  • Compared to innerHTML replacement, this approach avoids re-creating all nodes, reducing GC pressure and layout work.

Note: This is intentionally compact to illustrate the idea. Production reconcilers optimize far more cases (text merging, portals, controlled form elements, refs, event normalization, etc.).

Practical Patterns & Gotchas

  • Prefer fewer, larger mutations over many small ones. Build offline (DocumentFragment/<template>) and append once.
  • Stable keys for lists. Keys must be unique and persistent (use IDs, not indexes) to get correct moves and minimal DOM churn.
  • Event delegation: Attach one listener high in the tree (ul) and handle events from children (li). This reduces listener churn during list updates.
  • Avoid forced reflow: If you must read layout after writes, schedule the read in the next frame using rAF.
  • innerHTML vs. create nodes: For static or fully replaced sections, innerHTML is often fastest. For partial updates or preserving state, VDOM/diffing wins.
  • CSS for visuals, JS for structure: Often you can toggle a class instead of mutating many inline styles.
  • Chunk long work: For 100k nodes, build in chunks via rAF (one chunk per frame) to keep the UI responsive.

DOM performance isn’t magic; it’s about minimizing live DOM mutations, batching work smartly, and avoiding layout thrash. Use DocumentFragment (and <template>) to build offline and append once. Separate reads from writes; schedule visual work with requestAnimationFrame, coalesce logic with microtasks, and push non-urgent tasks to idle time.

When updates get complex — lists reordering, conditional nodes, preserving focus/state — a virtual DOM with keyed diffing gives you deterministic, minimal patches. Even a tiny homegrown reconciler demonstrates why modern frameworks feel fast: they do less DOM.

Adopt these patterns and you’ll ship UIs that stay buttery-smooth under load, scale to large lists, and respect your users’ battery and time.


Performance Optimization in DOM Manipulation was originally published in CarlosRojasDev on Medium, where people are continuing the conversation by highlighting and responding to this story.

Scroll to Top