Mastering Modern CSS Containment, Content-Visibility, and Layout Thrashing Prevention
CSS containment and content-visibility let the engine skip work you never needed — but they also silently rewrite your containing block and can wreck CLS. Here is how they actually work inside the rendering pipeline, paired with a production read/write scheduler that eliminates forced synchronous layout at the framework level.
AI Generated with Qwen 3.0 via CometAPIEvery millisecond the browser spends on layout is a millisecond it cannot spend painting, compositing, or responding to the user's finger. Yet most production applications ship layout work they never needed: whole subtrees reflowed off-screen, styles recalculated for elements nobody can see, and read/write interleaving in JavaScript that forces the engine to flush layout dozens of times inside a single frame. CSS containment, content-visibility, and disciplined DOM access are the three levers that fix this — and they compose multiplicatively.
This article covers what each containment primitive actually does inside the engine, where it silently changes your containing block, and how to build a read/write scheduler that removes layout thrashing at the framework layer rather than in a thousand call sites.
The Rendering Pipeline Is a Budget, Not a Guarantee
A frame is a pipeline: input → JavaScript → style → layout → paint → composite. At 60 Hz you have 16.6 ms; at 120 Hz, 8.3 ms. Style recalculation and layout are the two stages that can touch every node in the tree, which is why they dominate main-thread cost on content-heavy pages.
The engine wants to batch. Mutate as much as you like, and it will recompute style and layout exactly once, at the end of the frame. It breaks that promise only when you ask it a question it can answer solely by doing the work right now.
Where Forced Synchronous Layout Comes From
These reads invalidate the current tree and force a synchronous style + layout pass: offsetWidth, offsetHeight, clientWidth, clientHeight, scrollTop, getBoundingClientRect(), getClientRects(), getComputedStyle(), and offsetParent.
The pathology is interleaving them with writes:
// ❌ n reads × n writes = up to n forced synchronous layouts
const cells = document.querySelectorAll<HTMLElement>('.grid__cell');
for (const cell of cells) {
const gutter = cell.parentElement!.offsetWidth * 0.02; // forced layout
cell.style.marginRight = `${gutter}px`; // invalidates layout
}Each iteration dirties the tree, then reads it, forcing a flush. Ten thousand cells becomes ten thousand layouts. The fix is mechanical — hoist every read, then every write — and it is exactly what the scheduler later in this article automates.
CSS Containment: Teaching the Engine What Cannot Change
The contain property declares that a subtree is independent of the rest of the document. That sounds academic until you consider what it unlocks: the engine can skip an entire layout pass for that subtree when nothing inside changed, clip paint without considering overflow, and size the element without ever inspecting its children.
The Five Containment Types and What Each Actually Does
| Value | What it guarantees | What it costs you |
|---|---|---|
layout | Nothing outside affects the inside's layout, and vice versa. Creates a stacking context and a containing block for all positioned descendants. | position: fixed descendants are trapped inside. |
paint | Descendants clip to the padding box; no visible overflow escapes. Creates a stacking context. | Clipping, plus fixed/absolute containment. |
size | The element is sized as if it had no contents — intrinsic sizing resolves to zero. | You must size it explicitly or it collapses. |
style | Counters and quotes are scoped to the subtree. | Almost nothing; also the least impactful. |
inline-size | Size containment on the inline axis only. | Intrinsic inline size becomes zero. |
Two shorthands matter more than the individual flags:
contain: content=layout paint style— safe by default, never changes sizing.contain: strict=size layout paint style— maximum isolation, requires explicit dimensions.
/* Safe default for widget boundaries, cards, and scroll regions. */
.widget {
contain: content;
}
/* Only when you own the box's dimensions. */
.thumbnail {
contain: strict;
inline-size: 320px;
block-size: 180px;
}Container Queries Are Containment
container-type: inline-size implies contain: inline-size layout style; container-type: size implies full size containment. If you already use container queries, you already have containment — do not stack contain: strict on top and expect intrinsic sizing to keep working.
The Containing Block Side Effect Nobody Documents
Both layout and paint containment make the element a containing block for absolutely and fixed-positioned descendants, plus a stacking context. A tooltip using position: fixed inside a contain: paint card will position against the card, not the viewport. The correct escape hatches are to portal the overlay, use the Popover API, or use <dialog> — both are top-layer and immune to containment.
content-visibility: Skipping Work You Never Needed
content-visibility is the declarative version of virtualization, with one crucial difference: the DOM and the accessibility tree survive.
visible— normal rendering (default).auto— while the element is off-screen, its contents are skipped entirely: no style recalc, no layout, no paint. The element itself is still laid out, so it must have a size.hidden— contents are skipped unconditionally, but rendering state (scroll position, iframe state, form state) is cached, unlikedisplay: none.
.feed__item {
content-visibility: auto;
/* Reserve space so the scrollbar does not jump when items are skipped. */
contain-intrinsic-size: auto 320px;
}contain-intrinsic-size and the auto Keyword
auto <length> tells the engine: use <length> while the element is skipped, then remember the last rendered size once it has been laid out and use that from then on. This is the difference between a scrollbar that jitters and one that does not, and it is the most common source of CLS regressions when teams adopt content-visibility naively.
Progressive enhancement keeps older engines honest:
.card {
/* Fallback: normal intrinsic sizing. */
min-block-size: 280px;
}
@supports (content-visibility: auto) {
.card {
content-visibility: auto;
contain-intrinsic-size: auto 280px;
}
}Accessibility, Find-in-Page, and hidden="until-found"
content-visibility: auto keeps contents in the accessibility tree, the document's text index, and the tab order. The browser un-skips a subtree on demand for find-in-page matches, fragment navigation (#section), and focus. That is why it is not display: none, and why it is safe for SEO: crawlers see the full DOM.
content-visibility: hidden is different — it removes content from the accessibility tree. When the content is a collapsed disclosure panel users may search for, pair it with hidden="until-found":
<section id="faq-answer" hidden="until-found">
<p>...</p>
</section>The beforematch event fires before the browser reveals the content, which is the correct place to hydrate it:
const panel = document.querySelector<HTMLElement>('#faq-answer');
panel?.addEventListener('beforematch', () => {
// Runs synchronously before the reveal; hydrate, then let the browser scroll.
hydrateFaqAnswer(panel);
});Caveats That Will Bite You
- Sticky and fixed descendants cannot escape the containment boundary, because
content-visibility: autoimplies paint containment. - Anchor links into skipped content scroll correctly, but the final position shifts if
contain-intrinsic-sizewas wrong. - Never put `content-visibility: auto` on the scroll container itself — put it on the items.
- Un-skipping is asynchronous, so a
getBoundingClientRect()on off-screen content may return placeholder geometry.
Eliminating Layout Thrashing in Application Code
Containment reduces the cost of layout. A read/write scheduler reduces the number of layouts. Ship both.
The Read/Write Phase Scheduler
type FramePhase = 'read' | 'write';
interface SchedulerOptions {
/** Called when a queued task throws, so one bad task cannot stall the queue. */
onError?: (error: unknown, phase: FramePhase) => void;
}
const MAX_TASKS_PER_PHASE = 1000;
class FrameScheduler {
#reads: Array<() => void> = [];
#writes: Array<() => void> = [];
#rafId: number | null = null;
#onError: (error: unknown, phase: FramePhase) => void;
constructor({ onError }: SchedulerOptions = {}) {
this.#onError =
onError ??
((error, phase) => {
console.error(`[FrameScheduler] ${phase} task failed`, error);
});
}
read(task: () => void): void {
this.#reads.push(task);
this.#schedule();
}
write(task: () => void): void {
this.#writes.push(task);
this.#schedule();
}
#schedule(): void {
if (this.#rafId !== null) return;
this.#rafId = requestAnimationFrame(() => {
this.#rafId = null;
this.#flush('read');
this.#flush('write');
if (this.#reads.length > 0 || this.#writes.length > 0) this.#schedule();
});
}
#flush(phase: FramePhase): void {
const queue = phase === 'read' ? this.#reads : this.#writes;
let executed = 0;
while (queue.length > 0 && executed < MAX_TASKS_PER_PHASE) {
const task = queue.shift()!;
try {
task();
} catch (error) {
this.#onError(error, phase);
}
executed += 1;
}
if (queue.length > 0) {
this.#onError(
new Error(`[FrameScheduler] ${phase} queue exceeded ${MAX_TASKS_PER_PHASE} tasks`),
phase,
);
}
}
}
export const frame = new FrameScheduler();The drain loop is the point: tasks enqueued during the read phase append to the live queue, so every read finishes before the first write begins. Scheduling a width change inside a read callback cannot invalidate a measurement queued later in the same frame.
// ✅ Exactly two forced layouts per batch, regardless of element count.
function equalizeCardHeights(cards: readonly HTMLElement[]): void {
frame.read(() => {
const heights = cards.map((card) => card.getBoundingClientRect().height);
frame.write(() => {
cards.forEach((card, index) => {
card.style.setProperty('--card-height', `${heights[index]}px`);
});
});
});
}ResizeObserver Without the Loop Errors
ResizeObserver's callback runs after layout but before paint. Mutating an observed element's size inside the callback is precisely what produces ResizeObserver loop completed with undelivered notifications. Record values in the callback; write through the scheduler.
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const target = entry.target as HTMLElement;
const box = entry.contentBoxSize[0] ?? entry.contentRect;
const inlineSize = 'inlineSize' in box ? box.inlineSize : box.width;
// Record only — never mutate an observed element here.
frame.write(() => {
target.style.setProperty('--measured-inline-size', `${inlineSize}px`);
});
}
});
document.querySelectorAll<HTMLElement>('[data-measure]').forEach((el) => {
observer.observe(el, { box: 'content-box' });
});Prefer Compositor-Only Properties
transform, opacity, and filter animations can run off the main thread; top, left, width, height, and margin cannot. Animate with the Web Animations API or CSS and the layout stage disappears from the frame budget entirely. Reserve will-change for elements that genuinely animate, and remove it when the animation finishes.
Measuring What You Shipped
The DevTools Performance panel flags forced reflows with a red corner triangle on the Layout event, and Lighthouse surfaces them as 'Avoids forced reflow'. For field data, use the Long Animation Frames API to attribute main-thread stalls to specific scripts:
interface LoAFScript {
invoker: string;
sourceURL: string;
sourceFunctionName: string;
duration: number;
forcedStyleAndLayoutDuration: number;
}
interface LoAFEntry extends PerformanceEntry {
renderStart: number;
styleAndLayoutStart: number;
blockingDuration: number;
scripts: LoAFScript[];
}
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries() as LoAFEntry[]) {
const layoutMs = entry.styleAndLayoutStart
? entry.startTime + entry.duration - entry.styleAndLayoutStart
: 0;
console.warn('Long frame', {
duration: entry.duration,
styleAndLayoutMs: layoutMs.toFixed(1),
topScripts: entry.scripts
.slice()
.sort((a, b) => b.duration - a.duration)
.slice(0, 3)
.map(
(s) =>
`${s.invoker} (${s.duration.toFixed(1)}ms, forced: ${s.forcedStyleAndLayoutDuration.toFixed(1)}ms)`,
),
});
}
});
observer.observe({ type: 'long-animation-frame', buffered: true } as PerformanceObserverInit);forcedStyleAndLayoutDuration is the ground truth: it is exactly the time spent in the synchronous layouts your code provoked.
Production Checklist
contain: contenton widget boundaries, card internals, and independently scrolling panes.contain: strictonly where the box has explicitinline-sizeandblock-size.content-visibility: autoon long, uniform lists — never on the scroller itself.contain-intrinsic-size: auto <estimate>set to a realistic median item height.hidden="until-found"(nothidden) for searchable collapsed content.- All DOM reads and writes routed through a phase scheduler or batched in
requestAnimationFrame. ResizeObservercallbacks record-only; mutations deferred.- Animations limited to
transformandopacity. - LoAF and CLS attributed in RUM, not only in local profiling.
- Overlays portalled or moved to the top layer to survive paint containment.
Pitfalls to Avoid
- `contain: size` without explicit dimensions collapses the element to zero. Pair it with
inline-size/block-size, or usecontain: contentinstead. - `content-visibility: auto` with no `contain-intrinsic-size` produces scrollbar thrash and large CLS on fast scrolls.
- Nesting `content-visibility: auto` compounds placeholder error. One level per list is almost always enough.
- Assuming `contain: paint` is invisible. It clips shadows, dropdowns, and focus rings that overflow the padding box. Use
overflow-clip-marginwhen you need breathing room. - Reading layout after style writes in event handlers. A single
el.style.height = h; const v = el.offsetHeight;is a forced layout — there are no exceptions for 'just this once'. - Trusting `getBoundingClientRect()` on skipped content. You will get placeholder geometry, not real geometry.
Sources & Technical References
- W3C — CSS Containment Module Level 1 (contain, layout/paint/size/style containment)
- W3C — CSS Containment Module Level 2 (content-visibility, contain-intrinsic-size)
- W3C — CSS Containment Module Level 3 (container queries, container-type)
- MDN — `contain`
- MDN — `content-visibility`
- MDN — `contain-intrinsic-size`
- MDN — CSS container queries
- MDN — ResizeObserver
- MDN — PerformanceLongAnimationFrameTiming
- web.dev — content-visibility: the new CSS property that boosts your rendering performance
- web.dev — Avoid large, complex layouts and layout thrashing
- Chrome for Developers — Long Animation Frames API
- WHATWG HTML Standard — the `hidden` attribute and `until-found` state
- WebKit — WebKit Features in Safari 18.0 (content-visibility support)
- WICG — display-locking / content-visibility explainer
Discussion (0)
Continue Reading
More production-grade engineering deep dives and architectural guides

1B Spans/Day in Go: OTel SDK vs. eBPF vs. Hand-Rolled Tracing — Measured p99, CPU per Span, and Real Cost per 1M Spans
A billion spans a day sounds like a hyperscaler number — it is really just ~2,900 RPS. We benchmarked the OpenTelemetry Go SDK, eBPF auto-instrumentation, and a hand-rolled arena tracer on the same gRPC service to separate instrumentation CPU cost from the number that actually destroys observability budgets: storage and indexing per 1M spans.

Kubernetes Autoscaling at 10k Pods: Karpenter vs Cluster Autoscaler vs CAST AI, Measured
We ran identical HPA-driven load ramps from 2,000 to 10,000 pods across three EKS clusters to measure p99 scale-up latency, bin-packing waste, spot interruption recovery, and true cost per 1,000 pod-hours. Here is the methodology, the raw numbers, and the architectural reasons behind them.

Component Testing at 1M Runs/Month: Playwright CT vs Cypress CT vs Vitest Browser Mode — Measured p95, Flake Rate, and True CI Cost
We executed one million component test runs across Playwright CT, Cypress CT, and Vitest browser mode on identical hardware and measured p95 wall time, first-attempt flake rate, and throughput. The surprising result: CI compute is under 0.5% of your real bill — flake triage is the other 98%.