Layout and Paint Optimization
The Frame Budget and Pipeline Phases
The browser rendering pipeline allocates the 16.6ms frame budget (at 60fps) across five sequential phases. Every phase must complete within its share of that budget for the frame to be delivered without a drop:
| Pipeline Phase | Typical Budget Allocation | Engine implication |
|---|---|---|
| JS Execution & Microtasks | ~4ms | Yield before style recalc; long tasks block vsync |
| Style & Layout Resolution | ~6ms | Blink LayoutObject traversal; WebKit RenderTree rebuild |
| Paint & Rasterization | ~4ms | Skia bitmap generation; GPU upload preparation |
| Compositing | ~2.6ms | Layer tree merge, vsync alignment, frame presentation |
These are soft allocations, not hard limits. What matters is that the total stays under 16.6ms. The most common violation pattern is read/write interleaving in JavaScript, which forces synchronous layout mid-task and blows through both the JS and layout allocations simultaneously.
Understanding Reflow and Repaint Triggers is the first step toward eliminating that pattern.
Read/Write Interleaving
// β Forced synchronous layout on every iteration
// Each offsetWidth read flushes pending style changes before returning
function thrashLayout(elements) {
elements.forEach((el) => {
const width = el.offsetWidth // forces layout recalc
el.style.width = `${width + 10}px` // invalidates layout
})
}
// β
Batch reads, then batch writes
function batchedLayout(elements) {
const widths = []
// Phase 1: all reads (one layout flush)
requestAnimationFrame(() => {
elements.forEach((el) => widths.push(el.offsetWidth))
// Phase 2: all writes (one layout invalidation, processed next frame)
requestAnimationFrame(() => {
elements.forEach((el, i) => {
el.style.width = `${widths[i] + 10}px`
})
})
})
}
The double-rAF pattern separates the read phase and write phase across two consecutive frames. If you need both in the same frame, batch all reads first in the current rAF callback, then apply all writes in the same callback after the reads complete β this still produces only one layout flush rather than one per element.
CSS Containment
CSS Containment Strategies let you tell the engine that a subtree is independent of the rest of the document. contain: layout style paint restricts layout, style, and paint calculations to the contained subtree, preventing mutations inside from triggering global recalculation.
| Stage Constraint | Optimization |
|---|---|
| Style resolution | Flat selectors; contain: style for component boundaries |
| Layout calculation | Avoid offsetHeight/getBoundingClientRect() in write-phase |
| Paint rasterization | Limit overdraw; avoid large box-shadow or filter |
| Compositing | transform/opacity for GPU-accelerated transitions |
Layer Promotion and Compositor Offload
Promote frequently animated elements using will-change and Layer Hints to preemptively allocate GPU memory. This signals Blinkβs layer manager to hoist the element off the main thread.
Batch DOM reads and writes via requestAnimationFrame to eliminate forced synchronous layouts. When managing large datasets with many rows or cards, use Paint Invalidation and Regions patterns to restrict rasterization to dirty rectangles.
Virtual list example:
const VirtualList = ({ items, rowHeight }) => {
const containerRef = React.useRef()
const [visibleRange, setVisibleRange] = React.useState({ start: 0, end: 20 })
React.useEffect(() => {
const observer = new ResizeObserver((entries) => {
requestAnimationFrame(() => {
const height = entries[0].contentRect.height
const count = Math.ceil(height / rowHeight) + 2 // +2 for overscan
setVisibleRange((r) => ({ start: r.start, end: r.start + count }))
})
})
observer.observe(containerRef.current)
return () => observer.disconnect()
}, [rowHeight])
const visibleItems = items.slice(visibleRange.start, visibleRange.end)
const totalHeight = items.length * rowHeight
return (
<div ref={containerRef} style={{ height: '400px', overflowY: 'auto' }}>
<div style={{ height: totalHeight, position: 'relative' }}>
{visibleItems.map((item, i) => (
<div
key={item.id}
style={{
position: 'absolute',
top: `${(visibleRange.start + i) * rowHeight}px`,
height: `${rowHeight}px`,
}}
>
{item.content}
</div>
))}
</div>
</div>
)
}
This pattern limits paint work to the visible rows only. The container padding maintains scroll geometry without rendering off-screen content.
Debugging
- DevTools β Performance β Record with Layout, Paint, and Layers enabled.
- Filter Main thread; identify red/yellow blocks exceeding 16.6ms.
- Expand
Layoutevents; look for Forced reflow markers. Click through to see the exact JS line that triggered the synchronous flush. - Layers tab: verify promoted elements show Compositor Layer badges.
- Rendering tab: enable Paint Flashing to visualize dirty rectangles.
// Custom marks for pinpointing layout cost per code path
function trackFrameBudget() {
performance.mark('frame-start')
requestAnimationFrame(() => {
performance.mark('layout-start')
// Force a single intentional read to measure current layout cost
const _ = document.body.offsetHeight
performance.mark('layout-end')
performance.measure('layout-duration', 'layout-start', 'layout-end')
})
}
Metric Validation
| Metric | Target |
|---|---|
| Frame consistency | β₯ 90% of frames under 16.6ms |
| INP (p75) | β€ 200ms |
| CLS | β€ 0.1 |
| LCP | β€ 2.5s |
Integrate performance.getEntriesByType('layout-shift') and PerformanceObserver on longtask into your RUM pipeline. Correlate engine-level frame drops with user-reported jank to prioritize which optimization to apply next.