Engineering
A Layout Shift That Only Exists While the HTML Is Still Streaming
By Clasify Team · · 5 min read
Our homepage scored 73 on Lighthouse with a Cumulative Layout Shift of 0.351 — comfortably in the red. The confusing part was that the finished page never moved. Load it, watch it, resize it: nothing jumps. Every standard cause was already handled. Images carried width and height. No banner was injected after load. No web font swapped in late.
The shift was real, and it was entirely invisible in the final layout, because it happened while the HTML was still arriving.
CLS is a property of loading, not of layout
Cumulative Layout Shift measures how much visible content moves during the page's life. It is easy to read that as "does my finished design shift?" and go looking for the usual suspects: unsized media, late-injected DOM, fonts.
But the browser does not wait for your document before it starts laying out. It parses and renders progressively. If your HTML is large, the browser paints a partial version of your page, then re-lays it out as more of it arrives. Every one of those re-layouts counts.
That is the gap we fell into. Our marketing pages are prerendered at build time so that a crawler and a first-time visitor get real HTML rather than an empty shell waiting on JavaScript. The homepage is roughly 300 KB of it. That is good for a lot of things — and it opens a window, hundreds of milliseconds wide, in which the browser is laying out a document that is still growing.
One class, most of the score
The hero section was written like this:
min-h-[90vh] flex items-center
Vertically centring the hero inside a minimum-height box. Reasonable, and for the finished page, completely inert: we measured the hero's content at every viewport from 360×640 to 1920×1080, and it is always taller than 90vh. The centring never has any effect on the layout you actually see.
While the document streams, though, it has an enormous effect. With the first 54 pixels of hero content parsed, the browser centres that fragment inside the 90vh box and paints it at y=420. Then the rest arrives — another 812 pixels — and centring the now-taller content puts it at y=128. The block travels 292 pixels upward, and everything below it moves too.
Changing `items-center` to `items-start` removed 0.348 of the 0.351. The content now grows downward from a fixed top edge, which is what it visually does anyway.
You cannot see this on localhost
This is the part worth stealing, regardless of your framework.
A local dev server hands you the document instantly. The streaming window that causes the shift barely exists, so the bug does not reproduce, and any "fix" you try appears to work. Lighthouse on the deployed site will still disagree with you.
We ended up writing a small proxy that served the real production HTML in throttled chunks, so the document arrived over a realistic window. That made the number reproducible to four decimal places, which turned the work from guessing into measuring: change one thing, re-run, see whether the number moved.
If you are chasing a layout shift you cannot reproduce, reproduce the *delivery* first. The bug is usually not in the markup; it is in the timing.
The last 0.14 was two invisible circles
Removing the centring left about 0.14 unaccounted for, coming from two large, blurred background glows.
Those are positioned relative to the hero section — one anchored to its bottom edge, one to its vertical midpoint. So their position is a function of the section's height, and the section's height is exactly the thing that is unstable while the document streams. They were being painted, then moved, repeatedly.
The detail that makes this tractable: fully transparent elements are excluded from layout-shift accounting. These were not fully transparent — they were painted at around 5% opacity, which is plenty to count.
So we hold them at opacity 0 through the unstable window and fade them in afterwards. It is an opacity-only animation, so it cannot itself shift anything, and it starts after the document has settled — meaning the glows appear directly at their final position. The finished hero is pixel-identical to before; we verified that at desktop and mobile widths.
CLS went to 0.003 and the Performance score to 90.
What we would tell another team
- Treat CLS as a question about your loading process, not your design. "Does the finished page move?" is the wrong question and will send you looking in the wrong place.
- Large prerendered or server-rendered documents are a trade. They are usually the right trade — real content in the first response is worth a lot — but they widen the window in which partial layout happens, so centring and viewport-relative positioning behave differently than you expect.
- Anything positioned against a container whose height is still growing is a shift waiting to happen. That includes decorative elements nobody would think of as content.
- Reproduce the network, not just the page. A fix validated on localhost is not validated.
- Keep a reduced-motion path. Our fade-in is pinned to fully visible for anyone who has asked for less motion, so the fix cannot leave them with a permanently invisible element.
None of this required a rewrite. It was 26 lines across two files. The expensive part was not the fix — it was building a way to see the problem honestly.
We do this kind of work for other teams as web development; how we built Clasify covers the wider architecture.
All articles