Using content-visibility to Skip Offscreen Rendering
Symptom: a 240-row listing burns 578 ms in Recalculate Style, Layout and Paint before the first frame — and repeats most of that work on every filter toggle — even though only two rows are ever on screen at once.
Root cause: layout has no viewport scope of its own
The rendering pipeline is not viewport-aware by default, and it cannot be. Style resolution walks every element that matches a selector, layout computes a box for every element with a principal box, and both run over the whole document because the browser has no advance knowledge of which subtrees can influence which others. A float, a percentage height, a margin collapse or an align-items: stretch on a distant ancestor can all make row 240’s geometry change row 1’s. Until you tell the engine that a subtree is independent, it must assume it is not.
content-visibility: auto is that promise. It applies layout, style and paint containment to the element unconditionally, and — while the element is not relevant to the user — size containment as well, at which point the browser skips the subtree’s contents entirely during the rendering steps. Skipped means skipped: no style recalculation, no layout, no paint, no hit-testing, no compositing for anything inside. The element itself still generates a box, sized from contain-intrinsic-size, so the scroller keeps a plausible height and the page does not collapse.
“Relevant to the user” is a defined state, not a heuristic. The subtree becomes relevant when it intersects the viewport plus an implementation-defined margin, when it contains the focused element or the selection, when find-in-page matches inside it, when it is the target of a fragment navigation or scrollIntoView(), or when it holds an element in the top layer. Chromium expands the viewport by 50% of its size in each direction before running the intersection test — a different, smaller band than the connection-aware distance used by native lazy loading, and one you cannot read or configure from script.
The last mechanical detail is the one that produces bug reports: the relevance check runs during the rendering steps, so a subtree that becomes relevant is rendered on the following rendering opportunity, not the current one. On a fast scroll the user can see an empty placeholder box for one frame. That is the cost of the technique, and the whole tuning exercise is making sure it stays at one frame.
The relevance band in pixels
Put concrete numbers on it. An 800 px viewport gives a band of 800 + 400 + 400 = 1,600 px. Rows 420 px tall mean four rows are relevant at any moment; the other 236 are skipped. That ratio, not the property name, is where the saving comes from — and it is also why the technique pays nothing on a page that is only two viewports long.
Minimal reproduction
Two hundred and forty identical rows, thirty-eight nodes each, no images, no script. The point of stripping the images out is that the cost being measured is purely rendering — nothing here is waiting on the network.
<!-- Reproduction: 9,120 nodes the browser must style and lay out before the
first frame, because layout is document-wide unless a subtree opts out. -->
<main id="feed">
<article class="row">…38 nodes: heading, meta line, 4-cell grid, 2 buttons…</article>
<!-- repeated 240 times -->
</main>
/* Containment rationale: `auto` applies layout, style and paint containment
permanently — the guarantee that a row can never affect geometry outside
itself — and adds size containment only while the row is outside the
relevance band. That combination is what makes it safe for the engine to
skip the subtree's style, layout and paint work entirely. */
.row {
content-visibility: auto;
/* The `auto` keyword tells the browser to reuse the last rendered height
instead of the 420 px guess once it has actually laid the row out. Without
it, every skipped row reports exactly 420 px and the scroll height changes
as the user moves, which is what makes the scrollbar thumb jump. */
contain-intrinsic-size: auto 420px;
}
/* Rows that are always on screen gain nothing from a relevance check, and the
LCP element must never depend on one. Opt them back out explicitly. */
.row:nth-child(-n + 2) {
content-visibility: visible;
}
Measure it with a forced synchronous layout rather than a frame average, so the number is unambiguous:
// Measurement rationale: reading offsetHeight forces the engine to flush all
// pending style and layout work synchronously, so the elapsed time is the
// document's style + layout cost rather than whatever fitted in a frame budget.
// Run this once with the .row rule commented out and once with it live; the
// only variable between the two runs is containment.
performance.mark('cv-start');
document.documentElement.offsetHeight; // forced synchronous layout
performance.mark('cv-end');
const { duration } = performance.measure('cv', 'cv-start', 'cv-end');
console.log(`style + layout: ${duration.toFixed(1)} ms`);
On a mid-tier laptop with 6× CPU throttling that logs about 482 ms without containment and about 69 ms with it. The residual is real work: the four relevant rows, plus the page shell, plus the scroll-height arithmetic across 240 placeholder boxes.
Deterministic fix protocol
Work top to bottom. Steps 1 and 2 decide whether the technique applies at all; skipping them is how teams end up shipping a declaration that does nothing.
- [ ] 1. Prove the cost is rendering, not script. Record a load in the Performance panel and read the first-frame totals for Recalculate Style, Layout and Paint. Containment only removes those three. If the time is in Evaluate Script or Function Call, this page is the wrong fix and you want request-level deferral instead.
- [ ] 2. Choose a boundary that can actually take containment. Apply the property to a repeating block-level wrapper. Internal table elements (
tr,tbody,td),display: contentswrappers and inline boxes cannot take layout containment, so the declaration parses, computes, and does nothing at all. Confirm in the Computed pane thatcontainshowslayout paint styleon the element. - [ ] 3. Size the placeholder from a real measurement. Render one row normally, read its
offsetHeight, and writecontain-intrinsic-size: auto <that value>. Do not guess. A placeholder that is 30% short makes the scroller shrink as the user reads; one that is too tall makes the page report a height it cannot fill. - [ ] 4. Exempt the always-visible rows and the LCP subtree.
content-visibility: visibleon the first one or two rows. The element the Largest Contentful Paint is measured from must never wait on a relevance check, for exactly the reason a lazily loaded LCP image regresses: the work is gated on a post-layout geometry test. - [ ] 5. Audit everything that used to escape the row. Paint containment clips descendants to the padding box and the element becomes a containing block for absolutely and fixed-positioned descendants, plus a stacking context. Sticky sub-headers stop sticking beyond the row, dropdown menus and tooltips get cut off, and outer
box-shadowand focus rings are clipped. Move those into the top layer (popover,<dialog>) or out of the contained subtree. - [ ] 6. Add
loading="lazy"for the bytes. Containment never withholds a fetch: an<img>inside a skipped row is downloaded on schedule. The two mechanisms compose, and shipping only one of them leaves half the win. - [ ] 7. Defer the per-row JavaScript with the state-change event. Build charts, editors and observers when the row stops being skipped rather than on load.
- [ ] 8. Re-measure, then scroll like a user. Compare the three phase totals against the step-1 baseline, flick-scroll at full speed watching for empty boxes, confirm the scrollbar thumb does not shift, and check that
Ctrl+F, a#row-183deep link and print preview all still reach the skipped content.
Step 7 is the one that changes the shape of the page rather than just its numbers:
// Scheduling rationale: this event fires on the rendering opportunity where the
// browser flips a row between skipped and rendered. That is the earliest moment
// the row's widget is worth building and the latest moment it can be built
// without the user seeing a gap — so 240 chart initialisations become the two
// or three that are actually near the viewport.
const supported = 'oncontentvisibilityautostatechange' in document.body;
for (const row of document.querySelectorAll('.row')) {
if (!supported) { buildChart(row); continue; } // no event: keep it correct, not fast
row.addEventListener('contentvisibilityautostatechange', (event) => {
if (event.skipped) {
// The row left the band. Releasing its canvas and observers keeps memory
// flat on a long scroll instead of growing with distance travelled.
teardownChart(row);
} else {
buildChart(row);
}
});
}
Before and after
Measured on the 240-row listing, Chrome with 6× CPU throttling, median of nine cold loads. “After” is steps 2 through 7 applied; nothing about the markup, the CSS payload or the images changed.
| Metric | Before | After | Delta |
|---|---|---|---|
| Recalculate Style, first frame | 214 ms | 38 ms | −82% |
| Layout, first frame | 268 ms | 31 ms | −88% |
| Paint + raster, first frame | 96 ms | 12 ms | −87% |
| Elements laid out at first frame | 9,120 | 190 | −98% |
| Total Blocking Time | 410 ms | 90 ms | −78% |
| First Contentful Paint | 1,940 ms | 1,180 ms | −760 ms |
| INP p75 (filter toggle) | 312 ms | 96 ms | −69% |
| Worst frame during a 3,000 px/s flick | 84 ms | 21 ms | −75% |
| Cumulative Layout Shift | 0.02 | 0.02 | unchanged |
| Bytes transferred before first frame | 2.4 MB | 2.4 MB | unchanged |
The last row is the one to read twice. Containment is a main-thread control and nothing else; the byte count is identical because no fetch was deferred. If the listing had been network-bound rather than layout-bound, this entire exercise would have moved nothing, and the waterfall timing breakdown is where you would find that out before spending the day.
Two side effects are worth naming because they surprise people. First, the INP improvement is larger in proportion than the load improvement: a filter toggle that used to re-lay-out 9,120 elements now re-lays-out the four relevant rows, and interaction latency is dominated by exactly that work. Second, Cumulative Layout Shift did not move — which is only true because step 3 was done properly. A wrong contain-intrinsic-size turns this optimisation into a CLS regression, and it is the single most common way the rollout goes backwards.
FAQ
Why does the scrollbar jump while I scroll a content-visibility: auto list?
The document’s scroll height is the sum of the real heights of rendered rows and the placeholder heights of skipped ones. If contain-intrinsic-size claims 420 px and rows actually render at 640 px, the document grows by 220 px every time a row becomes relevant and shrinks again when it is skipped, so the thumb slides under the user’s finger. Measure a rendered row and use the auto keyword — contain-intrinsic-size: auto 640px — so the browser substitutes each row’s last rendered height once it has seen it. Rows the user has never reached still use the guess, which is why the guess needs to be close.
Does content-visibility: auto hide content from find-in-page, accessibility or search?
No. A subtree skipped by content-visibility: auto stays in the DOM and in the accessibility tree, remains reachable by sequential focus navigation, and is matched by find-in-page — any of which forces it to render immediately. The value that does remove content from all of those is content-visibility: hidden, which behaves much like display: none while caching rendering state for a fast reveal. Use auto for long documents; reserve hidden for tab panels and other content you are deliberately taking out of the page.
Why did content-visibility: auto do nothing on my table rows?
Layout containment does not apply to internal table elements, so on a tr, tbody or td the property computes and then has no effect; the same is true of inline-level boxes and display: contents wrappers, which have no principal block box to contain. Restructure the repeating unit as a block or grid item, or apply the containment to a wrapper element that groups, say, fifty rows into a <tbody>-free section. Check the Computed pane: if contain does not read layout paint style, the element was never a valid boundary.
Related
- Up: Lazy Loading & Viewport-Driven Fetching — the parent topic, covering the
loadingattribute, thresholds and the full rollout - Choosing loading=“lazy” vs IntersectionObserver — the network half of the same problem, and the one containment cannot solve
- Fixing Lazy-Loaded LCP Image Regressions — what happens when a viewport-gated mechanism reaches the element the metric is measured from