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.

IntermediateHow-to8 min readUpdated October 30, 2026

In this guide
  1. The question
  2. The page and the variants
  3. Results
  4. Without an estimate: more work and a short page
  5. What this test does not show
  6. What to do
  7. Run it yourself
  8. Mistakes to avoid
  9. Check your own page
  10. Sources

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.

VariantFirst paintStyle and layoutRenderingTotal Blocking Time
a. In full777 ms (675 to 833)339 ms (281 to 419)113 ms166 ms (124 to 299)
b. auto with an estimate685 ms (676 to 720)32 ms (26 to 44)14 ms0 ms
c. auto, no estimate764 ms (749 to 841)84 ms (73 to 126)37 ms0 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.

  1. 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.
  2. Total Blocking Time went from 166 ms to 0 in both variants that use the property.
  3. 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.

VariantPage height at loadBlocks drawn in the first framesHeight after scrolling to the end
a. In full111,874 px140111,874 px
b. Estimate of 786 px115,436 px3111,874 px
c. No estimate44,234 px51111,874 px
d. Estimate of 400 px63,274 px5111,874 px
e. Estimate of 760 px111,874 px3111,874 px

Variants d and e were added for this check only. Three things show up.

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

What to do

  1. 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.
  2. Apply the rule to repeated blocks below the first screen. The blocks in the first screen are drawn anyway.
  3. Give every block an estimate with contain-intrinsic-size: auto <height>, using the height of its content, not of the whole box. Round it. The auto keyword corrects it after the first draw.
  4. Check the page height at load and after scrolling, at the widths your visitors use, because text wraps and blocks change height.
  5. 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.
  6. Test find-in-page and the keyboard on the finished page.
  7. 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

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

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

Related guides

All guides →