The pixel pipeline
Style, layout, paint, and composite: what each step does and which changes trigger which steps. Measured in Chrome on 1,000 boxes, step by step, for six kinds of change.
- 01Name the stepsSay what style, layout, paint, raster and compositing each do, and which thread does each one.
- 02Predict the pathTell which steps a change runs: a change of size or place, a change of look, or a change to
transformoropacity. - 03Avoid extra workKeep a class's reach small, give layers only to what moves, and read sizes before you change styles.
From a change to pixels
Asha taps Add tea on the tea shop's page, and the button turns a darker green. Then a small cart slides in from the side. Each change like this has to end up as pixels on the screen. And while something moves, the screen needs a new picture many times a second. Each of those pictures is a frame. Most screens show 60 frames a second.
Some changes are cheap for the browser. Others make it redo a lot of work. To see the difference, the course built a test page: 1,000 small green boxes in a grid. Then it changed one thing about every box, in every frame, for 60 frames. That's about one second.
Chrome recorded what its main thread did the whole time. That's the head cook from lesson 6.1, who runs your JavaScript and works out styles, layout and paint, one job at a time. Here are two of the recorded seconds:
// The test page: 1,000 boxes, changed in every frame for about a second.// How long the main thread worked, in milliseconds (ms):const mainThread = { width: 157.4, slide: 22.6 }; console.log(`Changing width from JavaScript: ${mainThread.width} ms`);console.log(`Sliding with a CSS animation: ${mainThread.slide} ms`);console.log(`About ${Math.round(mainThread.width / mainThread.slide)} times the work`);- Lines 1 and 2 are comments. A millisecond (ms) is a thousandth of a second.
- Line 3 holds two recorded totals.
widthis the main thread's time when JavaScript changed the width of every box, in every frame.slideis its time when a CSS animation slid every box sideways withtransform. - Lines 5 and 6 print them:
Changing width from JavaScript: 157.4 msandSliding with a CSS animation: 22.6 ms. - Line 7 divides one by the other and prints
About 7 times the work.
Same 1,000 boxes. Same second on the screen. Yet one change kept the main thread about seven times as busy as the other. Why? Because the browser turns a change into pixels in steps, and not every change needs every step.
Paul Lewis, writing on web.dev, calls these steps the pixel pipeline. A pipeline is a row of steps, where each step hands its result to the next. First comes whatever made the change: your JavaScript, or a CSS animation. Then:
- Style: work out which CSS rules apply to each element, and so how each element should look.
- Layout: work out how big each element is, and where it goes.
- Paint: work out what to draw: the text, colors, borders, shadows and images.
- Composite: put the drawn parts together, in the right order, into one frame for the screen.
A change starts at style, then runs only the steps it needs. web.dev names three paths through the pipeline:
Picture the three paths as jobs in a room you're redecorating:
You're redecorating a living room. Swapping a cushion's cover for a red one is quick: nothing moves, and only one thing looks different. Moving the sofa is a bigger job. The chairs and the lamp may have to shift too, so you measure the whole room again. Quickest of all is sliding a picture along a rail that's already hung. You don't measure anything, and you don't paint anything.
- In real life: Deciding how each thing should look
- In the browser: Style, the first step for every change
- In real life: Moving the sofa, then measuring the room again
- In the browser: Layout, after a change like
width - In real life: Swapping a cushion's cover
- In the browser: Paint without layout, after a change like
background-color - In real life: Sliding a picture along its rail
- In the browser: Composite only, after a change to
transformoropacity
Every change starts at style. A change of size or place, like width, runs layout, paint and composite after it. A change of look, like a color, skips layout. A change to transform or opacity skips layout and paint. Every step a change skips is work the main thread doesn't do.
The rest of the lesson follows the test page through the steps, one at a time. By the end, you'll know where the difference between those two numbers went:
- style, and why the number of elements a change reaches matters;
- layout, and why one box can move all the others;
- paint and raster: first a list of drawing commands, then the pixels;
- layers and compositing, and what
will-changecosts; - which changes skip which steps, and how JavaScript can force layout to run early.
The JavaScript lesson The rendering pipeline and the system design lesson Layout, paint, and compositing introduce the same steps in brief. This lesson measures each one in Chrome.
Style recalculation
Every change starts here. Before the browser can place or draw anything, it has to know how each element should look. web.dev describes the work in two parts:
- Find the rules that match. For each element, the browser checks which CSS selectors match it. The selector
.box, for example, matches every element with the classbox. - Work out the final values. The browser applies the matching rules, and settles every property: the width, the color, and all the rest.
The result for one element is its computed style: the final value of each of its CSS properties. When something changes, such as a class, the browser works this out again for every element the change might affect. That's style recalculation. In the Performance panel of Chrome DevTools, it shows up as Recalculate Style.
What makes it slow? web.dev says the worst case is "the number of elements multiplied by the selector count": every element checked against every rule. It also says that the number of elements a change affects is often more important than how complicated the selectors are.
At the tea shop, the manager can hand one waiter an orange apron. One person changes, and it takes a moment. Or she can call an orange day for the whole team. Now every waiter has to hear the rule, check their apron, and change it. Same rule, many more people.
- In real life: An orange apron for one waiter
- In the browser: The class
warmon one box - In real life: An orange day for the whole team
- In the browser: The class
warmon the grid, which every box sits inside - In real life: Each waiter who checks the rule and changes
- In the browser: Each element whose style is worked out again
The test page has two CSS rules that matter here:
.box { width: 20px; height: 20px; background: #2e7d32; }.warm, .warm .box { background: #c2560a; }- Line 1 makes every box a green square, 20 px wide and 20 px tall.
- Line 2 has two selectors, split by a comma.
.warmmatches any element with the classwarm..warm .boxmatches every box inside such an element. Both turn orange.
The course toggled the class warm in every frame, for 30 frames, in two ways. One page toggled it on one box. The other toggled it on the grid around all 1,000 boxes. The course loaded each page five times. Here is a typical recalculation from each:
// Toggling the class warm in every frame, on a page of 1,000 boxes.// A typical style recalculation: how many elements, and how many ms.const oneBox = { elements: 1, ms: 0.026 };const grid = { elements: 1001, ms: 0.654 }; console.log(`On one box: ${oneBox.elements} element restyled in ${oneBox.ms} ms`);console.log(`On the grid: ${grid.elements} elements restyled in ${grid.ms} ms`);console.log(`${Math.round(grid.ms / oneBox.ms)} times as long`);- Lines 1 and 2 are comments.
- Line 3 is the class on one box. Line 4 is the class on the grid. Each holds how many elements one recalculation restyled, and how long it took.
- Lines 6 and 7 print them:
On one box: 1 element restyled in 0.026 msandOn the grid: 1001 elements restyled in 0.654 ms. - Line 8 divides the two times and prints
25 times as long.
Why 1,001 elements? The grid itself, plus its 1,000 boxes. Line 2 of the CSS makes each box's color depend on the grid's class. So when the grid's class changed, every box had to be checked again. That's the orange day for the whole team.
Both times are small on this simple page, but the work grows with the number of elements a change reaches. Why it matters: a class on a parent can reach every element inside it. Where you can, put the class on the element that actually changes. web.dev's advice is to "reduce the number of invalidated elements": the elements whose style must be worked out again.
Layout
Once every element's style is known, the browser works out how big each element is and where it goes on the page. That step is layout. It's the sofa in the room: move one thing, and other things may have to shift. web.dev puts it simply: "one element can affect others."
The test page shows how. Its boxes sit in rows, inside a grid 800 px wide. When a box doesn't fit on a row, it goes down to the next one. So the number of boxes on a row depends on how wide they are:
// The test page's grid is 800 px wide, with a 4 px gap between boxes.// A row of n boxes holds n widths and n - 1 gaps.const boxesPerRow = (width) => Math.floor((800 + 4) / (width + 4)); console.log(`At 20 px wide, ${boxesPerRow(20)} boxes fit on a row.`);console.log(`At 29 px wide, ${boxesPerRow(29)} boxes fit on a row.`);- Lines 1 and 2 are comments. A gap is the space between two boxes, here 4 px.
- Line 3 works out how many boxes fit. Each box takes its width plus one gap. The last box on a row has no gap after it, so line 3 adds one gap's 4 px to the 800 px.
- Line 5 prints
At 20 px wide, 33 boxes fit on a row. - Line 6 prints
At 29 px wide, 24 boxes fit on a row.
The width change in the recording made the boxes anywhere from 20 px to 29 px wide. At 29 px, the first row holds 9 fewer boxes. Box 25 drops to the second row, and the rows after it shift too. The course checked this in Chrome. When every box grew from 20 px to 29 px, every box except the first one moved.
That's why layout can be slow. To know where box 500 goes, the browser has to know the size of every box before it. web.dev: "Layout is almost always scoped to the entire document." Any property that changes a size or a place needs layout. web.dev names width, height, left and top.
Here is what the width change cost, step by step. The course changed the width of all 1,000 boxes in every frame, for 60 frames:
// Changing the width of 1,000 boxes in every frame, for 60 frames.// Each main-thread step's total time over those frames, in ms.const width = { style: 31.9, layout: 37.9, prePaint: 36.4, paint: 26.7, layerize: 11.1, commit: 2.4 };const frames = 60; for (const [step, ms] of Object.entries(width)) { console.log(`${step}: ${(ms / frames).toFixed(2)} ms a frame`);}- Lines 1 and 2 are comments.
- Line 3 holds six totals from Chrome's trace: its record of what each thread did, and when. Each name is one step on the main thread. You've met
styleandlayout. The other four come up in the next two sections. - Line 4 is the number of frames.
- Lines 6 to 8 divide each total by the number of frames, and print one line per step.
The first three lines it prints are style: 0.53 ms a frame, layout: 0.63 ms a frame and prePaint: 0.61 ms a frame. The last three are paint: 0.45 ms a frame, layerize: 0.18 ms a frame and commit: 0.04 ms a frame.
Layout was the biggest step, and every step after it ran in every frame too. Add the six up, plus the JavaScript that set the widths, and the main thread worked for 157.4 ms in 60 frames. That's about 2.6 ms a frame.
Why it matters: at 60 frames a second, a frame lasts about 16.7 ms. web.dev says your own work should fit in about 10 ms of it. On this small page, 2.6 ms fits easily. But layout grows with the page. web.dev shows a page that spent over 28 ms on layout in each frame of an animation, far longer than a frame lasts. So in an animation, avoid changing sizes and places when you can.
Paint and raster
Every box now has a size and a place. Next, the browser works out what to draw. web.dev calls painting "the process of filling in pixels": the text, colors, images, borders and shadows of every element.
web.dev also says painting is really two jobs. Chrome runs them on different threads:
- Paint runs on the main thread. It writes a display list: a list of drawing commands, such as "fill this square green". It doesn't make any pixels.
- Raster turns the display list into pixels. It works on small squares of the page, called tiles. In the recording, raster ran on a worker thread, not on the main thread.
The tea shop wants a new sign. The owner writes the plan on a card: "Green board. White letters: Mint tea." Writing the card is quick, and nothing is painted yet. Then a sign painter in the back turns the card into paint, one panel of the board at a time.
- In real life: Writing the plan on a card
- In the browser: Paint, on the main thread
- In real life: The card: "Green board. White letters: Mint tea."
- In the browser: The display list: drawing commands, and no pixels yet
- In real life: The sign painter in the back
- In the browser: Raster, on a worker thread
- In real life: One panel of the board at a time
- In the browser: One tile at a time
Here are all of Chrome's steps, and the threads that run them:
You've met style, layout and paint. Here are the other three steps on the main thread:
- Pre-paint gets ready for paint. Among other jobs, it marks which parts of the last drawing, and which tiles, are now out of date.
- Layerize splits the display list into layers. The next section explains layers.
- Commit hands the main thread's work over to the compositor thread.
Now a change that needs paint, but not layout. The course changed the background color of all 1,000 boxes, a new color in every frame. Here it is next to the width change:
// Two changes to 1,000 boxes, in every frame for 60 frames:// each one's layout, paint and raster time in all, in ms.const changes = { width: { layout: 37.9, paint: 26.7, raster: 5.3 }, "background-color": { layout: 0, paint: 29.1, raster: 4.8 },}; for (const [change, { layout, paint, raster }] of Object.entries(changes)) { console.log(`${change}: layout ${layout} ms, paint ${paint} ms, raster ${raster} ms`);}- Lines 1 and 2 are comments.
- Line 4 is the width change from the last section. Line 5 is the new change,
background-color. - Lines 8 to 10 print one line for each change.
- The width line is
width: layout 37.9 ms, paint 26.7 ms, raster 5.3 ms. - The color line is
background-color: layout 0 ms, paint 29.1 ms, raster 4.8 ms.
Layout: 0 ms. In 60 frames of new colors, layout never ran once. A color doesn't change any box's size or place, so there was nothing to measure. That's the cushion cover from the room. Paint and raster still ran in every frame, because every box looked different.
Why it matters: if you can show a change with a color, a shadow or a background, you skip layout. web.dev names color, background-image and box-shadow as paint-only properties.
Layers and compositing
So far, paint has written the whole page as one list. But the browser doesn't have to turn it all into one flat picture. It can split the page into layers: parts that are rastered on their own, then stacked on top of each other.
A teacher puts a map on an overhead projector. On a second see-through sheet, laid on top, she has drawn an arrow. To point at a new city, she slides the arrow's sheet. She doesn't draw the map again, or the arrow. The class sees the same two sheets, stacked a little differently.
- In real life: Each see-through sheet
- In the browser: A layer
- In real life: The stack of sheets, as the class sees it
- In the browser: One frame, after compositing
- In real life: Sliding the arrow's sheet
- In the browser: Changing
transformon an element with a layer of its own - In real life: Drawing on a sheet again
- In the browser: Paint and raster, which a slide doesn't need
In Chrome, three steps take the page from one list to a frame on the screen:
- Layerize, on the main thread, splits the display list into layers. RenderingNG, Chrome's own guide to its rendering, says it does this "for independent rasterization and animation."
- Commit hands the layers to the compositor thread. That's the waiter from lesson 6.1, who carries out plates that are already made.
- Compositing: the compositor thread puts the layers together, in the right order, into one frame.
To move a layer, the compositor only changes where it puts it. To fade one, it only changes how see-through it is. Neither needs layout or paint. That's why web.dev says only two properties can be changed by the compositor alone: transform and opacity.
There's a catch. web.dev says an element you change this way "should be on its own compositor layer." Giving an element a layer of its own is called promoting it. The usual way is the CSS property will-change. will-change: transform gives the browser a hint: this element's transform is going to change.
The course slid the 1,000 boxes sideways with transform, set from JavaScript in every frame. Then it did the same on a copy of the page with .box { will-change: transform; } added:
// Sliding 1,000 boxes with transform from JavaScript, for 60 frames.// Layerize, commit and raster in ms: plain boxes, then with will-change.const plain = { layerize: 23.5, commit: 2.4, raster: 13.6 };const promoted = { layerize: 28.4, commit: 23.6, raster: 0 };const total = (boxes) => (boxes.layerize + boxes.commit + boxes.raster).toFixed(1); console.log(`Raster: ${plain.raster} ms plain, ${promoted.raster} ms with will-change`);console.log(`Commit: ${plain.commit} ms plain, ${promoted.commit} ms with will-change`);console.log(`All three: ${total(plain)} ms plain, ${total(promoted)} ms with will-change`);- Lines 1 and 2 are comments.
- Line 3 is the plain boxes. Line 4 is the boxes with
will-change. - Line 5 adds up one page's three steps, to one decimal place.
- Line 7 prints
Raster: 13.6 ms plain, 0 ms with will-change. - Line 8 prints
Commit: 2.4 ms plain, 23.6 ms with will-change. - Line 9 prints
All three: 39.5 ms plain, 52.0 ms with will-change.
Without will-change, raster still ran in every frame. With it, raster never ran at all. Each box had a layer of its own, so the compositor only had to move the layers. That's the arrow's sheet, sliding over the map.
But commit took about ten times as long, 23.6 ms instead of 2.4 ms. There was now a layer for each of the 1,000 boxes to hand over. In all, the three steps took longer with will-change than without it. web.dev warns that "layers require memory and management."
Why it matters: promote the few elements you animate, not every element on the page. web.dev puts it plainly: "Do not promote elements unnecessarily."
Changes that skip steps
Now put all six recordings side by side, and go back to the question from the start of the lesson. Each row of the table is one change to the 1,000 boxes. The middle column lists the main-thread steps that ran in every one of the 60 frames. The last column is the main thread's total time.
| What changed? | Which steps ran in every frame? | How long did the main thread work? |
|---|---|---|
| width | Style, layout, pre-paint, paint, layerize and commit. Raster ran too, on a worker thread. | 157.4 ms. |
| background-color | Style, pre-paint, paint, layerize and commit. Raster ran too. Layout never ran. | 89.9 ms. |
| transform | Style, pre-paint, layerize and commit. Raster ran too. Layout and paint ran once, in the first frame. | 94.9 ms. |
| opacity | Style, pre-paint, layerize and commit. Raster ran too. Layout and paint ran once, in the first frame. | 75.7 ms. |
| transform, with will-change | Style, pre-paint, layerize and commit. Layout, paint and raster never ran. | 117.2 ms. |
| transform, as a CSS animation | None. The main thread worked only when the animation started and when it ended. | 22.6 ms. |
Read down the last column. width cost the most, at 157.4 ms. Skipping layout brought background-color down to 89.9 ms. opacity, which skipped layout and paint, came in at 75.7 ms.
Now look at transform, set from JavaScript: 94.9 ms. That's more than background-color, even though it skipped paint too. Skipping steps didn't make it free. JavaScript still set a new value on 1,000 boxes in every frame. So style still ran in every frame, to work out the new values, and so did pre-paint, layerize and commit.
The last row is different. The CSS animation of transform ran no step in every frame. The main thread worked for 22.6 ms in the whole second, and only when the animation started and when it ended. In between, the compositor thread moved the boxes by itself. RenderingNG says animations that skip layout, pre-paint and paint "can be run entirely on the compositor thread and skip the main thread." It's the waiter from lesson 6.1, sliding plates that are already made.
So here is the answer to the question at the top. Changing width from JavaScript ran every step, in every frame, on the main thread: 157.4 ms. The CSS animation of transform skipped layout and paint, then left the frames to the compositor thread: 22.6 ms.
Why it matters: to move or fade something, change transform or opacity, with a CSS animation when you can. The main thread then stays free for the person's taps and clicks. Pick a change, and compare its steps:
157.4 ms2.6 ms60 times60 times234width, on 1,000 boxes for 60 frames. In every frame: style, layout, pre-paint, paint, layerize and commit. Raster ran too, on a worker thread. The main thread worked for 157.4 ms in all, about 2.6 ms a frame.
Asking for a size too early
There's one more way to give the browser extra work: ask it a question at the wrong moment.
Normally the browser waits. Your JavaScript changes some styles. Then, once, when it gets the next frame ready, the browser runs style and layout. web.dev gives the order: first the JavaScript runs, then style, then layout.
But JavaScript can ask for a size, such as an element's offsetWidth, at any moment. If nothing has changed, the browser answers from the last frame's layout. If a style has changed, that layout is out of date. So the browser has to run style and layout right then, while your code waits. web.dev calls this a forced synchronous layout. Synchronous means "now, before your next line of code runs".
A cook is arranging food on a plate. After each piece, she asks: "How much room is left?" To answer, she has to stop and measure the whole plate again. Ten pieces means ten measurements. A calmer cook measures the plate once, before she starts, then places every piece.
- In real life: Putting one more piece on the plate
- In the browser: A write: changing a style, like
box.style.width - In real life: Measuring how much room is left
- In the browser: A read: asking for a size, like
offsetWidth - In real life: Measuring after every piece
- In the browser: Layout thrashing: a forced layout in every round
- In real life: Measuring once, then placing every piece
- In the browser: All the reads first, then all the writes
Here is the first cook in code. It makes every box 1 px wider:
// Make every box 1 px wider: read its width, then write a new one.for (const box of boxes) { const width = box.offsetWidth; box.style.width = `${width + 1}px`;}- Line 1 is a comment. Line 2 goes through the boxes, one at a time.
- Line 3 reads the box's width. In the first round, that's quick: the browser answers from the last frame's layout.
- Line 4 writes a new width. Now that layout is out of date.
- In the next round, line 3 reads a width again. The browser can't use the old layout, so it runs style and layout first. The same happens in every round after that.
Many forced layouts in a row like this are called layout thrashing. web.dev calls it a "read-write-read-write cycle". The fix is to change the order:
// The same change: all the reads first, then all the writes.const widths = boxes.map((box) => box.offsetWidth);boxes.forEach((box, index) => { box.style.width = `${widths[index] + 1}px`;});- Line 1 is a comment.
- Line 2 reads all 1,000 widths before anything changes. The browser answers every one from the last frame's layout.
- Lines 3 to 5 only write. Each box gets its old width plus 1 px.
- Layout runs once, later, when the browser gets the next frame ready.
Why it matters: in your own code, do all the reads first, then all the writes. web.dev's summary says the same: "read style values then make style changes."
Try it in your own browser. Each button below changes all 1,000 boxes, then reads a size, 20 times over. One button changes the width. The other changes the color. Compare how long each takes:
// Change every box, then read a size, which needs style and layout to be up to date.const started = performance.now();for (let round = 0; round < 20; round += 1) { for (const box of boxes) box.style.width = 10 + (round % 5) + "px"; grid.offsetHeight;}console.log(performance.now() - started);not yetnot yetEach button changes all 1,000 boxes, then reads a size. It does that 20 times. Each read makes the browser bring style, and layout if needed, up to date right then.
Which steps does it run?
For each change, ask one question: does it change a size or a place, only how something looks, or neither? Then sort it by the steps it runs:
- Changing width
- Changing height
- Changing left on a positioned element
- Changing color
- Changing background-color
- Changing box-shadow
- Changing transform
- Changing opacity
Sort each change by the steps it runs.
Common mix-ups
- "transform is free." No. Set from JavaScript on 1,000 boxes, it kept the main thread busy for 94.9 ms in 60 frames. That's more than
background-color. - "will-change makes everything faster." No. It removed raster, but commit grew from 2.4 ms to 23.6 ms. The three steps together took longer than before.
- "Changing a color needs layout." No. In 60 frames of new background colors, layout never ran.
- "A class costs the same wherever you put it." No. A class on the grid restyled 1,001 elements, and took 25 times as long as a class on one box.
- "Paint puts pixels on the screen." No. In Chrome, paint writes a list of drawing commands. Raster makes the pixels, and the GPU process draws the frame.
- "Every animation keeps the main thread busy." No. The CSS animation of
transformused 22.6 ms of the main thread in a whole second. - "Reading a size is harmless." No. Right after a style change, it forces style and layout to run at once.
Practice exercises
Answer first, then use the hints.
The last line of the style example now keeps one decimal place. What does it print?
// Toggling the class warm in every frame, on a page of 1,000 boxes.
// A typical style recalculation: how many elements, and how many ms.
const oneBox = { elements: 1, ms: 0.026 };
const grid = { elements: 1001, ms: 0.654 };
console.log(`On one box: ${oneBox.elements} element restyled in ${oneBox.ms} ms`);
console.log(`On the grid: ${grid.elements} elements restyled in ${grid.ms} ms`);
console.log(`${(grid.ms / oneBox.ms).toFixed(1)} times as long`);25.2 times as long. toFixed(1) keeps one decimal place, where Math.round gave a whole number.
The same width totals are now spread over 30 frames. What does the second line print?
// Changing the width of 1,000 boxes in every frame, for 60 frames.
// Each main-thread step's total time over those frames, in ms.
const width = { style: 31.9, layout: 37.9, prePaint: 36.4, paint: 26.7, layerize: 11.1, commit: 2.4 };
const frames = 30;
for (const [step, ms] of Object.entries(width)) {
console.log(`${step}: ${(ms / frames).toFixed(2)} ms a frame`);
}layout: 1.26 ms a frame. The same work in half the frames means twice the time in each frame.
In the recording of the width change, how many raster tasks ran in the 60 frames?
234: about four raster tasks a frame, all on a worker thread.
Even the CSS animation did some work on the main thread. Which step took the most time?
Style, at 14.2 ms. The browser worked out the boxes' styles when the animation started and when it ended. In between, the compositor thread did the work.
Which option on the Rendering tab of DevTools shows where the page paints again?
Paint flashing. Chrome flashes green wherever it paints again.
Which insight in DevTools' Performance panel points at forced synchronous layouts?
Forced reflow. It points at the code that asked for a size right after a style change.
Check your understanding
For each question, ask which steps the change needs: does it change a size or a place, only how something looks, or neither?
Question 1 of 7What does the last line of the style example print?
Read the code, then predict// Toggling the class warm in every frame, on a page of 1,000 boxes. // A typical style recalculation: how many elements, and how many ms. const oneBox = { elements: 1, ms: 0.026 }; const grid = { elements: 1001, ms: 0.654 }; console.log(`On one box: ${oneBox.elements} element restyled in ${oneBox.ms} ms`); console.log(`On the grid: ${grid.elements} elements restyled in ${grid.ms} ms`); console.log(`${Math.round(grid.ms / oneBox.ms)} times as long`);Choose an answer to see the explanation.
Question 2 of 7Which steps does a change to
widthrun?Choose an answer to see the explanation.
Question 3 of 7In Chrome, what does the paint step make?
Choose an answer to see the explanation.
Question 4 of 7What does the last line of the layers example print?
Read the code, then predict// Sliding 1,000 boxes with transform from JavaScript, for 60 frames. // Layerize, commit and raster in ms: plain boxes, then with will-change. const plain = { layerize: 23.5, commit: 2.4, raster: 13.6 }; const promoted = { layerize: 28.4, commit: 23.6, raster: 0 }; const total = (boxes) => (boxes.layerize + boxes.commit + boxes.raster).toFixed(1); console.log(`Raster: ${plain.raster} ms plain, ${promoted.raster} ms with will-change`); console.log(`Commit: ${plain.commit} ms plain, ${promoted.commit} ms with will-change`); console.log(`All three: ${total(plain)} ms plain, ${total(promoted)} ms with will-change`);Choose an answer to see the explanation.
Question 5 of 7In the recordings, what did
will-changetrade?Choose an answer to see the explanation.
Question 6 of 7Why did the CSS animation of
transformleave the main thread almost idle?Choose an answer to see the explanation.
Question 7 of 7A loop reads each box's
offsetWidth, then changes that box's width. What happens on a page of many boxes?Choose an answer to see the explanation.
Key takeaways
- Every change starts at style. The more elements a change reaches, the longer style takes.
- A change of size or place, like width, runs layout and every step after it.
- A change of look, like a color, skips layout.
- In Chrome, paint writes a list of drawing commands. Raster turns it into pixels, on worker threads.
- transform and opacity skip layout and paint. As a CSS animation, they barely touch the main thread.
- will-change saves raster, but adds layers to manage. Promote what moves, not everything.
- Reading a size right after a change forces style and layout at once. Do all the reads first, then all the writes.
Remember the one-liner.
Every change starts at style; pick changes that skip layout and paint, and the main thread has less to do.
Sources
The facts on this page come from these docs, standards and articles:
- Rendering performance, Paul Lewis, web.dev.
- Reduce the scope and complexity of style calculations, Jeremy Wagner and Paul Lewis, web.dev.
- Avoid large, complex layouts and layout thrashing, Jeremy Wagner, Paul Lewis and Barry Pollard, web.dev.
- CSS Flexible Box Layout Module Level 1: collecting items into lines, W3C.
- RenderingNG architecture, Chris Harrelson, Chrome for Developers.
- Stick to compositor-only properties and manage layer count, Paul Lewis, web.dev.
- will-change, MDN Web Docs.
- Discover issues with rendering performance, Chrome DevTools.
- Forced reflow insight, Connor Clark, Chrome for Developers.
Coming next: The cost of JavaScript, also in How the browser loads and renders. Why a kilobyte of script costs far more than a kilobyte of image: download, parse, compile and run.