Performance and memory in DOM code

Stop layout thrashing by separating reads from writes, batch with animation frames, and find the listener and node leaks that make a page degrade over time.

Reads, writes and layout thrashing

// thrashing: every read after a write forces a synchronous layout
for (const el of items) {
  el.style.width = el.offsetWidth / 2 + 'px';   // write, then read, then write
}

// batched: all reads, then all writes - one layout in total
const widths = items.map((el) => el.offsetWidth);
items.forEach((el, i) => { el.style.width = widths[i] / 2 + 'px'; });

// the properties that force layout when you read them
// offsetWidth, offsetHeight, offsetTop, offsetLeft
// clientWidth, clientHeight, scrollWidth, scrollHeight, scrollTop
// getBoundingClientRect(), getComputedStyle(), innerText
OperationCost
Reading a layout propertyForces layout if the tree is dirty
Writing a geometry propertyMarks the tree dirty
Reading classList or datasetCheap - no layout
Changing a class that affects layoutDirty, plus style recalculation
Reading textContentCheap
Reading innerTextForces layout
💡
The rule is one sentence: read everything you need first, then write. A single forced layout costs a fraction of a millisecond; a thousand of them in a loop is the difference between an instant interaction and a visibly janky one.

Animation frames and scheduling

// do visual work in the frame that will paint it
let scheduled = false;

function scheduleRender() {
  if (scheduled) return;        // coalesce multiple calls into one frame
  scheduled = true;
  requestAnimationFrame(() => {
    scheduled = false;
    render();
  });
}

window.addEventListener('scroll', scheduleRender, { passive: true });
// let the browser finish a long task before inserting 500 rows
function insertInChunks(parent, nodes, chunk = 50) {
  let i = 0;
  function step() {
    const fragment = document.createDocumentFragment();
    for (let n = 0; n < chunk && i < nodes.length; n++, i++) fragment.append(nodes[i]);
    parent.append(fragment);
    if (i < nodes.length) requestAnimationFrame(step);
  }
  requestAnimationFrame(step);
}
  • requestAnimationFrame runs before the next paint, which is exactly when a DOM write should happen.
  • Coalescing with a boolean flag turns forty scroll events per second into at most one render per frame.
  • For anything expensive that is not visual, prefer requestIdleCallback or a worker.
  • Use CSS transitions and transforms where possible - a transform animation runs off the main thread, a width animation does not.

Listener and node leaks

// leaked: the listener holds the element, and window holds the listener
function mountWidget() {
  const widget = document.createElement('div');
  window.addEventListener('resize', () => widget.reposition());
  document.body.append(widget);
}

// fixed: scope the listener to the component's lifetime
function mountWidget() {
  const widget = document.createElement('div');
  const controller = new AbortController();

  window.addEventListener('resize', () => widget.reposition(), {
    signal: controller.signal,
  });

  widget.addEventListener('dispose', () => controller.abort());
  document.body.append(widget);
}
// a detached node is still retained by any live reference
const cache = new Map();

function show(id) {
  if (!cache.has(id)) {
    const el = buildPanel(id);
    document.body.append(el);
    cache.set(id, el);
  }
}

function hide(id) {
  cache.get(id)?.remove();      // removed from the DOM...
  cache.delete(id);             // ...and from the cache, or it lives forever
}
LeakSymptomFix
Listener on a long-lived targetMemory grows as components mountAbortController signal
Cached detached nodesHeap snapshot shows detached treesDelete the cache entry on removal
Timer holding a closureWork continues after unmountClear the interval on teardown
Observer never disconnectedCallback fires for removed elementsdisconnect()
Growing array of nodesSteady increase under interactionStore ids, not elements

The DevTools memory panel is the way to confirm this rather than guess: take a heap snapshot, interact for a while, take another, and look for detached nodes whose count tracks the number of components you mounted and removed.

FAQ

Is innerHTML always slow?
No. A single innerHTML assignment is often faster than building the same tree node by node, because the parser is highly optimised. It becomes slow when used in a loop, because each assignment reparses the entire subtree - and it is unsafe whenever the string contains interpolated user data.
How do I find out what is actually slow?
Record a performance profile while reproducing the interaction, then look for long tasks and the call frames inside them. Layout and style recalculation show up as separate entries, so a thrashing loop is visible as many small layout entries between script frames.

Templates, cloning and efficient rendering Geometry, scrolling and visibility

Last refreshed 2026-09-18.