Lesson 16 / 23
Animation Performance
Motion's counterpart in the rendering pipeline; which property change reruns the layout, paint, and compositing stages, what a compositing layer is, forced synchronous layout, and the frame budget.
Contents
The previous three lessons defined motion: transform gave the geometry, transitions and keyframes gave the time. The cost was never mentioned.
There are two ways to move a card to the right: increasing the left value, or writing a
translation into the transform list. On screen, the two look the same. For the browser,
they are not the same work. This lesson establishes where the difference comes from, as a
rule.
The Rendering Pipeline
A page reaching the screen goes through ordered stages. Four of them matter for motion:
- Style calculation. Which declaration applies to which element is resolved; this is where the cascade and inheritance work. Every change runs this stage.
- Layout. Every box’s size and place is computed. If a box’s width changes, its siblings, its parent, and the flow after it can be affected; the computation may not stay local.
- Paint. A box’s content — color, border, shadow, text — is turned into pixels.
- Composite. Painted surfaces are ordered, transformed, and placed on the screen.
The stages are sequential: if layout reruns, paint and compositing rerun too. But the reverse does not hold; compositing can run on its own.
Which Property Triggers Which Stage
// performance.mjs — which pipeline stages a property change triggers, and forced layout count const PIPELINE = ["style", "layout", "paint", "composite"]; // Rule: from which stage does a property rerun? const START = { width: "layout", height: "layout", "margin-block-start": "layout", padding: "layout", "border-width": "layout", "font-size": "layout", "line-height": "layout", top: "layout", left: "layout", display: "layout", "flex-basis": "layout", color: "paint", "background-color": "paint", "background-image": "paint", "border-radius": "paint", "box-shadow": "paint", outline: "paint", visibility: "paint", "text-decoration": "paint", transform: "composite", opacity: "composite", }; console.log("--- from which stage a property change reruns ---"); console.log("property".padEnd(20) + PIPELINE.map((a) => a.padStart(10)).join("") + " stage count"); for (const [property, start] of Object.entries(START)) { const i = PIPELINE.indexOf(start); const mark = PIPELINE.map((_, k) => (k === 0 || k >= i ? "x" : "-").padStart(10)).join(""); console.log(property.padEnd(20) + mark + String(PIPELINE.length - i + (i > 0 ? 1 : 0)).padStart(15)); } console.log("\n--- two notations giving the same visual result ---"); for (const [a, b] of [["left", "transform"], ["width", "transform"], ["visibility", "opacity"]]) { const ia = PIPELINE.indexOf(START[a]), ib = PIPELINE.indexOf(START[b]); console.log( `${a.padEnd(12)} -> ${PIPELINE.slice(ia).join(" > ").padEnd(30)}` + `${b.padEnd(12)} -> ${PIPELINE.slice(ib).join(" > ")}`, ); } // --- forced synchronous layout count --- // Model: a write operation invalidates layout; a subsequent size read forces layout // to be recomputed to read the invalidated value. One computation happens at frame end anyway. function layoutCount(operations) { let dirty = false, forced = 0; const trace = []; for (const [kind, name] of operations) { if (kind === "write") { dirty = true; trace.push(`write ${name}`); } else { if (dirty) { forced++; dirty = false; trace.push(`read ${name} -> LAYOUT COMPUTED`); } else trace.push(`read ${name}`); } } return { forced, frameEnd: dirty ? 1 : 0, trace }; } const INTERLEAVED = [ ["read", "width(card 1)"], ["write", "width(card 1)"], ["read", "width(card 2)"], ["write", "width(card 2)"], ["read", "width(card 3)"], ["write", "width(card 3)"], ]; const BATCHED = [ ["read", "width(card 1)"], ["read", "width(card 2)"], ["read", "width(card 3)"], ["write", "width(card 1)"], ["write", "width(card 2)"], ["write", "width(card 3)"], ]; for (const [name, operations] of [["reads and writes interleaved", INTERLEAVED], ["reads first, then writes", BATCHED]]) { const s = layoutCount(operations); console.log(`\n--- ${name} ---`); for (const line of s.trace) console.log(" " + line); console.log(` forced layout computations: ${s.forced}, computation at frame end: ${s.frameEnd}`); } console.log("\n--- frame budget (arithmetic from refresh rate) ---"); for (const hz of [60, 90, 120, 144]) { console.log(`${String(hz).padStart(3)} fps -> ${(1000 / hz).toFixed(2)} ms per frame`); }
--- from which stage a property change reruns --- property style layout paint composite stage count width x x x x 4 height x x x x 4 margin-block-start x x x x 4 padding x x x x 4 border-width x x x x 4 font-size x x x x 4 line-height x x x x 4 top x x x x 4 left x x x x 4 display x x x x 4 flex-basis x x x x 4 color x - x x 3 background-color x - x x 3 background-image x - x x 3 border-radius x - x x 3 box-shadow x - x x 3 outline x - x x 3 visibility x - x x 3 text-decoration x - x x 3 transform x - - x 2 opacity x - - x 2 --- two notations giving the same visual result --- left -> layout > paint > composite transform -> composite width -> layout > paint > composite transform -> composite visibility -> paint > composite opacity -> composite --- reads and writes interleaved --- read width(card 1) write width(card 1) read width(card 2) -> LAYOUT COMPUTED write width(card 2) read width(card 3) -> LAYOUT COMPUTED write width(card 3) forced layout computations: 2, computation at frame end: 1 --- reads first, then writes --- read width(card 1) read width(card 2) read width(card 3) write width(card 1) write width(card 2) write width(card 3) forced layout computations: 0, computation at frame end: 1 --- frame budget (arithmetic from refresh rate) --- 60 fps -> 16.67 ms per frame 90 fps -> 11.11 ms per frame 120 fps -> 8.33 ms per frame 144 fps -> 6.94 ms per frame
The table is not a measurement, it is a rule set: properties split into three groups by their definition. Ones that touch geometry run from layout onward, ones that touch only appearance run from paint onward, and ones that change neither the box’s size nor its content run from compositing onward alone.
A handful of properties sit at the edge of the classification — filters and blend modes, for instance — and which stage they start from depends on the rendering implementation. The exact information is read from the browser’s developer tools rendering log; it should not be memorized as a rule.
Same Appearance, Different Cost
The output’s second block compares three pairs. Moving with left runs three stages,
moving with transform runs one. Hiding with visibility reruns paint, fading with
opacity does not.
From this comes the most practical rule of writing motion: animations are written
through transform and opacity. If a box needs to grow, scale is used instead of
width; if it needs to change place, translate is used instead of left.
The rule has a cost. scale also scales the box’s content; text grows as the box grows.
Changing width re-wraps the text into new lines. The two do not give the same visual
result, and the choice depends on which result is wanted.
The Compositing Layer
The reason a transform is cheap is where it is applied. A painted surface is produced once; transform and opacity are applied to that surface at the compositing stage. The surface is not repainted, it is only placed differently.
For this, the element needs to be split off onto its own surface — a compositing
layer. The browser decides on this split on its own; the will-change declaration is a
way to announce that decision ahead of time:
.measurement-cards > .card:hover { will-change: transform; }
will-change is not a guarantee, it is a hint. It has three rules. Splitting off a
layer costs memory; writing it on every element reverses the benefit. The declaration
should be turned on shortly before motion starts and removed once it finishes — a hint
written permanently is a permanent cost. And will-change also establishes a stacking
context; the warning from the first lesson applies here too.
Forced Synchronous Layout
The output’s third and fourth blocks count, on a model, a trap that script-driven motion often falls into. The model is this: a write operation invalidates layout; a subsequent size-reading operation, since it has to give the current value, forces layout to be recomputed immediately. This is called forced synchronous layout.
When reads and writes are interleaved, the model counts two extra computations. When the same operations are ordered as all reads first, then all writes, there is no extra computation; the single computation that would happen at frame’s end anyway is enough.
The rule is independent of notation: operations that read a measurement are grouped into one batch, operations that change a measurement into a separate batch. As the number of batches grows, so does the number of computations.
Frame Budget
The last block is an arithmetic conversion: on a screen producing 60 frames per second, the time per frame is milliseconds. This is the budget shared by every stage of a frame — script, style, layout, paint, composite.
Once the budget is exceeded, a frame is dropped and the motion stutters. This is why the question to ask when writing motion is not “how long does it take” but “which stages run per frame.” Reducing the stage count is a more reliable path than trying to estimate duration.
Refresh rate varies by screen; the same animation runs under different budgets. Motion written to depend on frame count speeds up on a fast screen. Declarations written in terms of duration do not carry this problem.
Limiting the Paint Area
An element being repainted can extend to its neighbors too; the paint area can be larger
than the element. The contain declaration limits this spread: contain: layout declares
that an element’s layout computation will not spill outward, contain: paint that
painting will not extend outside the box.
This declaration makes a promise; content that does not keep the promise breaks. A shadow
or a dropdown menu overflowing a box carrying the paint value is clipped. The declaration
is written where a box is genuinely self-contained.
Measurement on the Station Page
/* motion.css — step 4: limiting motion's rendering cost */ .measurement-cards > .card { transform-origin: center bottom; transition: transform 240ms ease-out, box-shadow 240ms ease-out; contain: layout paint; } .measurement-cards > .card:hover, .measurement-cards > .card:focus-within { transform: translateY(-8px) scale(1.04); transition-duration: 160ms; } .station-status[data-status="waiting"] .indicator { animation: pulse 1400ms 200ms infinite alternate; will-change: transform, opacity; }
The card’s motion is written only through transform; the contain declaration limits the
card’s layout and paint effect to its own box. Because the indicator moves continuously,
the will-change declaration is written there permanently — permanent motion is one of
the rare cases that justifies a permanent layer.
The shadow transition is a trade-off: box-shadow reruns paint. The same appearance can
also be reached with the opacity of a separate pseudo-element placed behind the card, and
that notation runs only compositing.
Summary
- The rendering pipeline is sequential: if layout runs, paint and compositing run too, but compositing can run on its own.
- Properties that touch geometry run from layout, ones that touch only appearance from paint, transform and opacity from compositing alone.
- When motion is written through transform and opacity, the stage count drops; measurement-changing notations produce the same appearance with more work.
- A compositing layer is the reason a transform is cheap;
will-changeis a hint that announces this split and is used temporarily because it costs memory. - When measurement reads and measurement writes are interleaved, forced synchronous layout computations form; batching reads and writes removes this.
Next Step
Up to this point, how motion is written and what it costs has been established. One question remains: is motion the same thing for every user? For some users, motion on screen produces discomfort, dizziness, or nausea, and the operating system announces this preference. The next lesson covers what the reduced-motion preference means and which motion should be removed, which one kept.
To keep your progress and take notes, Log in
My notes
Log in to take notes.