--- id: wiki-2026-0508-layout-thrashing title: Layout Thrashing category: 10_Wiki/Topics status: verified canonical_id: self aliases: [Forced Synchronous Layout, Reflow Thrashing, FSL] duplicate_of: none source_trust_level: A confidence_score: 0.9 verification_status: applied tags: [web-performance, dom, reflow, browser, javascript] raw_sources: [] last_reinforced: 2026-05-10 github_commit: pending tech_stack: language: javascript framework: vanilla/fastdom --- # Layout Thrashing ## 매 한 줄 > **"매 layout thrashing = read → write → read → write 반복으로 브라우저가 매번 reflow를 강제로 동기 실행하는 안티 패턴"**. JS 한 frame 안에서 DOM 측정 (offsetHeight 등)과 mutation (style 변경)을 번갈아 하면 매 read마다 forced synchronous layout이 발생해 60fps가 붕괴된다. 해결은 read와 write를 batch + rAF로 분리. ## 매 핵심 ### 매 왜 발생하나 - 브라우저는 효율을 위해 style/layout 계산을 **async + batched**로 처리. - 하지만 layout-dependent property를 JS가 읽으면 (`offsetWidth`, `getBoundingClientRect`) 즉시 정확한 값을 줘야 하므로 pending mutation 전부를 동기 flush — **forced sync layout**. - write → read를 반복하면 매 iteration마다 flush → O(n) reflow. ### 매 trigger되는 read 속성 - `offsetTop/Left/Width/Height`, `scrollTop/Left/Width/Height`, `clientTop/Left/Width/Height`. - `getComputedStyle()`, `getBoundingClientRect()`. - `innerText` (computed style 읽음). - `focus()` (scroll 발생 가능). ### 매 trigger되는 write - 어떤 layout 영향 style 변경: `el.style.width`, class 변경, DOM insert/remove. - `setAttribute` (size/position 영향). ### 매 해결 원칙 1. **Read 모두 먼저**, 그다음 **write 모두**. 2. **rAF**: 측정은 현재 frame, 변경은 다음 frame. 3. **FastDOM** 같은 scheduler. 4. **CSS Containment** (`contain: layout`) — reflow 범위 격리. 5. **Transform/opacity** — composite-only, layout 안 발생. ### 매 응용 1. List virtualization. 2. Drag & drop. 3. Sticky / parallax. 4. Animation. 5. Resize observer 응답. ## 💻 패턴 ### Anti-pattern — read/write 인터리브 ```js // ❌ N번 forced sync layout const items = document.querySelectorAll('.item'); items.forEach(el => { const w = el.offsetWidth; // read (flush!) el.style.width = (w * 1.1) + 'px'; // write (invalidate) }); ``` ### Fix — batch read 후 batch write ```js // ✅ 1번만 layout 계산 const items = document.querySelectorAll('.item'); const widths = []; for (const el of items) widths.push(el.offsetWidth); // all reads for (let i = 0; i < items.length; i++) { items[i].style.width = (widths[i] * 1.1) + 'px'; // all writes } ``` ### requestAnimationFrame + double-buffer ```js function resizeAll() { const items = [...document.querySelectorAll('.item')]; // measure phase const widths = items.map(el => el.offsetWidth); // mutate phase — 다음 frame requestAnimationFrame(() => { items.forEach((el, i) => { el.style.width = (widths[i] * 1.1) + 'px'; }); }); } ``` ### FastDOM 스타일 scheduler ```js const reads = []; const writes = []; let scheduled = false; function schedule() { if (scheduled) return; scheduled = true; requestAnimationFrame(() => { while (reads.length) reads.shift()(); while (writes.length) writes.shift()(); scheduled = false; }); } export const fastdom = { measure(fn) { reads.push(fn); schedule(); }, mutate(fn) { writes.push(fn); schedule(); }, }; // usage fastdom.measure(() => { const w = el.offsetWidth; fastdom.mutate(() => { el.style.width = (w * 1.1) + 'px'; }); }); ``` ### ResizeObserver — read/write 분리 ```js const ro = new ResizeObserver(entries => { // entries already has measurements — read 안 해도 됨 const updates = entries.map(e => ({ el: e.target, height: e.contentRect.height, })); requestAnimationFrame(() => { for (const u of updates) u.el.style.setProperty('--h', u.height + 'px'); }); }); ro.observe(document.querySelector('#panel')); ``` ### React — useLayoutEffect로 측정·변경 분리 ```tsx import { useLayoutEffect, useRef, useState } from "react"; function AutoFit({ children }) { const ref = useRef(null); const [w, setW] = useState(0); useLayoutEffect(() => { // 측정만 const next = ref.current!.offsetWidth; setW(next); // 변경은 React commit이 batch }, []); return
{children}
; } ``` ### CSS Containment — reflow 범위 격리 ```css .card { contain: layout style paint; /* 내부 변화가 외부 reflow 안 일으킴 */ } .list-item { contain: layout; content-visibility: auto; /* 보이지 않으면 layout 스킵 */ } ``` ### Performance 측정 — DevTools Performance API ```js performance.mark('measure-start'); const w = el.offsetWidth; performance.mark('measure-end'); performance.measure('forced-layout', 'measure-start', 'measure-end'); // DevTools Performance tab에서 "Recalculate Style" / "Layout" 보라색 막대 확인 ``` ### Transform 사용으로 layout 회피 ```js // ❌ left/top — layout 발생 el.style.left = x + 'px'; el.style.top = y + 'px'; // ✅ transform — composite-only el.style.transform = `translate3d(${x}px, ${y}px, 0)`; ``` ## 매 결정 기준 | 상황 | Approach | |---|---| | 단순 batch | read 먼저 → write 나중 (one pass) | | 여러 모듈이 DOM 만짐 | FastDOM 또는 자체 scheduler | | Animation | rAF + transform/opacity | | 큰 리스트 | virtualization + content-visibility | | 격리된 위젯 | `contain: layout style` | **기본값**: 모든 hot path에서 read/write 분리 + rAF + transform 우선. ## 🔗 Graph - 부모: [[Web-Performance]] - 변형: [[Forced-Synchronous-Layout]], [[Reflow]], [[Repaint]] - 응용: [[Drag-and-Drop]] - Adjacent: [[requestAnimationFrame]], [[ResizeObserver]], [[Core Web Vitals Optimization (INP, LCP, CLS)|Core-Web-Vitals]] ## 🤖 LLM 활용 **언제**: 코드에서 read/write interleave 탐지, FastDOM-style 리팩터 제안, Performance trace 해석. **언제 X**: 실제 frame budget 측정 — 디바이스/페이지 의존, DevTools 직접. ## ❌ 안티패턴 - **루프 안에서 offsetX 호출**: 가장 흔한 thrashing. - **jQuery `.css()` 연쇄**: 내부에서 measure 트리거. - **window.scroll listener에서 read+write**: scroll 매 이벤트마다 layout. - **MutationObserver 콜백에서 sync read**: 배치 의미 사라짐. - **Transform 가능한데 left/top 사용**: 불필요한 layout. ## 🧪 검증 / 중복 - Verified (Chrome DevTools docs, web.dev "Avoid large, complex layouts" 2026, Wilson Page FastDOM). - 신뢰도 A. ## 🕓 Changelog | 날짜 | 변경 | |---|---| | 2026-05-08 | Phase 1 | | 2026-05-10 | Manual cleanup — FastDOM, useLayoutEffect, CSS containment 추가 |