Speed Lab: content-visibility on a long page
A page of 140 product blocks drawn in full and with content-visibility auto, with and without a size estimate: layout time, first paint and the scroll bar.
In this guide
The question
A long page can hold thousands of elements. The browser works out the style and layout of all of them when the page loads, although only the first screen can be seen. That work runs on the main thread, before the first paint and in the way of the visitor's first tap. content-visibility: auto is meant to cut it. This test measures how much it cuts on a long page, and what the property does to the page's height and its scroll bar.
web.dev's guide to the property explains how it works. An element with content-visibility: auto gets layout, style and paint containment. While it is off screen and not relevant to the visitor, for example because it holds no focus or selection, it also gets size containment, and the browser skips rendering its contents. As the element nears the screen, the browser draws it. The content stays in the document and in the accessibility tree, so the browser's search still finds it. web.dev's travel blog demo went from 232 ms of rendering to 30 ms. MDN's data lists support from Chrome 85 and Firefox 125, and from Safari 18, with a gap noted below. A browser that does not know the rule draws the page in full, as before.
Size containment has a side effect. The browser lays a skipped block out as if it were empty, so without help it has no height. The contain-intrinsic-size property gives it a stand-in height. Its auto keyword makes the browser remember the real size once the block has been drawn.
The page and the variants
The page has a header, a heading and 140 blocks. Each block is a section with a heading, a paragraph, a list of eight links and a small table: about 55 elements. Lighthouse counts 7,705 elements on the page. The HTML is 206,215 bytes, and the server sends it compressed with gzip, so the transfer is 9.3 KB and the download is not what is being measured. A block is 786 px tall on the 412 px test phone, and the text is plain. I ran the pages as described in how the Speed Lab tests are run: Lighthouse 13.5.0, the mobile preset with devtools throttling, nine runs for each variant, taken in turns.
/* a. in full: no rule */
/* b. auto, with a size estimate */
.card { content-visibility: auto; contain-intrinsic-size: auto 786px; }
/* c. auto, with no estimate */
.card { content-visibility: auto; }Results
First Paint and Largest Contentful Paint were the same moment, because the page is text. Cumulative Layout Shift was 0 in every run. The last two columns come from the main-thread work breakdown in the Lighthouse report.
| Variant | First paint | Style and layout | Rendering | Total Blocking Time |
|---|---|---|---|---|
| a. In full | 777 ms (675 to 833) | 339 ms (281 to 419) | 113 ms | 166 ms (124 to 299) |
b. auto with an estimate | 685 ms (676 to 720) | 32 ms (26 to 44) | 14 ms | 0 ms |
c. auto, no estimate | 764 ms (749 to 841) | 84 ms (73 to 126) | 37 ms | 0 ms |
The total of all main-thread work was 738 ms for variant a, 238 ms for b and 360 ms for c.
Three results stand out.
- Style and layout fell by about 90%, from 339 ms to 32 ms. The ranges do not overlap. That is the same order as web.dev's demo.
- Total Blocking Time went from 166 ms to 0 in both variants that use the property.
- The first paint improved by 92 ms at the median, with a caution. The full page had one fast run of 675 ms, which sits inside the range of variant b, so the ranges overlap. Eight of the nine full-page runs were slower than every run of b (748 to 833 ms, against 676 to 720). I read it as a real but modest gain, because the first paint here is mostly the wait for the page to arrive.
Without an estimate: more work and a short page
Variant c did more work than variant b (84 ms against 32 ms) and kept the page's first paint closer to variant a. I checked in Chromium with a script that reads the page height and counts the blocks the browser draws.
| Variant | Page height at load | Blocks drawn in the first frames | Height after scrolling to the end |
|---|---|---|---|
| a. In full | 111,874 px | 140 | 111,874 px |
| b. Estimate of 786 px | 115,436 px | 3 | 111,874 px |
| c. No estimate | 44,234 px | 51 | 111,874 px |
| d. Estimate of 400 px | 63,274 px | 5 | 111,874 px |
| e. Estimate of 760 px | 111,874 px | 3 | 111,874 px |
Variants d and e were added for this check only. Three things show up.
- With no estimate the blocks collapse. Each skipped block shrank to its padding and border, so the page was only 40% of its true height. That put many blocks inside the screen area at once: 51 were drawn in the first frames. My reading is that once they were drawn they took their real height and pushed the others away, so they were skipped again. Only three stayed drawn. The extra drawing is the likely reason for the extra 52 ms.
- The page height is right only after the scroll. In c and d the page grew while the visitor scrolled, which moves the scroll bar. Chrome's layout shift observer reported 0 in all five variants, because the changes were off screen. A layout shift score does not catch this.
- My estimate was 26 px too tall. I measured a whole block, 786 px, border included. The property stands for the content, and the block's padding and border are added to it, so each skipped block was 812 px and the page started 3.2% too tall. With 760 px, the page had its exact height from the first frame. The Lighthouse runs used 786 px. That does not change the rendering numbers, which depend on how many blocks are drawn.
The auto keyword did its job. Once a block had been drawn, the browser remembered its height, and every variant ended at the same 111,874 px.
What this test does not show
- Scrolling. The browser draws a block as it nears the screen, so some of the work moves to the scroll. I did not measure how scrolling feels on a slow phone.
- Interaction. web.dev says less rendering work can improve Interaction to Next Paint. I did not measure it.
- Search and accessibility. MDN and web.dev say skipped content stays available to find-in-page and to assistive tools. I did not test them. MDN's compatibility data adds that in Safari 18 to 25, text in a skipped block could not be found with find-in-page, and it lists full support for
autofrom Safari 26. - One page shape. The blocks are text with no images. A page whose blocks hold large images has another cost, the download, which this property does not touch.
- One browser. The runs used Chrome. The property is listed for Firefox and Safari in MDN's data, and I did not test those.
- A slow machine. The processor slowdown is relative to this computer. See the method page.
What to do
- Find a long page. Lighthouse's "Optimize DOM size" insight lists the largest pages. Archive pages, product grids, long articles and comment lists are the usual cases. See DOM size and rendering.
- Apply the rule to repeated blocks below the first screen. The blocks in the first screen are drawn anyway.
- Give every block an estimate with
contain-intrinsic-size: auto <height>, using the height of its content, not of the whole box. Round it. Theautokeyword corrects it after the first draw. - Check the page height at load and after scrolling, at the widths your visitors use, because text wraps and blocks change height.
- Do not force the browser to measure skipped blocks. web.dev warns that scripts that read layout from a skipped block make the browser draw it, and the gain is lost.
- Test find-in-page and the keyboard on the finished page.
- Run the audit again and compare the style and layout time, not only the score.
Run it yourself
Save this as make-long-page.mjs and run node make-long-page.mjs 760. It writes the three pages. The number is the estimate for variant b, so use the content height of your own block.
// make-long-page.mjs: node make-long-page.mjs <block height in px>
// Writes long-page/plain, long-page/auto-estimate and long-page/auto-no-estimate: 140 product blocks, the same text in each.
import { mkdirSync, writeFileSync } from 'node:fs';
const height = Number(process.argv[2] || 760);
const block = (i) => {
const links = Array.from({ length: 8 }, (_, j) => `<li><a href="#c${i}-${j + 1}">Detail ${i}.${j + 1}: <span>specification line with some descriptive words</span></a></li>`).join('');
const rows = Array.from({ length: 5 }, (_, j) => `<tr><td>Size ${j + 1}</td><td>${11 + j} cm</td><td>${21 + j} cm</td></tr>`).join('');
return `<section class="card" id="c${i}"><h2>Product ${i}: a plain description</h2><p>This is card number ${i}. It has a heading, a paragraph of text, a list of links and a small table, so that it contains a few dozen elements, like a typical block in a catalogue or a long article.</p><ul>${links}</ul><table><thead><tr><th>Size</th><th>Width</th><th>Height</th></tr></thead><tbody>${rows}</tbody></table></section>`;
};
const blocks = Array.from({ length: 140 }, (_, i) => block(i + 1)).join('');
const css = `*{box-sizing:border-box}body{margin:0;font:16px/1.5 Arial,sans-serif;color:#222}header{padding:12px 16px;background:#0b3a6e;color:#fff}
main{max-width:720px;margin:0 auto;padding:0 16px}h1{font-size:26px;margin:14px 0}.card{border:1px solid #d5dbe5;border-radius:6px;padding:12px;margin:12px 0}
.card h2{font-size:18px;margin:0 0 8px}.card ul{margin:8px 0;padding-left:20px}.card li{margin:2px 0}table{border-collapse:collapse;width:100%}td,th{border:1px solid #ccc;padding:4px 6px;text-align:left}`;
const page = (extra) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Long page test</title><style>${css}${extra}</style></head><body>
<header>Example Shop</header><main><h1>The full range, 140 products on one page</h1>${blocks}</main></body></html>`;
const variants = {
plain: '',
'auto-estimate': `.card{content-visibility:auto;contain-intrinsic-size:auto ${height}px}`,
'auto-no-estimate': '.card{content-visibility:auto}',
};
for (const [name, extra] of Object.entries(variants)) {
mkdirSync(`long-page/${name}`, { recursive: true });
writeFileSync(`long-page/${name}/index.html`, page(extra));
}To measure it, use the runner on the method page: GZIP=1 node lab-runner.mjs long-page 9 first-contentful-paint,total-blocking-time,style-and-layout. The last name reads the style and layout time from the main-thread work breakdown.
To see the page height and the number of blocks the browser draws, install Playwright with npm install playwright and run this against each page with node check-long-page.mjs long-page/auto-estimate/index.html.
// check-long-page.mjs: the page height and the number of blocks drawn, at load and after scrolling to the end
import { chromium } from 'playwright';
import { resolve } from 'node:path';
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 412, height: 823 } });
await page.addInitScript(() => {
window.drawnEver = new Set();
document.addEventListener('contentvisibilityautostatechange', (event) => { if (!event.skipped) window.drawnEver.add(event.target.id); }, true);
});
await page.goto('file://' + resolve(process.argv[2]));
const height = () => page.evaluate(() => document.documentElement.scrollHeight);
const drawn = () => page.evaluate(() => [...document.querySelectorAll('section.card')].filter((card) => card.querySelector('h2').checkVisibility({ contentVisibilityAuto: true })).length);
console.log('at load: height', await height(), 'drawn now', await drawn(), 'drawn in the first frames', await page.evaluate(() => window.drawnEver.size));
for (let y = 0; y < (await height()) - 823; y += 700) {
await page.evaluate((top) => window.scrollTo(0, top), y);
await page.waitForTimeout(30);
}
console.log('after scrolling to the end: height', await height());
await browser.close();Mistakes to avoid
- Using the property with no size estimate. The page gets short, the scroll bar lies, and the browser draws more than it needs to.
- Measuring the whole box for the estimate. The padding and border are added to it.
- Putting the rule on the first screen, or on the whole
body. The first screen is drawn anyway, and a rule on the body skips nothing useful. - Trusting a layout shift score to catch a jumping scroll bar.
- Judging only by the first paint. The gain here is in the work on the main thread.
Check your own page
Run the free audit and open the DOM size insight. If your page has thousands of elements, try the rule on the repeated blocks below the first screen, with an estimate, and run the audit again. Compare the style and layout time and the Total Blocking Time.
Sources
- content-visibility: the new CSS property that boosts your rendering performance on web.dev: what
autodoes, size containment,contain-intrinsic-sizeand itsautokeyword, the travel blog demo, the accessibility note and the warning about forcing layout. - content-visibility and contain-intrinsic-size on MDN: the values, the accessibility notes and browser support.
- The Lighthouse 13.5.0 main-thread work breakdown and DOM size insight, as reported on the test pages.
Start here
Free website audit
Live Google data for any public page: mobile and desktop.
Mobile + desktop · Lighthouse lab test · Real-user Chrome data when Google has it
Rather have this done for you? Speed optimization · Book a call