DOM & rendering performance
Learn the browser rendering pipeline, prevent layout thrashing, batch DOM reads and writes, and virtualize long lists smoothly.
- 01Read the rendering pipelineExplain how JavaScript, style, layout, paint, and composite fit into a frame budget.
- 02Prevent forced layoutsSpot layout thrashing, prove it with a deterministic model, and batch reads before writes.
- 03Render only useful DOMUse fragments, containment, observers, and fixed-height list virtualization without hiding the trade-offs.
Keep each frame cheap
Rendering performance is not one trick. It is the discipline of making DOM work, CSS work, and pixel work small enough that the browser can respond before the next refresh. This lesson builds on the measurements from Sizes, scrolling & coordinates, the node-building patterns from Creating & changing elements, the state styling choices from Styles & classes, the frame loop from Animation with JavaScript, and the observer APIs from Observers.
DOM and rendering performance means changing, measuring, painting, and moving page content without forcing unnecessary style, layout, paint, or compositing work inside a frame.
At 60Hz the browser has roughly 16.7ms for one frame. On a 120Hz display, the budget is roughly 8.3ms. JavaScript, style calculation, layout, paint, compositing, input handling, and browser overhead share that time. If your code burns the budget, the page janks.
Imagine a painter changing a wall and asking the surveyor to re-measure the whole room after every brush stroke. The measurement is useful, but the timing is wasteful. A better workflow measures first, paints a batch, then checks again when needed.
- In real life: Painter changes one brush stroke
- In JavaScript: JavaScript writes a style
- In real life: Painter re-measures the wall immediately
- In JavaScript: Code reads
offsetHeightorgetBoundingClientRect() - In real life: Blueprint team recalculates dimensions
- In JavaScript: The browser runs layout now
- In real life: Painter repeats for every stroke
- In JavaScript: A read/write loop causes layout thrashing
- In real life: Measure once, paint many strokes
- In JavaScript: Batch reads first, then writes
Where the analogy stops: A real painter can use judgment and work in parallel. The browser follows dependency rules: geometry reads require current layout, and JavaScript runs on the main thread unless you move work elsewhere.
The Measuring performance and Main thread lessons cover profiling and long tasks. Debounce & throttle limits how often handlers run. This lesson focuses on the rendering work each handler wakes up.
JavaScript, style, layout, paint, composite
PIPELINEA frame can pass through five important stages. The browser can skip stages when nothing invalidates them, but this order is the working mental model:
const pipeline = [ "JavaScript", "Style", "Layout", "Paint", "Composite",]; console.log(pipeline.join(" -> "));- JavaScript changes DOM, styles, classes, scroll positions, or data.
- Style finds matching CSS rules and computed values.
- Layout, also called reflow, computes box sizes and positions.
- Paint draws pixels for backgrounds, borders, shadows, text, and images.
- Composite moves and blends layers, often for
transformandopacity.
The expensive part is not “CSS is slow” or “DOM is slow.” The expensive part is waking up more stages than the interaction needs, especially repeatedly inside one JavaScript task.
const propertyCosts = { width: "layout", paddingTop: "layout", boxShadow: "paint", backgroundColor: "paint", transform: "composite", opacity: "composite",}; console.log(propertyCosts.transform);console.log(propertyCosts.width);`width``padding``top` on a positioned element`background-color``box-shadow``color``transform: translateX(...)``opacity``will-change: transform`
Sort each property by the browser work it usually needs. The explanations call out important caveats.
| Stage | Question it answers | Common triggers | Performance move |
|---|---|---|---|
| Style | Which CSS rules and computed values apply? | Class changes, media queries, getComputedStyle reads | Avoid repeating style reads after writes in loops |
| Layout | How big is each box and where does it sit? | width, padding, text changes, offsetHeight reads | Batch layout reads before layout-affecting writes |
| Paint | Which pixels need drawing? | background-color, box-shadow, color | Keep paint areas small; use containment for isolated cards |
| Composite | How are already-painted layers moved and blended? | transform, opacity, promoted layers | Use for motion, but budget layer memory and will-change |
will-change is a budget, not a blanketwill-change: transform can ask the browser to prepare a layer before motion starts. That preparation costs memory. Remove the hint after the transition, and never apply it to hundreds of elements just in case.
Layout thrashing
REAL DEMOA layout read is not bad by itself. You often need offsetHeight, getBoundingClientRect(), scrollTop, or getComputedStyle(). The problem is reading layout immediately after writing layout-affecting styles, over and over. The browser must stop JavaScript and calculate layout synchronously so the read can be correct.
function createInstrumentedLayout(count) { let dirty = false; let forcedLayouts = 0; const boxes = Array.from({ length: count }, (_, index) => { const box = { height: 24 + (index % 5), readHeight() { if (dirty) { forcedLayouts += 1; dirty = false; } return box.height; }, writeHeight(next) { box.height = next; dirty = true; }, }; return box; }); return { boxes, get forcedLayouts() { return forcedLayouts; } };} function runLayoutFlushModel(count, mode) { const model = createInstrumentedLayout(count); let totalHeight = 0; if (mode === "thrash") { for (const box of model.boxes) { box.writeHeight(box.height + 1); totalHeight += box.readHeight(); } } else { const heights = model.boxes.map((box) => box.readHeight()); heights.forEach((height, index) => { model.boxes[index].writeHeight(height + 1); totalHeight += height + 1; }); } return { forcedLayouts: model.forcedLayouts, totalHeight };} console.log(runLayoutFlushModel(5, "thrash").forcedLayouts);console.log(runLayoutFlushModel(5, "batched").forcedLayouts);The fake model is intentionally simple: a write marks layout as dirty; the next layout read flushes it and increments forcedLayouts. That matches the browser rule you need to remember without depending on real timings.
const boxes = [...document.querySelectorAll(".rp-run-box")]; for (const [index, box] of boxes.entries()) { box.style.inlineSize = 120 + index + "px"; box.getBoundingClientRect();} console.log("touched " + boxes.length + " boxes");Run the two modes. Timings are real browser measurements and will vary; the forced-layout count comes from the deterministic model tested with the lesson.
The live demo uses real DOM boxes and real elapsed time, so results vary by machine. Tests assert only the deterministic model: thrash mode forces one layout per box; batched mode forces zero synchronous layouts because all reads happen before writes.
Batch reads and writes
STEP THROUGHThe safe order is boring and powerful: read all geometry, compute decisions, then write all changes. When the writes are visual, requestAnimationFrame is the natural place to perform them because it runs before the next paint.
Step through a tiny FastDOM-style scheduler that queues reads and writes for the next frame.
script
function createFrameScheduler(requestFrame) { const writes = []; let scheduled = false; function schedule() { if (scheduled) return; scheduled = true; requestFrame(flush); } function measure(task) { reads.push(task); schedule(); } function mutate(task) { writes.push(task); schedule(); } function flush() { scheduled = false; const readBatch = reads.splice(0); const writeBatch = writes.splice(0); readBatch.forEach((task) => task()); writeBatch.forEach((task) => task()); } return { measure, mutate };} const frames = [];const log = [];const scheduler = createFrameScheduler((flush) => frames.push(flush));scheduler.mutate(() => log.push("write: transform"));scheduler.measure(() => log.push("read: rect"));frames.shift()();console.log(log.join(" -> "));Libraries such as FastDOM popularized two words for this: measure for reads and mutate for writes. The tiny scheduler in the step-through is real code: even if a write is requested first, the flush runs the read queue first.
const list = document.createElement("ul");const fragment = document.createDocumentFragment(); for (const label of ["Read", "Measure", "Mutate"]) { const item = document.createElement("li"); item.textContent = label; fragment.append(item);} list.replaceChildren(fragment);console.log(list.children.length);console.log(fragment.childNodes.length);Build many nodes off-page, then insert once. DocumentFragment moves its children into the destination, so after replaceChildren(fragment) the fragment is empty. This is different from repeatedly appending visible nodes one by one.
const card = document.createElement("article");card.className = "card";card.classList.toggle("is-open", true);card.style.setProperty("--progress", "0.64"); console.log(card.className);console.log(card.style.getPropertyValue("--progress"));Styles & classes explained the split: classes choose designed states, while inline styles and custom properties carry truly dynamic values. That split also helps performance because CSS can own groups of declarations instead of JavaScript churning many individual inline styles.
.feed-card { contain: layout paint style; content-visibility: auto; contain-intrinsic-size: 1px 180px;}CSS containment limits how much a subtree can affect the rest of the page. content-visibility: auto lets the browser skip rendering off-screen content while keeping the DOM mounted. contain-intrinsic-size reserves space so scroll position does not jump before the content is rendered.
Virtualizing long lists
10,000 ROWSA list with 10,000 rows does not need 10,000 DOM nodes if the user can see only a few at a time. Virtualization, also called windowing or virtual scrolling, renders the visible rows plus an overscan buffer. Top and bottom spacers keep the scrollbar the same height as the full list.
Step through the fixed-height virtual-list math used by the live 10,000-row demo.
script
const rowHeight = 32;const viewportHeight = 160;const scrollTop = 328;const overscan = 2; const firstVisible = Math.floor(scrollTop / rowHeight);const lastVisible = Math.floor((scrollTop + viewportHeight - 1) / rowHeight);const startIndex = Math.max(0, firstVisible - overscan);const endIndex = Math.min(totalRows - 1, lastVisible + overscan);const beforeHeight = startIndex * rowHeight;const afterHeight = (totalRows - endIndex - 1) * rowHeight; console.log(startIndex + "-" + endIndex);console.log(beforeHeight + ":" + afterHeight);- Top spacer
- 0px
- Bottom spacer
- 319616px
- Mounted rows
- 12
ScrollTop 0 maps to visible rows 0–7; overscan expands the mounted window to 0–11.
Fixed-height rows are the easy case because scrollTop / rowHeight gives the first visible index. Variable-height rows need measurement, caching, and correction when real heights change. That is why many production virtualizers maintain an index of measured row offsets.
| Strategy | What stays in the DOM | Best fit | Trade-off |
|---|---|---|---|
| Full render | Every row has a DOM node | Small lists, print views, content that must be searchable now | Large lists slow style, layout, paint, memory, and accessibility trees |
| Virtualized window | Only visible rows plus overscan are real DOM | Thousands of similar rows in app-like scroll containers | Find-in-page and some screen-reader flows cannot reach unmounted rows |
content-visibility: auto | DOM stays mounted, off-screen rendering work is skipped | Long article cards where native find and accessibility should keep working | The DOM and accessibility tree can still be large; use contain-intrinsic-size to reserve space |
A virtualized row that is not mounted cannot be found by the browser’s find-in-page, and some screen-reader flows cannot reach it. For article-like content, content-visibility: auto may be enough. For app-like data grids, virtualization can be right, but expose good keyboard navigation, counts, and labels.
Finding rendering work in DevTools
VERIFYMeasure before changing architecture. Chrome DevTools has two places to check rendering work. The Rendering tab can show paint flashing and layout shift regions. The Performance panel records a timeline where layout work appears as purple Layout blocks; forced synchronous layout warnings are named Forced reflow.
// In Chrome DevTools:// 1. More tools -> Rendering -> Paint flashing.// 2. Performance panel -> record an interaction.// 3. Look for purple "Layout" blocks.// 4. Expand warnings named "Forced reflow".When a recording shows purple layout blocks under your event handler, inspect the call stack. If the stack points to a geometry read after a write, fix ordering. If paint dominates, reduce paint area or isolate subtrees. If JavaScript dominates, move to the main-thread responsiveness techniques.
Real-world patterns
Rendering performance work shows up in autocomplete menus, draggable panels, dashboards, editors, infinite feeds, and data grids. The practical decision tree is:
- Use debounce or throttle to limit expensive input and scroll handlers, then still keep each handler cheap.
- Use ResizeObserver and IntersectionObserver instead of polling with scroll handlers when the browser can tell you about size or visibility changes.
- Use a renderer or framework to patch changed nodes instead of replacing large chunks of DOM with
innerHTML. - Use class toggles for designed states, CSS variables for data, and
transform/opacityfor motion when the visual design allows it. - Use virtualization for huge app-like lists, and
content-visibilityfor long document-like pages where keeping content mounted matters.
The next lesson, Loading performance, moves from rendering work after code runs to shipping and loading less work in the first place.
Misconceptions and trade-offs
- “Transforms are free.” They are often compositor-friendly, but too many layers consume memory and can make compositing expensive.
- “`will-change` fixes animation.” It is a short-lived hint. Leaving it everywhere is a cost.
- “`innerHTML` is the fastest render.” It may be simple, but it reparses markup and can destroy focus, selection, listeners, and typed state.
- “Virtualization is always best.” It can break find-in-page and hide content from assistive technology when rows are unmounted.
- “The browser will batch everything for me.” The browser batches many things, but a synchronous geometry read after a write asks for the answer right now.
Practice exercises
Predict the number logged by the starter code.
console.log((1000 / 60).toFixed(1));console.log((1000 / 60).toFixed(1));1000 / 60 is 16.666..., and toFixed(1) prints 16.7. Real pages get less than that for your code.
Use the fake model from the lesson. How many forced layouts print?
let dirty = false;
let forcedLayouts = 0;
function writeStyle() { dirty = true; }
function readLayout() {
if (dirty) {
forcedLayouts += 1;
dirty = false;
}
}
for (let index = 0; index < 3; index += 1) {
writeStyle();
readLayout();
}
console.log(forcedLayouts);let dirty = false;
let forcedLayouts = 0;
function writeStyle() { dirty = true; }
function readLayout() {
if (dirty) {
forcedLayouts += 1;
dirty = false;
}
}
for (let index = 0; index < 3; index += 1) {
writeStyle();
readLayout();
}
console.log(forcedLayouts);The loop runs three write-read pairs. Every read follows a dirty write, so the model prints 3.
Predict the mounted range for a fixed-height virtual list.
const rowHeight = 32;
const viewportHeight = 160;
const scrollTop = 328;
const overscan = 2;
const firstVisible = Math.floor(scrollTop / rowHeight);
const lastVisible = Math.floor((scrollTop + viewportHeight - 1) / rowHeight);
const startIndex = Math.max(0, firstVisible - overscan);
const endIndex = lastVisible + overscan;
console.log(startIndex + "-" + endIndex);const rowHeight = 32;
const viewportHeight = 160;
const scrollTop = 328;
const overscan = 2;
const firstVisible = Math.floor(scrollTop / rowHeight);
const lastVisible = Math.floor((scrollTop + viewportHeight - 1) / rowHeight);
const startIndex = Math.max(0, firstVisible - overscan);
const endIndex = lastVisible + overscan;
console.log(startIndex + "-" + endIndex);The first visible row is 10, the inclusive last visible row is 15, and overscan expands the range to 8-17.
Predict the log from a queue that drains reads before writes.
const reads = [];
const writes = [];
const log = [];
reads.push(() => log.push("read"));
writes.push(() => log.push("write"));
reads.forEach((task) => task());
writes.forEach((task) => task());
console.log(log.join(" -> "));const reads = [];
const writes = [];
const log = [];
reads.push(() => log.push("read"));
writes.push(() => log.push("write"));
reads.forEach((task) => task());
writes.forEach((task) => task());
console.log(log.join(" -> "));Even if tasks were requested elsewhere, this flush order logs read -> write, which avoids read-after-write thrashing.
A customer grid has 50,000 fixed-height rows and app-style keyboard navigation. Which strategy renders only visible rows plus overscan?
Use virtualization, also called windowing or virtual scrolling, when an app-like list has thousands of similar rows and only a small viewport is visible.
Quiz
Answer these by identifying the rendering stage first, then checking whether a geometry read happens after a write.
Question 1 of 8What is the usual rendering pipeline order for one frame?
Choose an answer to see the explanation.
Question 2 of 8Which read commonly forces synchronous layout when it happens after a style write?
Choose an answer to see the explanation.
Question 3 of 8How many forced layouts does this model print?
Read the code, then predictlet dirty = false; let forcedLayouts = 0; function write() { dirty = true; } function read() { if (dirty) { forcedLayouts += 1; dirty = false; } } write(); read(); write(); read(); console.log(forcedLayouts);Choose an answer to see the explanation.
Question 4 of 8Which property pair is usually safest for smooth motion?
Choose an answer to see the explanation.
Question 5 of 8Why queue DOM writes in
requestAnimationFrameafter measurements?Choose an answer to see the explanation.
Question 6 of 8Which visible fixed-height rows does this math find before overscan?
Read the code, then predictconst scrollTop = 96; const rowHeight = 24; const viewportHeight = 72; const first = Math.floor(scrollTop / rowHeight); const last = Math.floor((scrollTop + viewportHeight - 1) / rowHeight); console.log(first + "-" + last);Choose an answer to see the explanation.
Question 7 of 8When is
content-visibility: autoenough instead of virtualizing?Choose an answer to see the explanation.
Question 8 of 8What is the biggest caution with
will-change?Choose an answer to see the explanation.
Key takeaways
- Think in stages: JavaScript, Style, Layout, Paint, Composite.
- Forced synchronous layout happens when a geometry read needs fresh layout after a write.
- Batch reads before writes; use
requestAnimationFramefor visual mutations. - Build DOM in fragments, use classes and CSS variables intentionally, and avoid big
innerHTMLrerenders for stateful UI. - Virtualize huge app-like lists, but use
content-visibilitywhen content should stay mounted.
Next, Loading performance focuses on shipping less JavaScript and loading it smarter, so there is less work to parse and execute before rendering begins.