๐Ÿณ IntuitiveFE
Login
โ† All concepts

Layout Thrashing

โฑ๏ธ ~3-minute bite ยท solve the sandbox to master

0%lesson
๐Ÿง’

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

js
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

offsetHeight / offsetWidthoffsetTop / offsetLeftscrollTop / scrollLeftgetBoundingClientRect()clientWidth / clientHeightgetComputedStyle()scrollWidth / scrollHeightinnerText (triggers layout)
๐ŸŽ›๏ธ

Interactive Sandbox

โ€” Move something, see it react instantly.

Pattern

Operation sequence โ€” step 1/5

1readoffsetHeight
2writestyle.height
3readoffsetHeightFORCED+16ms
4writestyle.height
5readoffsetHeightFORCED+16ms
Step 1/5: First read OK โ€” layout is current
Total cost: 320ms for 10 elements (10 ร— 32ms forced layouts)
1 / 5
js
1// โœ— Read-write-read loop (100 elements = 100 layouts!)
2for (const el of elements) {
3 const h = el.offsetHeight; // forced layout if dirty
4 el.style.height = h + 10 + 'px'; // marks layout dirty
5}
โš ๏ธ Gotcha: Any read of layout properties (offsetHeight, getBoundingClientRect, scrollTop) after a write forces a synchronous layout calculation.
๐Ÿ’ก Insight: The browser optimizes by deferring layout until needed. Reading a layout property after writing forces it to calculate immediately โ€” bypassing the deferred batch.
Completed:๐Ÿ’€๐Ÿ“ฆ๐Ÿš€๐Ÿ“œ๐ŸŽจ
๐ŸŽฏ

Challenge

Step through all 5 patterns to the final step. Notice how forced layouts accumulate โ€” then see how batching eliminates them entirely.

Try it
๐ŸŽฏ

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.

js
1// csstriggers.com โ€” full property table
2// layout โ†’ paint โ†’ composite (all 3 phases)
3el.style.width = '100px';
4ย 
5// paint โ†’ composite (skips layout)
6el.style.backgroundColor = 'red';
7ย 
8// composite only (GPU, skips layout + paint)
9el.style.transform = 'translateX(100px)';
10el.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.

js
1// Each rAF tick: layout is clean at start of frame
2requestAnimationFrame(() => {
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.

js
1// Reflow: changes geometry, recalculates all affected elements
2el.style.width = '200px'; // โ†’ layout โ†’ paint โ†’ composite
3ย 
4// Repaint only: visual change, geometry unchanged
5el.style.color = 'red'; // โ†’ paint โ†’ composite (no layout)
6ย 
7// Neither reflow nor repaint: compositor thread only
8el.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.

js
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.

js
1// โœ— resize event โ€” fires constantly, triggers layout reads
2window.addEventListener('resize', () => {
3 const w = el.offsetWidth; // forced layout mid-frame
4 updateChart(w);
5});
6ย 
7// โœ“ ResizeObserver โ€” fires after layout settled
8const ro = new ResizeObserver(entries => {
9 const { width } = entries[0].contentRect; // clean read
10 updateChart(width);
11});
12ro.observe(el);
๐Ÿ”ฌ

The Deep Dive

โ€” Spec refs, engine internals, the minutiae.

Browser rendering pipeline โ€” full detail

js
1JavaScript โ†’ Style โ†’ Layout โ†’ Paint โ†’ Composite
2 โ†‘ โ†‘
3 (your code) (forced if read
4 after write)
5ย 
6Each 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.

js
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.

js
1// โœ— scroll handler reads layout every frame
2window.addEventListener('scroll', () => {
3 const rect = el.getBoundingClientRect(); // forced layout!
4 if (rect.top < window.innerHeight) loadMore();
5});
6ย 
7// โœ“ IntersectionObserver โ€” no layout triggered
8const observer = new IntersectionObserver(([entry]) => {
9 if (entry.isIntersecting) loadMore();
10}, { rootMargin: '200px' }); // 200px before viewport edge
11observer.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:

ValueWhat it scopes
contain: layoutLayout recalculation cannot escape this element
contain: paintPainting is clipped to this element's border box
contain: sizeElement size doesn't depend on children
contain: strictAll of the above โ€” maximum isolation
content-visibility: autoSkip 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.

js
1const 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});
9ro.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.
1/4

What triggers a forced synchronous layout in the browser?