DOM & rendering performance
Learn the browser rendering pipeline, prevent layout thrashing, batch DOM reads and writes, and virtualize long lists smoothly.
- 01Name the five steps of a frameExplain JavaScript, Style, Layout, Paint, and Composite, and how they share the short time one frame has.
- 02Stop forced layoutsSpot a size read that comes right after a write, count forced layouts with a small model, and fix it by reading first.
- 03Put less on the pageUse fragments,
contain, observers, and virtual lists, and know what each one costs.
Keep the work for each frame small
When something on a page changes, the browser draws the page again. Each new drawing of the screen is called a frame. Rendering performance is about keeping the work for each frame small. Then the page reacts fast and moves smoothly.
This lesson uses ideas from earlier lessons:
- measuring elements, from Sizes, scrolling & coordinates
- making new elements, from Creating & changing elements
- classes and inline styles, from Styles & classes
- running code once per frame, from Animation with JavaScript
- being told when an element changes, from Observers
Rendering performance means changing, measuring, drawing, and moving things on the page without making the browser do extra work inside one frame.
A screen redraws many times each second. This speed is called the refresh rate. It is measured in hertz (Hz): 60Hz means 60 times a second. So at 60Hz, the browser has about 16.7ms to make one frame. (A millisecond, ms, is a thousandth of a second.) At 120Hz it has about 8.3ms.
That time is shared. Your JavaScript uses it. The browser's own drawing steps use it. Handling clicks and key presses uses it too. If the work takes longer, the browser misses a frame and the page stutters. People call this stutter jank.
const refreshRates = [60, 120];
for (const refreshRate of refreshRates) {
console.log((1000 / refreshRate).toFixed(1));
}Line 1 lists two refresh rates, 60 and 120. Line 3 divides 1,000 milliseconds (one second) by each rate. Then toFixed(1) rounds the answer to one decimal place. So the code prints 16.7 and then 8.3. This is the time between two frames. Your JavaScript gets only part of it.
A painter is painting a room. After every single brush stroke, she asks a helper to measure the whole room again. Each measurement is correct, but most of them are wasted. It is faster to measure once, paint many strokes, and measure again only when needed.
- In real life: The painter paints one stroke
- In JavaScript: JavaScript writes a style
- In real life: She asks for the room to be measured right away
- In JavaScript: Code reads
offsetHeightorgetBoundingClientRect() - In real life: A helper measures the whole room again
- In JavaScript: The browser runs layout right now
- In real life: This happens after every stroke
- In JavaScript: A write-then-read loop causes layout thrashing
- In real life: Measure once, then paint many strokes
- In JavaScript: Do all reads first, then all writes
Other lessons cover nearby topics:
- Measuring performance shows how to time your code.
- Main thread shows how to split up long tasks.
- Debounce & throttle shows how to run a handler less often.
This lesson is about the drawing work that each handler causes.
The five steps of a frame
PIPELINETo draw a frame, the browser can run up to five steps, always in the same order. This list of steps is called the rendering pipeline. The browser skips a step when nothing has changed for it.
const pipeline = [ "JavaScript", "Style", "Layout", "Paint", "Composite",]; console.log(pipeline.join(" -> "));The code prints the five steps joined by arrows. Here is what each step does:
- JavaScript: your code runs. It may change elements, styles, classes, or the scroll position.
- Style: the browser works out which CSS rules apply to each element.
- Layout (also called reflow): the browser works out how big each box is and where it sits.
- Paint: the browser draws the pixels: backgrounds, borders, shadows, text, and images.
- Composite: the browser puts the painted parts together on the screen. A painted part that can move on its own is called a layer. Moving or fading a whole layer can happen in this step alone. That is often how
transformandopacitywork.
CSS is not slow, and the DOM is not slow. The cost comes from waking up more steps than a change needs. It gets much worse when your code does that many times in a row.
Different CSS properties wake up different steps. The next example lists a few common ones.
const propertyCosts = { width: "layout", paddingTop: "layout", boxShadow: "paint", backgroundColor: "paint", transform: "composite", opacity: "composite",}; console.log(propertyCosts.transform);console.log(propertyCosts.width);- Lines 2 and 3 change the size of a box, so they need layout. Paint and composite follow.
- Lines 4 and 5 change only how the box looks, so they start at paint.
- Lines 6 and 7 can often be done in the composite step alone.
- The last two lines print
compositeandlayout.
width change wakes up all three steps. A transform change often wakes up only Composite.Here is a small message box, called a toast, that slides up and fades in. It changes only transform and opacity.
.toast {
opacity: 0;
transform: translateY(8px);
transition: transform 180ms ease, opacity 180ms ease;
}
.toast.is-visible {
opacity: 1;
transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
.toast { transition: none; }
}- The first rule (lines 1 to 5) starts the toast 8px lower and fully see-through (
opacity: 0). Line 4 says both changes should animate over 180ms. - The second rule (lines 7 to 10) applies when the toast gets the
is-visibleclass. It moves the toast to its final place and makes it fully visible. - The last rule (lines 12 to 14) turns the animation off when the user has asked their device for less motion.
Browsers can often do these two changes in the composite step. It still depends on the page and the device.
will-change is a hint to the browser. It says: "this element is about to change this property, so get ready."
const card = document.createElement("article");
card.style.willChange = "transform";
console.log(card.style.willChange);
card.style.willChange = "auto";
console.log(card.style.willChange);Line 2 tells the browser the card may soon move with transform. Line 4 sets the hint back to auto, which means "no hint". So the code prints transform, then auto.
A hint does not promise a faster animation. Add one only after you have measured a real problem.
`width``padding``top` on a positioned element`background-color``box-shadow``color``transform: translateX(...)``opacity``will-change: transform`
Put each property under the first step it usually needs. Each explanation names the exceptions.
The table below compares the four steps after JavaScript. Read one row at a time: the step, what it works out, what wakes it up, and how to keep it small.
| Step | What does it work out? | What wakes it up? | How do you keep it small? |
|---|---|---|---|
| Style | Which CSS rules apply to each element, and their final values. | Class changes, media queries, and getComputedStyle() reads. | Don't read styles right after writes inside a loop. |
| Layout | How big each box is and where it sits. | width, padding, text changes, and offsetHeight reads. | Do all size reads before any write that changes sizes. |
| Paint | Which pixels need drawing. | background-color, box-shadow, and color. | Keep the painted area small. Use contain to keep each card separate. |
| Composite | How the painted layers are moved and faded on the screen. | transform, opacity, and elements that have their own layer. | Use it for movement. Each layer uses memory, so use will-change only for a short time. |
will-change on a few elements, for a short timewill-change: transform can make the browser prepare a layer before the movement starts. Each layer uses memory. Remove the hint when the movement is over. Never add it to hundreds of elements "just in case".
Layout thrashing: reading right after writing
REAL DEMOSometimes your code needs the size or position of an element. Asking for it is called a layout read. Common layout reads are:
offsetHeightgetBoundingClientRect()scrollTopgetComputedStyle()
A layout read is fine on its own. The problem is a read that comes right after a write: a change that can move or resize things, like a new width. The browser can't give you an old answer. So it pauses your JavaScript and runs layout right away. This is called a forced layout (or forced reflow).
Now picture a loop that does "write, read, write, read" for many elements. The browser runs layout once for every element. This is called layout thrashing.
Real timings change from one computer to the next. So the next example uses a small pretend model instead of the real browser. It follows the same rule, and it always gives the same answer.
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);How the model works:
- Each box has a
height, areadHeight()method, and awriteHeight()method. - A write sets
dirtytotrue(line 16). Heredirtymeans "the layout is out of date". - A read checks
dirty(line 8). If it istrue, the read adds 1 toforcedLayoutsand setsdirtyback tofalse(lines 9 and 10). - In
"thrash"mode, each box is written and then read straight away (lines 30 and 31). - In
"batched"mode, all heights are read first (line 34). Then all boxes are written (lines 35 to 37).
With 5 boxes, the code prints 5 for thrash mode and 0 for batched mode.
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 both modes. The times are real and change on every run. The forced-layout count comes from the pretend model, so it always stays the same.
The demo above uses real boxes on the page and real time, so your timings will differ from anyone else's. The forced-layout count comes from the pretend model, so it never changes. Thrash mode forces one layout per box. Batched mode forces none, because every read happens before any write.
Do all reads first, then all writes
STEP THROUGHThe fix is simple. Do the work in three steps:
- Read all the sizes and positions you need.
- Decide what to change.
- Write all the changes.
When the writes change what the user sees, put them inside requestAnimationFrame. This browser function takes a callback and runs it once, just before the next frame is drawn.
Some libraries, such as FastDOM, use two words for this: measure for reads and mutate for writes. The step-through below is a tiny scheduler: a helper that decides when each task runs. It keeps reads in one list and writes in another.
Step through a tiny scheduler, like FastDOM, that keeps reads and writes in two lists until 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(" -> "));The code asks for the write first (line 36) and the read second (line 37). When the frame runs, the scheduler still runs the read first, so the code prints read: rect -> write: transform.
The next example updates the screen only once per frame, even when new values arrive faster. It uses its own small requestAnimationFrame on line 4, so you can see what happens. That version saves each callback in the frames list instead of waiting for a real frame.
const frames = [];
let pending = false;
let latestX = 0;
function requestAnimationFrame(callback) { frames.push(callback); }
function moveTo(x) {
latestX = x;
if (pending) return;
pending = true;
requestAnimationFrame(() => {
pending = false;
console.log("translateX(" + latestX + "px)");
});
}
moveTo(12);
moveTo(28);
console.log(frames.length);
frames.shift()();- Lines 16 and 17 call
moveTotwice, first with 12 and then with 28, before any frame runs. - Each call saves the newest value in
latestX(line 7). - Only the first call asks for a frame. On the second call,
pendingis alreadytrue, so line 8 stops it. - Line 18 prints
1, because only one callback is waiting. - Line 19 runs that callback. It prints
translateX(28px), the newest value.
The real requestAnimationFrame runs its callback once, just before the next paint. It still runs on the main thread, the same place as the rest of your page code.
When you add many elements, you can build them away from the page first. A DocumentFragment is a light container that is not on the page. You put elements inside it, then add them all to the page in one step.
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);- Line 2 makes the fragment.
- Lines 4 to 7 make three
liitems and put them in the fragment. - Line 10 moves all three items into the list with
replaceChildren. - Line 11 prints
3: the list now has three children. - Line 12 prints
0: the items moved out, so the fragment is now empty.
This is one change to the page, instead of three separate ones.
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"));Line 2 gives the card the class card. Line 3 adds the class is-open. Line 4 sets the CSS variable --progress to 0.64. So the code prints card is-open and 0.64.
The Styles & classes lesson explained when to use which. A class picks a look you designed in CSS. An inline style or CSS variable carries a value that keeps changing, like a number.
This also helps speed. One class change can switch many CSS rules at once. Your JavaScript doesn't have to set many inline styles one by one.
Long pages can also tell the browser to skip drawing parts that are off the screen:
.feed-card { contain: layout paint style; content-visibility: auto; contain-intrinsic-size: 1px 180px;}- Line 2,
contain, says the card's insides don't affect the rest of the page. So the browser can redo style, layout, and paint for one card without checking the whole page. - Line 3,
content-visibility: auto, lets the browser skip drawing cards that are off the screen. The cards stay in the DOM. - Line 4,
contain-intrinsic-size, gives each skipped card a stand-in size of 180px tall. Without it, the page could jump while you scroll.
Long lists: create only the rows you can see
10,000 ROWSThink of a list with 10,000 rows. The user can see only a few rows at a time, so the page doesn't need 10,000 elements. It can create only the rows on the screen, plus a few extra. This is called virtualization. It is also called windowing or virtual scrolling.
A virtual list has three parts:
- The viewport is the part of the list you can see.
- Overscan is a few extra rows above and below the viewport. They are ready in case you scroll fast.
- Two empty spacers, one at the top and one at the bottom, take up the space of all the other rows. They keep the scroll bar the same size as for the full list.
The step-through below works out which rows to create. The list has 10,000 rows, each 32px tall. The viewport is 160px tall and scrolled down 328px. There are 2 rows of overscan.
Step through the math for a virtual list where every row has the same height. The live 10,000-row demo uses the same math.
script
const rowHeight = 32;const viewportHeight = 160;const scrollTop = 328;const overscan = 2; const firstVisible = Math.floor(scrollTop / rowHeight);const lastVisible = Math.ceil((scrollTop + viewportHeight) / rowHeight) - 1;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);scrollTop at 328, only rows 8 to 17 are real elements: 6 on the screen and 2 extra on each side. Two empty spacers stand in for the other 9,990 rows.const rowHeight = 32;
const viewportHeight = 64;
const scrollTop = 0.5;
const firstVisible = Math.floor(scrollTop / rowHeight);
const lastVisible = Math.ceil((scrollTop + viewportHeight) / rowHeight) - 1;
console.log(firstVisible + "-" + lastVisible);Here scrollTop is 0.5, so the viewport runs from 0.5px to 64.5px. Each row is 32px tall, so row 2 starts at 64px. A thin strip of row 2 is on the screen.
- Line 4 finds the first row:
Math.floor(0.5 / 32)is0. - Line 5 finds the last row:
Math.ceil(64.5 / 32)is 3, and 3 minus 1 is2. - So the code prints
0-2.
A shortcut that subtracts one whole pixel would work out Math.floor(63.5 / 32), which is 1. It would miss row 2 whenever scrollTop is not a whole number.
- Top spacer
- 0px
- Bottom spacer
- 319616px
- Rows created
- 12
scrollTop is 0, so rows 0–7 are on the screen. With the extra rows, the list creates rows 0–11.
When every row has the same height, the math is easy: scrollTop / rowHeight, rounded down, gives the first row on the screen. When rows have different heights, the list must measure each row, remember the sizes, and fix them when a row changes. That is why real list libraries keep a saved list of where each row starts.
The table below compares three ways to show a long list. Read across a row to see what each way keeps on the page, when it fits best, and what it costs.
| Way | What is on the page? | When does it fit best? | What does it cost? |
|---|---|---|---|
| Create every row | Every row is an element on the page. | Small lists, print views, and content people must be able to search right away. | With a large list, style, layout, paint, memory, and screen readers all get slower. |
| Virtual list | Only the rows on the screen, plus a few extra, are elements. | Thousands of rows that look alike, in an app-like scrolling box. | Find-in-page and some screen readers can't reach the rows that aren't on the page. |
content-visibility: auto | All rows stay on the page. The browser skips drawing the ones off the screen. | Long article-like pages, where find-in-page and screen readers should keep working. | The page still has many elements. Use contain-intrinsic-size to keep space for the skipped parts. |
A row that isn't on the page can't be found with the browser's find-in-page search (Ctrl+F or Cmd+F). Some screen readers can't reach it either. For article-like pages, content-visibility: auto may be enough. For app-like data tables, virtualization can be the right choice. Then give good keyboard controls, row counts, and labels.
Find rendering work in DevTools
VERIFYMeasure first, before you change how your code is built. Chrome DevTools has two places to check drawing work:
- The Rendering tab can flash the parts of the page that get painted again. It can also show areas where the layout jumped.
- The Performance panel records a timeline of what the browser did. Layout work shows as purple blocks named Layout. A forced layout gets a warning 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 the recording shows purple Layout blocks under your event handler, open its call stack: the list of functions that led there. Then:
- If it points to a size read right after a write, change the order: reads first, then writes.
- If paint takes most of the time, make the painted area smaller, or use
containto keep each part separate. - If JavaScript takes most of the time, use the ideas from the Main thread lesson.
Where this shows up in real code
You will meet these problems in search boxes with suggestions, panels you can drag, dashboards, editors, endless feeds, and big data tables. Here is what to use:
- Use debounce or throttle to run heavy typing and scroll handlers less often. Still keep each handler small.
- Use ResizeObserver and IntersectionObserver to be told when an element changes size or comes into view. Then you don't have to check it in every scroll handler.
- Use a small renderer or a framework to change only the elements that changed. Don't replace big parts of the page with
innerHTML. - Use classes for looks you designed, CSS variables for numbers, and
transformandopacityfor movement when the design allows it. - Use virtualization for huge app-like lists. Use
content-visibilityfor long pages where the content should stay on the page.
The next lesson, Loading performance, is about the work before your code runs: sending and loading less code in the first place.
Common misunderstandings
- "Moving things with
transformcosts nothing." It often skips layout and paint. But each layer uses memory, and too many layers make the composite step slow. - "
will-changemakes animation fast." It is only a hint, meant for a short time. Leaving it on many elements wastes memory. - "
innerHTMLis the fastest way to update." It is short to write, but the browser must read the whole HTML string again. It also throws away focus, selected text, event listeners, and anything typed into inputs. - "Virtualization is always best." Rows that aren't on the page can't be found with find-in-page, and some screen readers can't reach them.
- "The browser groups all my changes for me." It groups many of them. But a size read right after a write asks for the answer now, so the browser must run layout at once.
Practice exercises
What number does the starter code print?
console.log((1000 / 60).toFixed(1));console.log((1000 / 60).toFixed(1));1000 / 60 is 16.666.... toFixed(1) rounds it to one decimal place, so the code prints 16.7. Your own code gets only part of that time.
This code uses the same pretend model as the lesson. How many forced layouts does it 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 times. Each time, the read comes right after a write, while dirty is true. So each read adds 1, and the code prints 3.
Every row in this list has the same height. Which rows does the list create?
const rowHeight = 32;
const viewportHeight = 160;
const scrollTop = 328;
const overscan = 2;
const firstVisible = Math.floor(scrollTop / rowHeight);
const lastVisible = Math.ceil((scrollTop + viewportHeight) / rowHeight) - 1;
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.ceil((scrollTop + viewportHeight) / rowHeight) - 1;
const startIndex = Math.max(0, firstVisible - overscan);
const endIndex = lastVisible + overscan;
console.log(startIndex + "-" + endIndex);The first row on the screen is 10, and the last is 15. With 2 extra rows on each side, the list creates rows 8-17.
This code keeps reads and writes in two lists. What order does it print?
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(" -> "));The code runs the reads list first and the writes list second, so it prints read -> write. No read comes right after a write, so there is no layout thrashing.
A customer table has 50,000 rows, all the same height. People move through it with the keyboard, like in an app. Which way creates only the rows on the screen, plus a few extra?
Use virtualization, also called windowing or virtual scrolling. It fits app-like lists with thousands of rows that look alike, where the user sees only a few rows at a time.
Quiz
For each question, first ask which step the code wakes up. Then check whether a size read comes right after a write.
Question 1 of 8In what order does the browser usually run the steps for one frame?
Choose an answer to see the explanation.
Question 2 of 8Which read often forces layout when it comes right 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 two properties are usually best for smooth movement?
Choose an answer to see the explanation.
Question 5 of 8Why put DOM writes inside
requestAnimationFrameafter you read sizes?Choose an answer to see the explanation.
Question 6 of 8Which rows on the screen does this math find, before any extra rows are added?
Read the code, then predictconst scrollTop = 96; const rowHeight = 24; const viewportHeight = 72; const first = Math.floor(scrollTop / rowHeight); const last = Math.ceil((scrollTop + viewportHeight) / rowHeight) - 1; console.log(first + "-" + last);Choose an answer to see the explanation.
Question 7 of 8When is
content-visibility: autoenough, so you don't need a virtual list?Choose an answer to see the explanation.
Question 8 of 8What is the main warning about
will-change?Choose an answer to see the explanation.
Key takeaways
- The browser draws a frame in up to five steps: JavaScript, Style, Layout, Paint, and Composite.
- A forced layout happens when your code reads a size or position right after a write.
- Do all reads first, then all writes. Put changes the user sees inside
requestAnimationFrame. - Build many elements in a fragment. Use classes for designed looks and CSS variables for numbers. Don't rebuild big parts of the page with
innerHTML. - Use a virtual list for huge app-like lists. Use
content-visibilitywhen the content should stay on the page.
Next, Loading performance is about sending less JavaScript and loading it in a smarter way. Then the browser has less code to read and run before it can draw the page.