content-visibility: auto is the cheapest big performance win on a long page. It also moves your content under people's fingers if the size estimates are guesses.
I found that out on this site's homepage. A hover test failed about one run in four, and the trail led to a chunk that was estimated at 800px and rendered at 1756px. Every time it woke up, the page grew by 956px below the fold and everything above it stayed put, until the moment a chunk above the pointer woke up too.
This is the measurement I should have done in the first place, and the script that does it in twenty seconds.
Last verified against Chrome 149 headless, Next.js 16.2, the nyk.dev homepage. Version-specific details drift — check the vendor docs before relying on an exact flag or limit.
Why guessing the intrinsic size fails
The usual pattern looks harmless:
.below-fold {
content-visibility: auto;
contain-intrinsic-size: 520px;
}Pick a round number, apply it to every deferred section, ship. The initial scroll range looks about right, Lighthouse improves, and nothing visibly breaks on the first scroll.
What that rule actually says is: "until this chunk renders, lay it out as if it were 520px tall." The browser believes you. When the chunk comes within the viewport's proximity margin it renders for real, and the document height changes by the difference. A real height of 264px means everything below jumps up 256px. A real height of 1756px drops it by 956px.
Users usually scroll past that moment, so you do not see it as a jump. You see it as a page that feels slightly off, links that miss on the first tap, or a scroll position that lands somewhere you did not choose. And on this site, one automated hover that kept landing on the wrong card.
By the end you will have
- A script that prints, per chunk, the estimate, the initial laid-out height, and the rendered height at desktop and phone widths.
- The Below-Fold Estimate Table for your own page, with the diffs that need attention circled.
- A rule for when a chunk needs its own modifier class instead of a shared size.
- The connection between estimate drift and pointer-based test flakes, so you recognise it next time.
The document reflows by rendered minus estimate, once per chunk
Here is the before table from the nyk.dev homepage at 1440px. The estimate column is what the CSS declared; rendered is what the chunk measured after scrolling the whole page.
| Chunk | Estimate | Rendered | Diff |
|---|---|---|---|
| Tagline | 520px | 511px | -9 |
| Ventures + work | 800px | 1756px | +956 |
| Funnel strip | 520px | 264px | -256 |
| Newsletter | 520px | 435px | -85 |
| About | 520px | 440px | -80 |
| Articles | 800px | 944px | +144 |
| Contact | 520px | 418px | -102 |
| Ask | 240px | 223px | -17 |
| Footer | 800px | 479px | -321 |
Two things in that table are the whole lesson.
The work chunk had a modifier class, .below-fold--work, with a mobile value of 3200px and no desktop value at all. On desktop it fell through to .below-fold--xl at 800px. Nobody wrote "800" for that section; it inherited it.
The funnel strip and the footer both shared a generic size that was roughly double their real height. The generic number was fine for the sections it was written for, and wrong for the ones added later.
After measuring, every desktop chunk sits within 17px of its estimate and every phone chunk within 43px. The hover test went from failing one run in four to 16 of 16 with retries off, without touching the test's assertions.
Magnet: Below-Fold Estimate Table
Run this from the repo that has Playwright installed, against a running dev or production server. It scrolls the page once to force every chunk to render, then prints the table.
// .devgod/below-fold-measure.mjs
import { chromium } from "@playwright/test";
const url = process.env.PROBE_URL ?? "http://127.0.0.1:3005/";
const browser = await chromium.launch();
for (const [name, viewport] of [
["desktop", { width: 1440, height: 1000 }],
["mobile", { width: 390, height: 844 }],
]) {
const page = await browser.newPage({ viewport });
await page.goto(url, { waitUntil: "load" });
const before = await page.evaluate(() =>
[...document.querySelectorAll(".below-fold")].map((el) => ({
id: el.id || el.querySelector("section[id]")?.id || el.className,
estimate: getComputedStyle(el).getPropertyValue("--below-fold-intrinsic").trim(),
initial: Math.round(el.getBoundingClientRect().height),
})),
);
for (let y = 0; y < 12000; y += 400) {
await page.evaluate((y) => window.scrollTo(0, y), y);
await page.waitForTimeout(60);
}
const after = await page.evaluate(() =>
[...document.querySelectorAll(".below-fold")].map((el) =>
Math.round(el.getBoundingClientRect().height),
),
);
console.log(`== ${name}`);
before.forEach((b, i) =>
console.log(`${b.estimate} est | ${b.initial} initial | ${after[i]} rendered | diff ${after[i] - parseInt(b.estimate)} | ${b.id}`),
);
await page.close();
}
await browser.close();It assumes your estimate lives in a custom property (--below-fold-intrinsic) so the script can read it back. For a literal in contain-intrinsic-size, read that property instead.
You should see: one line per chunk and width. initial should equal the estimate (that is the browser honouring your number). diff is what you fix: give any chunk over ~50px its own modifier class with the rendered height at that breakpoint, and re-run until the column is small.
Three rules that fell out of doing it:
- Every modifier that has a mobile value needs a desktop value. Inheritance is not a default, it is a wrong number.
- Sections added after the sizes were written get their own class. Sharing a size is a claim that two sections are the same height.
- Re-run after any layout change to a deferred section. The numbers are measured at two widths and drift in between; that is accepted, the point is to be close, not exact.
Failure modes
The generic size. One 520px on every deferred section. It was right for the first three and wrong for the next six.
The half-defined modifier. A class with a mobile value inside a media query and nothing outside it. On desktop it silently inherits whatever the parent class says.
Measuring the initial height instead of the rendered one. getBoundingClientRect() before activation returns the estimate. You have to scroll the chunk into view, then measure.
Fixing the test, not the page. Adding a retry or a waitForTimeout to the hover test would have made it pass and left the shift in production. The test was reporting something true.
Trusting auto <length>. On mobile Chromium, contain-intrinsic-size: auto 520px reused an initial zero-size observation before activation and collapsed the scroll range to one viewport. A fixed length behaved.
When not to bother
Do not measure a page with two or three deferred sections that each render close to one viewport tall. The reflow is small and the user has scrolled past it.
Do not add content-visibility: auto to reach a Lighthouse number on a page that is already short. The cost is estimate maintenance forever; the gain is a few milliseconds of layout you were not paying.
Do not use it at all on content that scripts measure at load time (sticky offsets, scroll-spy, anchors computed from element positions). The measured positions are estimates until activation.
Your estimates are the layout until the browser proves otherwise
content-visibility: auto asks you to draw the page before it exists. Draw it from measurements, not from a number that looked reasonable when the page had four sections.
If the page you are fixing is the front door of a product with agents behind it, the same discipline applies one layer down: the Six-Gate Coding-Agent Control Matrix is the version of this table for what an agent is allowed to change.
Your next action: run the Below-Fold Estimate Table against your longest page at two widths and give every chunk over 50px off its own measured size.







