Free lesson · Frontend System Design
Why does one animation stutter and another stays silk-smooth?
Take two animations that look almost identical to a user. One slides a menu in by animating its left property from -300px to 0. The other slides it in by animating transform: translateX(-300px) to translateX(0). Same visual result. On a mid-range phone, the first one stutters and tears; the second one glides at 60 frames per second.
The code is nearly identical. The difference is which stage of the rendering pipeline each one forces the browser to redo - and that is the entire subject of this lesson. Once the page has loaded, every change you make to it re-triggers some portion of the pipeline you learned about earlier. The skill that separates engineers who build smooth interfaces from those who ship janky ones is knowing exactly how much of the pipeline each change costs. There are three tiers, and they differ by orders of magnitude.
The three tiers of change
After the initial render, any visual update falls into one of three buckets, from most expensive to least.
graph TD
Change[You change a style or the DOM] --> Q1{Does it change<br/>geometry?<br/>size, position, layout}
Q1 -->|yes| REFLOW[REFLOW + repaint + composite<br/>most expensive<br/>e.g. width, height, top, font-size,<br/>adding/removing nodes]
Q1 -->|no| Q2{Does it change<br/>appearance only?<br/>color, shadow, background}
Q2 -->|yes| REPAINT[REPAINT + composite<br/>medium cost<br/>e.g. color, background, visibility]
Q2 -->|no, layer transform only| COMPOSITE[COMPOSITE only<br/>cheapest, GPU<br/>e.g. transform, opacity]
This decision flowchart shows the three tiers of cost for a visual change. When you change a style or the DOM, first ask whether it changes geometry, meaning size, position, or layout. If yes, it triggers a reflow plus repaint plus composite, the most expensive path, caused by properties like width, height, top, font-size, or adding and removing nodes. If it doesn't change geometry, ask whether it changes appearance only, like color, shadow, or background. If yes, it triggers a repaint plus composite, a medium cost. If it changes neither geometry nor appearance but only a layer transform, like transform or opacity, it triggers a composite only, the cheapest path, handled by the GPU.
Tier 1 - Reflow (layout)
A reflow happens when you change something that affects geometry - the size or position of elements. The browser has to recompute the layout: where every affected element sits and how big it is. The properties that trigger it are the ones that move boxes around: width, height, top, left, margin, padding, font-size, display, and structural changes like adding or removing DOM nodes.
Reflow is the most expensive tier because geometry is relational. Change the height of one element and everything below it may shift; change the width of a flex container and all its children may need re-measuring. A reflow can cascade across a large subtree, or in the worst case the whole document. This is why "just tweak the width in a loop" can tank your frame rate.
Tier 2 - Repaint
A repaint happens when you change something visual that doesn't affect geometry - color, background-color, box-shadow, visibility, outline. The element's box stays exactly where it is and the same size; only its pixels change. The browser skips layout (it already knows the geometry) and goes straight to repainting the affected area. Cheaper than reflow, but not free - a large repaint area, or expensive paint operations like big blurred shadows, can still cost real milliseconds.
Tier 3 - Composite only
The cheapest changes touch only the compositing stage. transform and opacity are the famous two: if an element is on its own layer, the browser can move it, scale it, rotate it, or fade it by manipulating that layer on the GPU, without redoing layout or paint. The pixels were already painted; the compositor just repositions or blends the existing layer. This is why transform and opacity are the golden properties for animation - they're the only common properties that animate without ever touching the expensive stages.
| Tier | Triggers | Pipeline stages re-run | Relative cost |
|---|---|---|---|
| Reflow | width, height, top/left, margin, font-size, DOM add/remove | layout → paint → composite | highest |
| Repaint | color, background, box-shadow, visibility | paint → composite | medium |
| Composite | transform, opacity (on a layer) | composite only (GPU) | lowest |
Important: The single most useful rule that falls out of this: animate with
transformandopacity, never withtop/left/width/height. Animating position withleftforces a reflow on every frame - sixty reflows a second - which is exactly how you get jank. Animating withtransformstays in the composite tier and runs on the GPU. If you take one actionable thing from this whole module, take this.
Layout thrashing: the bug that hides in innocent code
Knowing reflow is expensive isn't enough, because there's a trap that turns one unavoidable reflow into dozens of forced ones, and it hides in code that looks completely reasonable. It's called layout thrashing (or forced synchronous layout), and it's caused by interleaving reads and writes of layout properties.
Here's the mechanism. The browser is lazy and smart: it batches your style changes and does the reflow once, later, when it needs to (right before the next paint). So if you write ten style changes in a row, you get one reflow, not ten. Efficient.
But - and this is the trap - if you read a layout property like offsetHeight, offsetWidth, getBoundingClientRect(), or scrollTop, the browser is forced to give you an accurate, up-to-date answer. If there are pending writes in its queue, it must flush them and reflow right now to compute the correct value. So a read after a write forces a synchronous reflow on the spot.
Now picture a loop that reads a width and then sets a width, for each item in a list:
// BAD - layout thrashing: read, write, read, write...
for (const box of boxes) {
const width = box.offsetWidth; // READ → forces reflow (flush pending writes)
box.style.width = width + 10 + 'px'; // WRITE → queues a change
}
// Each iteration: the read forces a reflow of the write from the PREVIOUS iteration.
// 100 boxes = ~100 forced synchronous reflows.
Each read forces the browser to flush the previous iteration's write, reflowing on every single pass. A hundred boxes becomes a hundred reflows, and the loop crawls.
The fix is batching: read everything first, then write everything. Separate the phases so all the reads happen against a stable layout, then all the writes queue up and reflow once.
// GOOD - batched: all reads, then all writes → one reflow
const widths = boxes.map(box => box.offsetWidth); // all READS together
boxes.forEach((box, i) => { // all WRITES together
box.style.width = widths[i] + 10 + 'px';
});
// The browser batches the writes into a single reflow before the next paint.
sequenceDiagram
participant JS as Your Code
participant B as Browser Layout
Note over JS,B: BAD - interleaved read/write
JS->>B: read offsetWidth
B->>B: forced reflow (flush writes)
JS->>B: write width
JS->>B: read offsetWidth
B->>B: forced reflow again
JS->>B: write width
Note over JS,B: GOOD - batched
JS->>B: read all widths
B->>B: (one consistent layout)
JS->>B: write all widths
B->>B: single reflow before next paint
This sequence diagram contrasts layout thrashing with batching. In the bad case, your code interleaves reads and writes: it reads offsetWidth, which forces the browser to reflow to flush pending writes, then writes a width, then reads again, forcing another reflow, then writes again. Each read triggers a forced synchronous reflow. In the good case, your code reads all the widths first against one consistent layout, then writes all the widths together, and the browser performs a single reflow before the next paint.
Warning: Layout thrashing is invisible in code review because each line looks fine - there's no obvious "slow" call. It only shows up as jank under load, on a list with many items or in an animation loop. The tell is the pattern: reading a layout property inside a loop that also writes styles. Train your eye to spot read-then-write interleaving, because no linter will catch it for you.
How this connects to frameworks
You might be thinking: I use React, I don't write offsetWidth loops. Two things. First, you will eventually - measuring an element to position a tooltip, syncing scroll positions, building a virtualized list - and when you do, the rules above are exactly what keep it smooth. Second, this is why the Virtual DOM exists, which is our literal next lesson. React batches DOM changes precisely to avoid handing the browser an interleaved mess of reads and writes. It computes all the changes in memory, then applies them together so the browser can do one efficient reflow. The framework is automating the "read all, then write all" discipline for you - but only for DOM structure. The moment you reach for a layout measurement, you're back to doing it by hand, and you'd better know the rules.
Interview: Strong things to say when performance comes up: "For animations I stick to
transformandopacityso the work stays on the compositor and off the main thread - animating layout properties liketoporwidthforces a reflow every frame." And if you can name layout thrashing - "I'd batch DOM reads and writes to avoid forced synchronous layouts; reading something likegetBoundingClientRectbetween writes makes the browser reflow repeatedly" - you've demonstrated the kind of rendering-internals knowledge that reads as genuinely senior, because it only comes from having debugged a janky interface for real.
A quick word on will-change and layers
You can hint to the browser that an element will be animated by giving it will-change: transform, which promotes it to its own compositor layer ahead of time, so the first frame of the animation isn't spent doing the promotion. It's a useful tool - but a sharp one. Every layer costs memory, and promoting everything "just in case" can use more memory than it saves and actually hurt performance. Use it deliberately on elements you're about to animate, and remove it when you're done. The senior signal isn't knowing will-change exists; it's knowing it's a tradeoff, not a free speed-up.
Scheduling work the browser-friendly way
Knowing what causes reflow is half of it; the other half is knowing when to do your DOM work so it lines up with the browser's own rhythm instead of fighting it. There are a few tools that exist precisely for this.
requestAnimationFrame schedules a callback to run right before the next paint. This is where visual updates belong, because the browser is about to reflow and paint anyway - so a write you queue here gets folded into that upcoming frame instead of forcing an extra one. The classic pattern for smooth animation is: do all your reads, then schedule all your writes inside a requestAnimationFrame callback, so the writes land in one clean batch at the natural moment. Setting style changes in a tight setTimeout or a raw loop, by contrast, fights the browser's frame schedule and invites jank.
IntersectionObserver exists so you don't have to repeatedly read an element's position to know when it enters the viewport. The naive approach - listening to scroll and calling getBoundingClientRect() on every event - is a layout-thrashing machine: it forces a synchronous reflow on every scroll tick. IntersectionObserver lets the browser tell you when visibility changes, asynchronously, with no forced reads. It's the right tool for lazy-loading images, infinite scroll, and "animate when it scrolls into view" - and reaching for it instead of scroll-plus-measure is a clear signal you understand the reflow cost of reading layout.
ResizeObserver plays the same role for size changes: instead of polling an element's dimensions, you're notified when they change, again without forced synchronous reads. Both observers are the browser's answer to "stop measuring in a loop."
Tip: A useful heuristic: any time you find yourself reading a layout value (
offsetWidth,getBoundingClientRect,scrollTop) inside something that runs frequently - a scroll handler, a resize handler, an animation loop - pause and ask whether an observer API can hand you that information asynchronously instead. Nine times out of ten it can, and you've just deleted a source of forced reflows you didn't know you had. The same instinct applies to CSS: prefer animating properties the compositor can handle alone, and when you must change layout, change it on as few elements as possible and as high up the tree as makes sense, so the reflow's blast radius stays small.
Debugging jank: where to actually look
Let me make this concrete with the workflow you'd use to find one of these problems, because "avoid reflow" is advice and "here's how I'd locate the reflow" is a skill. In the Performance panel of DevTools, you record an interaction and look at the main-thread timeline. Reflows show up as "Layout" events; forced synchronous layouts get specifically flagged, often with a warning triangle, because the browser knows you made it reflow at an inconvenient time. Long purple "Recalculate Style" and "Layout" bars stacked during a scroll or animation are the visual signature of thrashing.
The diagnostic story usually goes: the timeline shows the frame rate dropping during an interaction; you zoom into a slow frame; you see a tall stack of repeated Layout events where there should be one; you click one and DevTools points you at the exact line of JavaScript that forced it - invariably a layout read sitting between writes. Then you apply the fix from this lesson: batch the reads, batch the writes, or move the measurement to an observer. Being able to describe that loop - record, find the slow frame, identify the forced layout, trace it to the read - is what "I know how to debug performance" actually means, versus just asserting it.
Note: This connects forward to the whole Performance module, where we'll live in these tools properly. The reason it belongs here, in the rendering-internals lesson, is that the tools are only legible once you understand the pipeline. A "Layout" event in the timeline means nothing if you don't know what layout is. Now you do - so the profiler stops being a wall of colored bars and becomes a map of the pipeline you've just learned, with timings attached.
What you should carry out of this lesson
Every post-load change costs one of three tiers: reflow (geometry - most expensive, cascades), repaint (appearance - medium), or composite-only (transform/opacity on a layer - cheapest, GPU). Animate with the cheap tier. Avoid layout thrashing by separating all your DOM reads from all your DOM writes, because reading a layout property forces the browser to flush pending writes and reflow synchronously. And treat will-change and layer promotion as tradeoffs against memory, not free wins.
That last point - that frameworks batch DOM work to avoid handing the browser an inefficient sequence of changes - is the perfect bridge to the next batch of lessons. We've been talking about the real DOM and the cost of touching it. Next, we look at the abstractions built on top of it: the Virtual DOM that React popularized to make those updates cheap, and the Shadow DOM that powers web components - and why "DOM" turns out to mean three quite different things.