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| Operation | Cost |
|---|---|
| Reading a layout property | Forces layout if the tree is dirty |
| Writing a geometry property | Marks the tree dirty |
Reading classList or dataset | Cheap - no layout |
| Changing a class that affects layout | Dirty, plus style recalculation |
Reading textContent | Cheap |
Reading innerText | Forces 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);
}requestAnimationFrameruns 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
requestIdleCallbackor 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
}| Leak | Symptom | Fix |
|---|---|---|
| Listener on a long-lived target | Memory grows as components mount | AbortController signal |
| Cached detached nodes | Heap snapshot shows detached trees | Delete the cache entry on removal |
| Timer holding a closure | Work continues after unmount | Clear the interval on teardown |
| Observer never disconnected | Callback fires for removed elements | disconnect() |
| Growing array of nodes | Steady increase under interaction | Store 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.
Related
Templates, cloning and efficient rendering Geometry, scrolling and visibility
Last refreshed 2026-09-18.