Layout Thrashing
โฑ๏ธ ~3-minute bite ยท solve the sandbox to master
5-Year-Old Metaphor
โ The physical, real-world picture. No jargon.๐ Layout thrashing = asking a librarian to sort the library, then asking for a book, then sorting again. Each question interrupts the sort. Batch all questions before asking them to start sorting.
The library analogy
The librarian (browser layout engine) sorts all books (calculates layout) in one efficient batch. While sorting, they cannot be interrupted โ they must finish the sort before answering any question.
Asking "where is book X?" (reading offsetHeight) after moving a shelf (writing style.height) forces the librarian to stop, re-sort everything, then answer you. Then you move another shelf and ask again โ 100 interruptions = 100 full sorts.
The fix: hand over your entire list of questions before any shelves are moved. The librarian answers all questions (one sort), then you move all the shelves (one dirty mark). Only one sort total.
The rendering pipeline
๐Layout
Calculates the size and position of every element. Most expensive phase.
width, height, top, left, margin, padding, offsetHeight, getBoundingClientRect
๐๏ธPaint
Fills in pixels: colors, borders, shadows. Skips layout if no geometry changed.
background, color, border, box-shadow, outline
๐จComposite
GPU assembles pre-painted layers. Runs on compositor thread โ never blocks main thread.
transform, opacity
ASCII: read-write batching pattern
| 1 | โ THRASHING (n layouts for n elements): |
| 2 | โโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโ |
| 3 | โ read[0] โ โ โ LAYOUT SYNC โ โ โ write[0] โ |
| 4 | โโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโ |
| 5 | โโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโ |
| 6 | โ read[1] โ โ โ LAYOUT SYNC โ โ โ write[1] โ |
| 7 | โโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโ |
| 8 | ... ร n elements = n synchronous layouts |
| 9 | ย |
| 10 | โ BATCHED (1 layout for n elements): |
| 11 | โโโโโโโโโโโโโโ โโโโโโโโโโโโโโ โโโโโโโโโโโโโโ |
| 12 | โ read[0] โ โ read[1] โ โ read[n] โ โ 1 layout |
| 13 | โโโโโโโโโโโโโโ โโโโโโโโโโโโโโ โโโโโโโโโโโโโโ |
| 14 | โโโโโโโโโโโโโโ โโโโโโโโโโโโโโ โโโโโโโโโโโโโโ |
| 15 | โ write[0] โ โ write[1] โ โ write[n] โ โ dirty once |
| 16 | โโโโโโโโโโโโโโ โโโโโโโโโโโโโโ โโโโโโโโโโโโโโ |
| 17 | โ layout at frame boundary |
Layout-triggering properties to memorize
Interactive Sandbox
โ Move something, see it react instantly.Pattern
Operation sequence โ step 1/5
| 1 | // โ Read-write-read loop (100 elements = 100 layouts!) |
| 2 | for (const el of elements) { |
| 3 | const h = el.offsetHeight; // forced layout if dirty |
| 4 | el.style.height = h + 10 + 'px'; // marks layout dirty |
| 5 | } |
Challenge
Step through all 5 patterns to the final step. Notice how forced layouts accumulate โ then see how batching eliminates them entirely.
Why Should I Care?
โ The exact interview question + the bug it kills.Exact interview questions
Q: What CSS properties trigger layout vs paint vs composite?
Layout (reflow): anything that changes geometry โ width, height, top, left, margin, padding, font-size. Also JS reads: offsetHeight, getBoundingClientRect, scrollTop. Every element that depends on the changed element must be recalculated.
Paint only:visual properties that don't change geometry โ background-color, color, border-radius, box-shadow, outline.
Composite only: transform and opacity. These run on the GPU compositor thread, bypassing both layout and paint on the main thread. Always prefer these for animations.
| 1 | // csstriggers.com โ full property table |
| 2 | // layout โ paint โ composite (all 3 phases) |
| 3 | el.style.width = '100px'; |
| 4 | ย |
| 5 | // paint โ composite (skips layout) |
| 6 | el.style.backgroundColor = 'red'; |
| 7 | ย |
| 8 | // composite only (GPU, skips layout + paint) |
| 9 | el.style.transform = 'translateX(100px)'; |
| 10 | el.style.opacity = '0.5'; |
Q: How does requestAnimationFrame help prevent thrashing?
rAF callbacks run at the start of each frame, after the browser has finished any pending layout from the previous frame. By putting all your reads and writes inside a rAF callback, you guarantee you're reading against a clean, non-dirty layout. Additionally, rAF naturally groups all work for a given frame into one batch, so multiple components calling rAF on the same frame can be coalesced.
| 1 | // Each rAF tick: layout is clean at start of frame |
| 2 | requestAnimationFrame(() => { |
| 3 | // Safe to read โ layout is fresh |
| 4 | const h = el.offsetHeight; |
| 5 | // Write โ marks dirty, but no one reads until next frame |
| 6 | el.style.height = (h + 1) + 'px'; |
| 7 | }); |
Q: What is the difference between reflow and repaint?
Reflow (layout):the browser recalculates the geometry of elements โ position, size, and how they affect adjacent elements. It's expensive because changing one element can cascade to recalculate the entire document tree.
Repaint (paint):the browser redraws pixels for visual changes that don't affect geometry. Cheaper than reflow but still uses the main thread.
| 1 | // Reflow: changes geometry, recalculates all affected elements |
| 2 | el.style.width = '200px'; // โ layout โ paint โ composite |
| 3 | ย |
| 4 | // Repaint only: visual change, geometry unchanged |
| 5 | el.style.color = 'red'; // โ paint โ composite (no layout) |
| 6 | ย |
| 7 | // Neither reflow nor repaint: compositor thread only |
| 8 | el.style.opacity = '0.8'; // โ composite only (GPU) |
Production patterns for avoiding thrashing
Pattern: CSS containment
contain: layout tells the browser that changes inside this element cannot affect layout outside it. This scopes reflow to a subtree, dramatically reducing the blast radius of any forced layout.
| 1 | /* Scopes layout recalculation to this element */ |
| 2 | .card { |
| 3 | contain: layout; |
| 4 | } |
| 5 | /* Changing .card's children never triggers |
| 6 | layout recalculation outside .card */ |
Pattern: ResizeObserver instead of resize event
The window resize event fires on every pixel change, triggering handler code that often reads layout properties. ResizeObserver fires after layout has settled for a given element โ reads inside it are against a clean layout.
| 1 | // โ resize event โ fires constantly, triggers layout reads |
| 2 | window.addEventListener('resize', () => { |
| 3 | const w = el.offsetWidth; // forced layout mid-frame |
| 4 | updateChart(w); |
| 5 | }); |
| 6 | ย |
| 7 | // โ ResizeObserver โ fires after layout settled |
| 8 | const ro = new ResizeObserver(entries => { |
| 9 | const { width } = entries[0].contentRect; // clean read |
| 10 | updateChart(width); |
| 11 | }); |
| 12 | ro.observe(el); |
The Deep Dive
โ Spec refs, engine internals, the minutiae.Browser rendering pipeline โ full detail
| 1 | JavaScript โ Style โ Layout โ Paint โ Composite |
| 2 | โ โ |
| 3 | (your code) (forced if read |
| 4 | after write) |
| 5 | ย |
| 6 | Each phase can be skipped if upstream didn't change: |
| 7 | - Only composite changed โ skip JS, Style, Layout, Paint |
| 8 | - Only paint changed โ skip Layout |
| 9 | - Layout changed โ must do all downstream phases |
The csstriggers.com table lists every CSS property and which phases it triggers in Blink, Gecko, and WebKit. Bookmark it โ it's the authoritative reference.
will-change: transform โ GPU layer creation
Adding will-change: transform (or transform: translateZ(0) as the old hack) promotes an element to its own compositor layer. This means the GPU can animate it without touching the main thread โ zero-cost animation.
| 1 | /* Promote to GPU layer BEFORE animation starts */ |
| 2 | .animated-element { |
| 3 | will-change: transform; |
| 4 | } |
| 5 | /* Now transform animations are compositor-only */ |
| 6 | .animated-element:hover { |
| 7 | transform: scale(1.05); |
| 8 | transition: transform 0.2s ease; |
| 9 | } |
| 10 | ย |
| 11 | /* โ ๏ธ Don't apply to everything โ GPU memory is finite */ |
| 12 | /* Each layer uses VRAM proportional to element area */ |
| 13 | /* Guideline: only elements that actually animate */ |
IntersectionObserver for virtual scroll
IntersectionObserver reports when elements enter/leave the viewport without triggering layout. It's the correct way to implement lazy loading and infinite scroll โ much cheaper than reading scrollTop in a scroll handler.
| 1 | // โ scroll handler reads layout every frame |
| 2 | window.addEventListener('scroll', () => { |
| 3 | const rect = el.getBoundingClientRect(); // forced layout! |
| 4 | if (rect.top < window.innerHeight) loadMore(); |
| 5 | }); |
| 6 | ย |
| 7 | // โ IntersectionObserver โ no layout triggered |
| 8 | const observer = new IntersectionObserver(([entry]) => { |
| 9 | if (entry.isIntersecting) loadMore(); |
| 10 | }, { rootMargin: '200px' }); // 200px before viewport edge |
| 11 | observer.observe(sentinel); // sentinel = last list item |
CSS containment โ scoping layout blast radius
The contain property tells the browser that a subtree is independent. Four values, each scoping a different phase:
| Value | What it scopes |
|---|---|
| contain: layout | Layout recalculation cannot escape this element |
| contain: paint | Painting is clipped to this element's border box |
| contain: size | Element size doesn't depend on children |
| contain: strict | All of the above โ maximum isolation |
| content-visibility: auto | Skip rendering off-screen elements entirely (Chrome 85+) |
ResizeObserver vs window resize event
ResizeObserver fires after layout has settled for an element โ its callback is called during the "update the rendering" step, after layout and before paint. The entries provide contentRect with the new dimensions โ no reads needed, no layout triggered.
| 1 | const ro = new ResizeObserver(entries => { |
| 2 | for (const entry of entries) { |
| 3 | // contentRect is provided โ no getBoundingClientRect needed |
| 4 | const { width, height } = entry.contentRect; |
| 5 | // Also available: entry.borderBoxSize, entry.contentBoxSize |
| 6 | console.log(`${entry.target.id}: ${width}ร${height}`); |
| 7 | } |
| 8 | }); |
| 9 | ro.observe(document.getElementById('chart')); |
Interview Questions
โ Real questions from real interviews โ with answers.Reading a layout property after writing a style forces a synchronous layout recalculation, instead of letting the browser batch it at frame end.
offsetHeight/Width, offsetTop/Left, scrollTop/Left, getBoundingClientRect, clientWidth/Height, getComputedStyle, scrollWidth/Height.
rAF callbacks run at the start of the next frame after the browser has finished any pending layout, giving you a clean baseline for reads.
FastDOM queues reads to a readQueue and writes to a writeQueue, then flushes reads before writes in a single rAF callback.
contain: layout tells the browser that layout changes inside this element cannot escape it, scoping reflow to the subtree.
Memory Game
โ Quick quiz โ lock the concept in long-term memory.What triggers a forced synchronous layout in the browser?