Skip to content

    Frontend System Design

    Layout Thrashing and Forced Reflow

    Learn why reading geometry right after changing styles forces layout, how loops that mix reads and writes multiply the cost, and how to batch them.

    By Published 8 min read

    • frontend-system-design
    • layout-thrashing
    • forced-reflow
    • performance
    Share

    Focused lessonintermediateTypeScript labFrontend System Design

    Before this: Layout, Paint and Compositing in Depth

    What you'll learn: By the end of this lesson you will be able to explain why the browser delays layout, say which reads force it early, recognise layout thrashing in a loop, fix it by separating reads from writes, spot where batching cannot help, and find forced layouts with the browser's tools. You will also run a small model that counts the cost.

    Picture an office manager with a seating chart. Moving a name card is quick. But if someone asks "where is Bob sitting now?" after each move, she must redraw the whole chart before answering, because Bob's seat may have shifted. Twenty questions between twenty moves mean twenty redraws. Ask all the questions first, then make the moves, and she redraws once.

    The browser behaves like the manager. This lesson builds on Layout, Paint and Compositing in Depth and the short thrashing section of The Critical Rendering Path. The running example is a thumbnail gallery whose script sizes every thumbnail from its container's width.

    The core idea

    1. Writing a style does not run layout. It marks the affected boxes as needing layout and the browser does the work later, normally once per frame.
    2. Reading geometry needs a current answer. If you ask for a layout-dependent value while layout is marked out of date, the browser must run style and layout first. web.dev calls this forced synchronous layout (also forced reflow).
    3. Layout thrashing is doing that repeatedly. web.dev: "do lots of them in quick succession".

    How the browser defers layout

    Changing a geometric property (width, height, left, top) invalidates layout, and web.dev notes layout "is normally scoped to the whole document". Chromium's LayoutNG article describes the older design in which an object was marked dirty "and likewise all of its parents in the tree", and the newer one in which results are cached against the constraints a parent hands down. Details are engine-specific; the shared idea is a write flags, a pass later computes.

    Figure 1. The two states of layout

    stateDiagram-v2 accTitle: Layout is clean or dirty accDescr: A style write moves layout from clean to dirty. Either a geometry read (a forced layout) or the browser's own rendering update moves it back to clean. A read while clean is free. [*] --> Clean Clean --> Dirty: write a geometric style Dirty --> Dirty: more writes (cheap) Dirty --> Clean: read of geometry (forced layout) Dirty --> Clean: rendering update (normal layout) Clean --> Clean: read of geometry (free)

    How to read this: the cost sits on the arrow from Dirty to Clean, which an alternating script takes once per read.

    What forces layout

    Paul Irish maintains a widely cited list, compiled from the Blink source, of what makes Chromium compute style and layout synchronously: box metrics (offsetWidth, offsetHeight, clientWidth, getBoundingClientRect()), scroll metrics (scrollTop, scrollHeight, scrollIntoView()), focus(), innerText, and getComputedStyle() for some properties (such as height, width and transform). The list is Blink-specific, was last updated in April 2020, and commenters report items that no longer force layout in newer Chromium. Treat it as a map to check in your own browser, not a contract.

    Paint-only writes (web.dev's examples: color, background-image, box-shadow) leave layout clean, so a read after them costs nothing extra.

    Illustration 1: a minimal example

    // Thrashes: each iteration reads, then writes. The read after the first write forces layout.
    for (const img of thumbs) {
      img.style.width = `${container.offsetWidth / 4}px`;
    }

    With n thumbnails, reads 2..n each find layout dirty. The fix, in web.dev's words, is to "batch your style reads and do them first":

    const width = container.offsetWidth / 4; // one read, layout is already clean
    for (const img of thumbs) {
      img.style.width = `${width}px`;
    }

    Figure 2. The same loop, interleaved and batched

    sequenceDiagram accTitle: Interleaved versus batched reads and writes accDescr: In the interleaved loop every read after a write forces a layout pass. In the batched loop all reads happen while layout is clean, then all writes, then one layout in the rendering update. participant S as Script participant L as Layout Note over S,L: Interleaved S->>L: read width (clean, free) S->>S: write thumb 1 S->>L: read width (dirty, forced layout) S->>S: write thumb 2 S->>L: read width (dirty, forced layout) Note over S,L: Batched S->>L: read width once (clean, free) S->>S: write thumb 1, 2, 3 ... L->>L: one layout in the rendering update

    How to read this: the expensive arrows are the reads that follow a write.

    Illustration 2: a realistic example

    A dashboard table sizes a bar in each row's detail panel to the row's measured width. One helper reads the width and writes the bar, and a forEach calls it per row. Nothing in the helper looks wrong, yet with 200 rows (illustrative) it forces a layout 199 times. The cure is structural: measure all rows, then write all bars.

    Illustration 3: where batching cannot help

    Sometimes a read depends on an earlier write: "grow this card, then ask where the next card starts". Reads-first returns different answers.

    Figure 3. What to do when you find a forced layout

    flowchart TD accTitle: Choosing a fix for a forced layout accDescr: If the read does not depend on the earlier writes, batch reads before writes. If it does, compute the value in code or restructure with CSS. If you need sizes after layout, use a ResizeObserver. Q1{"Does the read depend on
    an earlier write?"} -->|"no"| A["Read everything first,
    then write"] Q1 -->|"yes"| Q2{"Can CSS or arithmetic
    give the value?"} Q2 -->|"yes"| B["Compute it in code,
    or let layout do it"] Q2 -->|"no"| C["Observe sizes with
    ResizeObserver, accept
    one layout"]

    How to read this: most thrashing loops end up in the first branch.

    MDN says ResizeObserver reports changes to an element's dimensions and, following the specification, invokes its callbacks before paint, so you get sizes without calling a forcing API. Its trap: a handler that resizes what it observes can loop, and the browser then reports "ResizeObserver loop completed with undelivered notifications". It is Baseline widely available.

    A small scheduler keeps the discipline in one place: it queues reads and writes separately and runs all reads before all writes (checked in Node with a fake scheduler):

    export function createBatcher(schedule: (run: () => void) => void) {
      const reads: Array<() => void> = [];
      const writes: Array<() => void> = [];
      let scheduled = false;
    
      const flush = (): void => {
        scheduled = false;
        const r = reads.splice(0);
        const w = writes.splice(0);
        for (const read of r) read();
        for (const write of w) write();
      };
      const request = (): void => {
        if (!scheduled) {
          scheduled = true;
          schedule(flush);
        }
      };
      return {
        measure(fn: () => void): void { reads.push(fn); request(); },
        mutate(fn: () => void): void { writes.push(fn); request(); },
      };
    }

    In a page you would pass requestAnimationFrame as schedule. A read that depends on an earlier queued write now sees the old state: Illustration 3 again.

    Trade-offs

    Approach What it fixes What it costs Watch out for
    Reorder: reads, then writes Forced layouts in a loop Restructuring Reads that depend on writes
    Measure/mutate queue Interleaving across modules A small abstraction; one frame of latency Callbacks that read after queuing a write
    ResizeObserver Reading sizes after layout Async handling Resize loops
    CSS-only layout The need to measure Design constraints Not always possible

    Build it: count the cost of interleaving

    Goal. Run a toy layout engine and see, in counters, why interleaved reads and writes multiply layout passes.

    This models the idea, not a browser. There is no CSS, text or paint, boxes are stacked vertically, and the "cost" is a count of boxes recomputed, not milliseconds. Real engines differ, and nothing here was measured in a browser.

    Setup. The lab is in the course's example workspace (captured on Node 26.12.1, which runs TypeScript files directly).

    cd examples/frontend/labs/layout-dirty-flags
    pnpm test          # 23 tests
    node src/demo.ts   # prints the scenarios below

    Steps.

    1. Read src/model.ts: write sets selfDirty on the node and childDirty on every ancestor; read runs layout first if the root is dirty and counts a forced layout; endFrame runs the normal one.
    2. In gallery-resize the interleaved loop does 8 passes for 8 items (7 forced, since the first read finds clean layout, plus the frame's own); the batched one does 1.
    3. In true-dependency, batching returns different read values (NO): Illustration 3. In paint-only, neither order runs layout.
    4. In the scaling table, passes grow with n and the reused count grows roughly with its square, because every pass walks the unchanged siblings again.

    Expected output, captured from a real run (Node 26.12.1, 2026-11-08):

    === gallery-resize
    lesson: Reading the container's width inside the loop that writes thumbnail widths forces a layout per item.
    operations: 16 (8 reads, 8 writes)
    interleaved   reads= 8  writes= 8  forced= 7  frame=1  passes= 8  laidOut= 24  reused= 72
    batched       reads= 8  writes= 8  forced= 0  frame=1  passes= 1  laidOut= 10  reused=  2
    batching returns the same read values: yes
    both orders leave the same geometry:   yes
    
    === grow-each
    lesson: Read-modify-write per item thrashes; reading every height first and writing after does not.
    operations: 12 (6 reads, 6 writes)
    interleaved   reads= 6  writes= 6  forced= 5  frame=1  passes= 6  laidOut= 18  reused= 42
    batched       reads= 6  writes= 6  forced= 0  frame=1  passes= 1  laidOut=  8  reused=  2
    batching returns the same read values: yes
    both orders leave the same geometry:   yes
    
    === true-dependency
    lesson: When a read needs an earlier write, batching changes the answers: compute in code or change the design.
    operations: 10 (5 reads, 5 writes)
    interleaved   reads= 5  writes= 5  forced= 5  frame=0  passes= 5  laidOut= 15  reused= 30
    batched       reads= 5  writes= 5  forced= 0  frame=1  passes= 1  laidOut=  7  reused=  2
    batching returns the same read values: NO
    both orders leave the same geometry:   yes
    
    === paint-only
    lesson: A write that cannot change geometry leaves layout clean, so the read that follows is free.
    operations: 16 (8 reads, 8 writes)
    interleaved   reads= 8  writes= 8  forced= 0  frame=0  passes= 0  laidOut=  0  reused=  0
    batched       reads= 8  writes= 8  forced= 0  frame=0  passes= 0  laidOut=  0  reused=  0
    batching returns the same read values: yes
    both orders leave the same geometry:   yes
    
    === one-leaf-write
    lesson: Changing a leaf recomputes the leaf and its ancestors; clean siblings are reused.
    operations: 2 (1 reads, 1 writes)
    interleaved   reads= 1  writes= 1  forced= 1  frame=0  passes= 1  laidOut=  3  reused=  9
    batched       reads= 1  writes= 1  forced= 0  frame=1  passes= 1  laidOut=  3  reused=  9
    batching returns the same read values: NO
    both orders leave the same geometry:   yes
    
    === one-container-write
    lesson: Changing a container whose children are fluid recomputes every descendant.
    operations: 2 (1 reads, 1 writes)
    interleaved   reads= 1  writes= 1  forced= 1  frame=0  passes= 1  laidOut= 10  reused=  2
    batched       reads= 1  writes= 1  forced= 0  frame=1  passes= 1  laidOut= 10  reused=  2
    batching returns the same read values: yes
    both orders leave the same geometry:   yes
    
    === scaling: the gallery-resize loop for n thumbnails
       n  interleaved passes  interleaved laidOut  interleaved reused  batched passes  batched laidOut
       4                   4                  12                  20               1                6
      16                  16                  48                 272               1               18
      64                  64                 192                4160               1               66
     256                 256                 768               65792               1              258
    
    Costs are counts of boxes recomputed by a toy model, not milliseconds. This is not a browser.

    Break it. In write, comment out the loop that sets childDirty on the ancestors. Layout never walks down to the changed node and reads return stale geometry; 13 of the 23 tests fail. Revert it.

    Stretch. Add a containment boundary that stops childDirty propagation for a fixed-size node and compare laidOut.

    In practice: the dashboard that froze on "expand all"

    NOTE

    This is a realistic scenario, not a case history. It is a composite built from common production patterns. It describes no specific company or product, and every number in it is an illustrative assumption.

    The situation

    An analytics dashboard has a table of 200 rows (illustrative). "Expand all" opens each row's detail panel and sizes a bar inside it to the row's width. On a mid-range laptop it freezes for a visible moment. The performance panel shows one long task with many small layout entries, and its forced-reflow insight flags the bar-sizing function.

    Starting architecture

    Figure 4. Reads and writes mixed in one helper

    Starting architecture: every row's helper reads layout, then writes, inside one loopExpand all to Per-row helper; Per-row helper to Layout engine (read after write); Layout engine to Per-row helper (answer); Layout engine to FrameMAIN THREAD, ONE TASKExpand all: one click handlerExpand allone click handlerPer-row helper: read width, write barPer-row helperread width, writebarLayout engine: runs on each readLayout engineruns on each readFrame: lateFramelateread after writeanswer

    How to read this: the loop and the layout engine ping-pong inside one task, so the frame waits for all 200 rounds.

    Options considered

    Option What it fixes What it costs Risk
    Measure all rows, then write all bars The forced layouts A two-phase helper Minimal
    Make bar width a percentage in CSS The measuring entirely A CSS change Needs a design that allows it

    The decision

    Field Decision record
    Context Per-row read-then-write inside one task forces a layout per row.
    Decision Use a percentage width in CSS where the design allows it, else measure first and write after. Add a review item for reads inside loops that write.
    Consequences, good Layout runs once for the expand; no new dependency.
    Consequences, bad Slightly less readable helper.
    Revisit when Interleaving keeps reappearing: adopt a shared queue.

    Rollout and verification

    1. Record "expand all" before and after in the performance panel with CPU throttling and compare forced-reflow entries.
    2. In the field, forcedStyleAndLayoutDuration can report forced layout per script; MDN marks it limited availability and experimental, so use it as a diagnostic.

    The 2 a.m. failure

    After release, a bar is the wrong width on narrow screens. The new two-phase helper measured rows before another code path had updated the row font size, so it read a stale width. First checks: log the order of reads and writes, and confirm the font-size update ran first. Mitigation: run that update before the measure phase. Follow-up: add a narrow-viewport test with a wrapped label.

    Architect's notes

    • Make "reads before writes" a review rule for loops that touch layout.
    • Centralise measuring where many components touch layout.
    • Measure with tools, not folklore. Use the performance panel's forced-reflow insight; lists of forcing properties are engine- and version-specific.

    Common mistakes

    • Reading offsetWidth inside a loop that writes widths.
    • Batching reads that depend on earlier writes. The answers change.
    • Resizing an element inside its own ResizeObserver callback without a guard.

    Key takeaways

    • Writes mark layout dirty; reads of geometry force it to be current.
    • Thrashing is alternating the two in a loop: one layout per read.
    • Read everything first, then write, unless a read truly depends on a write.
    • Find forced layouts with the performance panel; the model in the lab counts passes, not milliseconds.

    Practice exercises

    Exercise 1: Starter (30 min). Rewrite the loop from Illustration 1 so it forces no layout, then match the lab's gallery-resize numbers.

    Exercise 2: Intermediate (2 hours). In a page of 200 boxes, record both versions in the performance panel and find the forced-reflow insight.

    Exercise 3: Advanced (half day). Add the containment stretch to the lab.

    In this course:

    Official documentation

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments