Back to the Lab

The Lab · CSS

I Measured content-visibility: auto on 3,000 Cards

I rendered 48 to 3,000 product cards in headless Chromium with and without content-visibility. Long pages win big, the copied one-liner can lose.

RAXXO Studios 9 min read
TLDR This entry in one minute

Each line jumps to its section

  • At 3,000 cards the first frame dropped from 144 ms to 38 ms in headless Chromium 145
  • Without contain-intrinsic-size, Chromium rendered 444 cards instead of 16, and a 200-card page got slower
  • A 360 px height guess made the page 36 percent too short and broke scroll restore
  • The layout work moves to scroll time: 1.6 seconds spread over 560 steps, about 3 ms each

On a page with 3,000 product cards, one CSS property cut the time to the first frame from 144 ms to 38 ms in headless Chromium. Written the way most snippets show it, the same property rendered 444 cards when 16 were on screen and made a 200-card page slower than doing nothing at all.

I ran four variants through Playwright and read the numbers off the browser. The fix for the bad one is a second line of CSS, and the height you put in it matters more than I expected.

The Test: 3,000 Product Cards in Headless Chromium

The page is a plain product grid. Each card has an image box with a 4:5 aspect ratio, a badge, a title, a description of 18 to 34 words, a row of small tags and a price row with a button. That's 14 elements per card, laid out with grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)), which gives four columns at 1280 px wide.

A Node script opened a fresh page for every run, inserted the cards with innerHTML and measured two things. First, the time until the browser had produced a frame (two requestAnimationFrame callbacks after the insert). Second, the layout and style time Chromium reports through the DevTools protocol's Performance.getMetrics. 15 runs per variant, median reported, Chromium 145 on an M3 Pro laptop, viewport 1280 by 800.

The variants differ in one rule on .card:


/* A: nothing */

/* B: the snippet everyone copies */
.card { content-visibility: auto; }

/* C: with a height guess that's too small */
.card { content-visibility: auto; contain-intrinsic-size: auto 360px; }

/* D: with a guess close to the real card */
.card { content-visibility: auto; contain-intrinsic-size: auto 580px; }

Results at 3,000 cards:

Variant First frame Layout Cards rendered Page height
A, nothing 144.2 ms 81.3 ms 3,000 448,033 px
B, auto only 52.9 ms 21.6 ms 444 77,886 px
C, auto 360px 33.8 ms 10.3 ms 24 284,836 px
D, auto 580px 38.1 ms 9.3 ms 16 448,502 px

Variant D renders 16 cards at load: the rows on screen plus the ones right next to it. The other 2,984 are skipped. They get no layout and no paint, but they stay in the DOM. Layout time falls from 81 ms to 9 ms, and the page height lands within 0.1 percent of the real one.

I also ran it with the CPU throttled 4x through the same protocol, as a rough stand-in for a mid-range phone (it's an approximation, not a device). The gap grew: 640 ms to the first frame for variant A, 161 ms for variant D.

C and D are within noise of each other on speed. The difference between them shows up later, when someone scrolls.

The Snippet Everyone Copies Rendered 444 Cards

Variant B is content-visibility: auto on its own. At 3,000 cards it still beat doing nothing, 144 ms down to 53 ms. Look at the rendered count, though: 444 cards for a screen that shows 8.

A skipped element gets size containment. Without a size hint, that means it's treated as zero pixels tall, plus its border. At the first layout the whole 3,000-card grid measured 13,516 px, nearly all of it gaps. Hundreds of flat cards fit inside the viewport and the margin the browser keeps around it, so Chromium rendered 444 of them, found out how tall they really were and laid everything out a second time. After that first frame the page was 77,886 px tall, and it kept growing while I scrolled. Ten steps in, the scrollbar thumb sat at 10.4 percent of the track when the true position was 1.8.

On a small page that second pass costs more than the skipping saves. Time to first frame across page sizes, full CPU speed:

Cards A, nothing B, auto only D, auto 580px
48 11.0 ms 13.0 ms 11.2 ms
200 14.4 ms 16.8 ms 10.9 ms
1,000 51.6 ms 32.9 ms 14.3 ms
3,000 144.2 ms 52.9 ms 38.1 ms

At 200 cards the copied snippet made the page slower.

With the 4x throttle it was the same story, 59.3 ms without the property and 64.1 ms with it. Variant D, on that same throttled 200-card page, came in at 31.6 ms.

At 48 cards, roughly one page of a product collection on a Shopify store, nothing measurable happened at full speed. Throttled, D saved about 6 ms (30.8 down to 25.1).

So below a couple of hundred items I don't add it. A 48-product grid has bigger problems to fix first, usually images and scripts, and the changes that actually moved a real theme are in Shopify Theme Performance: From 62 to 98 Lighthouse in One Weekend.

Guess the Height, Then Let the Browser Remember It

contain-intrinsic-size gives a skipped element a placeholder size. Variant C guessed 360 px. The real cards averaged 583 px at 1280 wide, so the page came out 284,836 px tall instead of 448,033. That's 36 percent short.

You can see it in the scrollbar. After ten viewport-sized scroll steps the thumb sat at 2.8 percent of the track instead of the true 1.8 percent, so it was moving about 56 percent too fast. The page height changed on 532 of 560 scroll steps as real sizes replaced the guesses.

The auto in auto 360px tells the browser to remember an element's last rendered size and use it instead of the placeholder when the element gets skipped again. web.dev documents it that way, and after one full scroll variant C's page had the right height. One quirk: in Chromium 145 the version without auto remembered as well. I don't know if other engines do the same, so I keep writing auto.

The trap that bit hardest was scroll restoration. I scrolled down to card 2,400 the normal way, saved window.scrollY (359,200), loaded a fresh page and called scrollTo with that number.

  • Variant A landed on the exact card.
  • Variant D landed one row off, card 2,400 instead of 2,404.
  • Variant C's fresh page was too short. The browser clamped the scroll at 283,994 px and showed card 2,992, near the very end.
  • Saving the id of the top card and calling scrollIntoView put all three on the right card.

// save before leaving the page
const top = [...document.querySelectorAll(".card")]
  .find((c) => c.getBoundingClientRect().bottom > 0);
sessionStorage.setItem("grid-top", top.id);

// restore on the way back
document.getElementById(sessionStorage.getItem("grid-top"))?.scrollIntoView();

To get a good guess, measure a few rendered cards with getBoundingClientRect().height at your breakpoints. Mine were 583 px at 1280 wide and 631 px at 390 wide, so the value belongs in a media query:


.card { content-visibility: auto; contain-intrinsic-size: auto 630px; }
@media (min-width: 1024px) {
  .card { contain-intrinsic-size: auto 580px; }
}

Where the Work Goes When Someone Scrolls

Skipped work comes back the moment a card scrolls into view. To see how much, I scrolled each variant from top to bottom in 800 px steps, 560 of them, and read the layout counter again.

Variant A spent 0 ms on layout during the scroll, because it had paid 81 ms up front. Variant D spent 1,646 ms, spread across the 560 steps, which is about 3 ms per step. Its slowest frame took 24.6 ms against 18.4 ms for variant A, while 95 percent of its steps stayed under 17.6 ms. Most of the deferred work fit inside a normal 60 Hz frame.

Someone who scrolls all 3,000 cards makes the page do about 20 times more layout in total. Nobody scrolls all 3,000 cards, and that's the bet you're making.

It also isn't an INP fix. The Lab already tried it in the field: on the below-the-fold sections of a real store, INP didn't budge, because the slow part was JavaScript in the interaction handlers. That write-up is Six Months of Shopify Web Vitals Data What Actually Moved INP. Treat it as a load-time tool.

Skipped content stays in the DOM and the accessibility tree, and MDN says it remains available to find-in-page and tab order. What you can't do is ask a skipped element for its real size. A far-away card with the 360 px guess reported 362 px while skipped (the guess plus a 1 px border top and bottom). Any script that measures offscreen elements, like a masonry layout or a sticky offset, will get the placeholder. Check first:


const skipped = !card.firstElementChild.checkVisibility({ contentVisibilityAuto: true });

That check is how I counted the 16 rendered cards in the first place. If you'd rather react than poll, contentvisibilityautostatechange fires whenever an element's rendering starts or stops being skipped.

Support is broad now: web.dev's table lists Chrome and Edge from 85, Firefox from 125 and Safari from 18.

Older browsers ignore the property and render everything, which is variant A. Nothing breaks.

Bottom Line

Put content-visibility: auto on long lists and never on its own. The second line, contain-intrinsic-size: auto plus a measured height, is what turned a 444-card render into a 16-card one and kept the page height within 0.1 percent of the truth.

The places I'd use it are the long ones: a blog archive, a changelog, a comment thread, search results that keep growing with "load more". A 48-product grid or anything above the fold gets nothing from it, and the copied one-liner can make a 200-item page slower.

Two habits come with it. Measure the card height at your smallest and largest breakpoint instead of guessing. And if your page restores scroll position, save the id of the top item, because a saved pixel offset means a different place once the page height is partly an estimate.

Count the repeated items on your longest page. If it's under 200, spend the time on scripts instead. If it's over, what does one of those items measure at 390 px wide?

This article contains affiliate links. If you sign up through them, I may earn a small commission at no extra cost to you. (Ad)

Get the next entry by mail
One mail when a new entry lands. No spam. Unsubscribe anytime.
RAXXO Studios

Written by

RAXXO Studios

One designer in Berlin, close to twenty years in. I build tools with AI, use them daily, and write down what happened.

Share this entry

X LinkedIn
All entries