cf.completefrontendCode editorOpen lab
THE JAVASCRIPT FIELD GUIDE

DOM & rendering performance

Learn the browser rendering pipeline, prevent layout thrashing, batch DOM reads and writes, and virtualize long lists smoothly.

By the end, you can
  • 01
    Read the rendering pipelineExplain how JavaScript, style, layout, paint, and composite fit into a frame budget.
  • 02
    Prevent forced layoutsSpot layout thrashing, prove it with a deterministic model, and batch reads before writes.
  • 03
    Render 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.

Plain definition

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.

Real-life analogyA painter who keeps re-measuring the wall

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 offsetHeight or getBoundingClientRect()
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

PIPELINE

A frame can pass through five important stages. The browser can skip stages when nothing invalidates them, but this order is the working mental model:

The frame pipelinePop out in the code editor (opens in a new tab)JavaScript
const pipeline = [  "JavaScript",  "Style",  "Layout",  "Paint",  "Composite",]; console.log(pipeline.join(" -> "));
  1. JavaScript changes DOM, styles, classes, scroll positions, or data.
  2. Style finds matching CSS rules and computed values.
  3. Layout, also called reflow, computes box sizes and positions.
  4. Paint draws pixels for backgrounds, borders, shadows, text, and images.
  5. Composite moves and blends layers, often for transform and opacity.

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.

Common CSS property costsPop out in the code editor (opens in a new tab)JavaScript
const propertyCosts = {  width: "layout",  paddingTop: "layout",  boxShadow: "paint",  backgroundColor: "paint",  transform: "composite",  opacity: "composite",}; console.log(propertyCosts.transform);console.log(propertyCosts.width);
Which rendering stage does this usually trigger?
  • `width`
  • `padding`
  • `top` on a positioned element
  • `background-color`
  • `box-shadow`
  • `color`
  • `transform: translateX(...)`
  • `opacity`
  • `will-change: transform`
Try it yourself
0 of 9 correct

Sort each property by the browser work it usually needs. The explanations call out important caveats.

Choose a category for every card. You can change an answer at any time; Reset clears them all.
Layout, paint, and composite compared
StageQuestion it answersCommon triggersPerformance move
StyleWhich CSS rules and computed values apply?Class changes, media queries, getComputedStyle readsAvoid repeating style reads after writes in loops
LayoutHow big is each box and where does it sit?width, padding, text changes, offsetHeight readsBatch layout reads before layout-affecting writes
PaintWhich pixels need drawing?background-color, box-shadow, colorKeep paint areas small; use containment for isolated cards
CompositeHow are already-painted layers moved and blended?transform, opacity, promoted layersUse for motion, but budget layer memory and will-change
will-change is a budget, not a blanket

will-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 DEMO

A 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.

A deterministic forced-layout modelPop out in the code editor (opens in a new tab)JavaScript
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.

Thrash vs batched layout reads
Real DOM pattern used by thrash modePop out in the code editor (opens in a new tab)JavaScript
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");
Modenot run
Elapsed--
Forced layouts in model--
Height sum--
Live boxesrun 0
Box 1
Box 2
Box 3
Box 4
Box 5
Box 6
Box 7
Box 8
Box 9
Box 10
Box 11
Box 12
Box 13
Box 14
Box 15
Box 16
Box 17
Box 18
Box 19
Box 20
Box 21
Box 22
Box 23
Box 24
Box 25
Box 26
Box 27
Box 28
Box 29
Box 30
Box 31
Box 32
Box 33
Box 34
Box 35
Box 36
Box 37
Box 38
Box 39
Box 40
Box 41
Box 42
Box 43
Box 44
Box 45
Box 46
Box 47
Box 48
Box 49
Box 50
Box 51
Box 52
Box 53
Box 54
Box 55
Box 56
Box 57
Box 58
Box 59
Box 60
Box 61
Box 62
Box 63
Box 64
Box 65
Box 66
Box 67
Box 68
Box 69
Box 70
Box 71
Box 72
Try it yourself

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 page uses real DOM boxes for elapsed time and an instrumented fake layout model for deterministic forced-layout counts. Reset clears inline styles.

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 THROUGH

The 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 measure/mutate queue
Step 0 of 11Ready
Your turn: follow the blue line

Step through a tiny FastDOM-style scheduler that queues reads and writes for the next frame.

Running in
  1. script
Next: line 2
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
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(" -> "));
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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.

Bulk insert with a fragmentPop out in the code editor (opens in a new tab)JavaScript
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.

Prefer state classes and CSS variablesPop out in the code editor (opens in a new tab)JavaScript
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.

Contain long cards without unmounting themCSS
.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 ROWS

A 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 virtual-list index math
Step 0 of 13Ready
Your turn: follow the blue line

Step through the fixed-height virtual-list math used by the live 10,000-row demo.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
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);
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
Virtual list: 10,000 logical rows, a tiny DOM window
Row 1Mounted index 0
Row 2Mounted index 1
Row 3Mounted index 2
Row 4Mounted index 3
Row 5Mounted index 4
Row 6Mounted index 5
Row 7Mounted index 6
Row 8Mounted index 7
Row 9Mounted index 8
Row 10Mounted index 9
Row 11Mounted index 10
Row 12Mounted index 11
Top spacer
0px
Bottom spacer
319616px
Mounted rows
12
Try it yourself
Rendering rows 0–11 (12 nodes)

ScrollTop 0 maps to visible rows 0–7; overscan expands the mounted window to 0–11.

The scrollbar represents all 10,000 rows. Only the rows in the current window plus overscan are mounted.

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.

Full render, virtualization, and content-visibility compared
StrategyWhat stays in the DOMBest fitTrade-off
Full renderEvery row has a DOM nodeSmall lists, print views, content that must be searchable nowLarge lists slow style, layout, paint, memory, and accessibility trees
Virtualized windowOnly visible rows plus overscan are real DOMThousands of similar rows in app-like scroll containersFind-in-page and some screen-reader flows cannot reach unmounted rows
content-visibility: autoDOM stays mounted, off-screen rendering work is skippedLong article cards where native find and accessibility should keep workingThe DOM and accessibility tree can still be large; use contain-intrinsic-size to reserve space
Accessibility and find-in-page trade-offs

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

VERIFY

Measure 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.

DevTools rendering checklistJavaScript
// 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/opacity for motion when the visual design allows it.
  • Use virtualization for huge app-like lists, and content-visibility for 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

Exercise 1 · Warm-upCompute a 60Hz frame budget

Predict the number logged by the starter code.

Starter codePop out in the code editor (opens in a new tab)JavaScript
console.log((1000 / 60).toFixed(1));

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · PracticeCount forced layout flushes

    Use the fake model from the lesson. How many forced layouts print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    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);

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeFind the virtualized row range

      Predict the mounted range for a fixed-height virtual list.

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      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);

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticePreserve scheduler order

        Predict the log from a queue that drains reads before writes.

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        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(" -> "));

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeChoose a long-list strategy

          A customer grid has 50,000 fixed-height rows and app-style keyboard navigation. Which strategy renders only visible rows plus overscan?

          Answer, then press Check. Spacing and letter case don’t matter.

            Quiz

            Answer these by identifying the rendering stage first, then checking whether a geometry read happens after a write.

            DOM rendering performance quiz · 8 questionsScore: first tries count
            1. Question 1 of 8What is the usual rendering pipeline order for one frame?

              Choose an answer to see the explanation.

            2. Question 2 of 8Which read commonly forces synchronous layout when it happens after a style write?

              Choose an answer to see the explanation.

            3. Question 3 of 8How many forced layouts does this model print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              let 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.

            4. Question 4 of 8Which property pair is usually safest for smooth motion?

              Choose an answer to see the explanation.

            5. Question 5 of 8Why queue DOM writes in requestAnimationFrame after measurements?

              Choose an answer to see the explanation.

            6. Question 6 of 8Which visible fixed-height rows does this math find before overscan?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const 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.

            7. Question 7 of 8When is content-visibility: auto enough instead of virtualizing?

              Choose an answer to see the explanation.

            8. 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 requestAnimationFrame for visual mutations.
            • Build DOM in fragments, use classes and CSS variables intentionally, and avoid big innerHTML rerenders for stateful UI.
            • Virtualize huge app-like lists, but use content-visibility when 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.

            CompleteFrontend Clear concepts. Working examples.