Every measurement I took said my Cumulative Layout Shift was 0. Lighthouse, a headless Chrome
run, reloading the page and staring at it — all zero. The pages were shifting anyway.
The number was wrong because of how I was loading the page, and the shift itself came from a CSS
selector I would have called harmless.
Why an unthrottled measurement reads zero
Layout shift needs two paints to happen. The browser lays something out, more bytes arrive, and
it lays it out differently.
On a fast connection to a nearby server, the entire HTML document lands in one chunk. There is no
second paint, so there is no shift to record — on a page that genuinely shifts for real users on
real connections. The measurement isn't noisy. It is confidently, reproducibly zero.
You have to slow the page down until the streaming actually streams.
// Drive Chrome over CDP: --headless=new --remote-debugging-port=9222
await client.send('Emulation.setCPUThrottlingRate', { rate: 4 });
await client.send('Network.emulateNetworkConditions', {
offline: false,
latency: 300,
downloadThroughput: (0.5 * 1024 * 1024) / 8,
uploadThroughput: (0.5 * 1024 * 1024) / 8,
});
And record the shifts with the sources attached, because a bare CLS number tells you nothing
about which element moved:
await client.send('Page.addScriptToEvaluateOnNewDocument', {
source: `
window.__shifts = [];
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.hadRecentInput) continue;
window.__shifts.push({
value: entry.value,
sources: entry.sources.map((s) => ({
node: s.node && s.node.nodeName + '.' + (s.node.className || ''),
from: s.previousRect,
to: s.currentRect,
})),
});
}
}).observe({ type: 'layout-shift', buffered: true });
`,
});
Cache-bust the URL on every run. A warm CDN edge undoes the throttling you just configured.
Throttled, the same pages that had measured 0 came back at 0.110 and 0.025.
The selector
The layout is a content column with an optional sidebar. Some pages have the sidebar, some don't,
and the shell was deciding for itself which case it was in:
/* Two columns, unless there is no rail inside me. */
.shell { grid-template-columns: minmax(0, 1fr) 274px; }
.shell:not(:has(.termrail)) { grid-template-columns: minmax(0, 1fr); }
That reads as elegant. The container asks a question about its own contents and answers it. No
prop drilling, no flag to keep in sync, no way for the class and the markup to disagree.
It is also a bet — that the child parses before first paint.
Where that bet loses
The rail is the shell's last child. Here is what that means on a real page of mine:
HTML document 407,197 characters
.shell opens at 11,722
.termrail appears at 134,567 ← 122,845 characters later
The browser sees .shell open, looks inside for .termrail, does not find it yet, and paints
the content column at full width. 122,845 characters later the rail arrives, :has() starts
matching, and the column is re-laid out 274px narrower.
Everything the user had already started reading moves left. That is the 0.110.
The bet wins on a short page — the whole thing arrives before first paint and :has() is right
the first time. It loses in exact proportion to how much HTML you ship, which means it fails worst
on your biggest, most content-heavy pages. Those are usually the ones that matter.
The fix is to move the decision earlier
The information was available on the server the whole time. The only reason it wasn't in the
markup is that :has() made it feel unnecessary.
// The server already knows whether this page has a rail.
<div className={keys.length ? 'shell has-rail' : 'shell'}>
.shell { grid-template-columns: minmax(0, 1fr) 274px; }
.shell:not(.has-rail) { grid-template-columns: minmax(0, 1fr); }
The class now rides the container's opening tag. On that page it lands 122,845 characters ahead
of the element it used to depend on. Re-measured under the same throttling: 0.
The rule I took from it
Not "avoid :has()". The rule is narrower:
Never gate a grid or flex track count on
:has(<descendant>). Put the signal on the container's
opening tag.
Anything that changes how much space siblings get — column counts, track sizes, flex-basis,
display on a wrapper — is a layout decision, and a layout decision keyed off a later element is
a race you lose on long pages.
:has() is fine where being wrong for a moment costs nothing. These are still in my stylesheet
and I have no plans to remove them:
:root:has(.gl-page) { scroll-behavior: smooth; }
Nothing reflows if that flips halfway through the load.
Two things worth keeping
A performance number you measured without throttling is not a measurement. It is a statement
about your connection. The default in headless Chrome is your own network and CPU, and both are
faster than what your users have.
Watch entry.sources, not the score. The total told me there was a problem, once I could see
one at all. It was the previous and current rects on the sources array — a column that was 1180px
wide and became 906px — that named the element and pointed straight at the selector.
This came out of a project that crawls the docs of 15 AI vendors and records every change with
the date it happened: aichangewatch.com
Top comments (0)