A Shopify fashion homepage stacks Sanity CMS sections that stream in through Next.js. Vercel Speed Insights watches cumulative layout shift. When a section streamed in from height zero, the product grid punched the thumb. P75 could stay green while the tail ate the page.
Homepage layout: grid, fold, leftover strips. The median visitor on a warm cache never sees the stampede. The P95 visitor does. They call it jank. Speed Insights calls it Poor CLS.
Isolated recovery came with height zero
I wrapped every Sanity CMS section on that storefront in a 'use client' error boundary, and inside it sat an empty React <Suspense>. That boundary is load-bearing: Next.js recovers an async server throw only at a Suspense edge, and without one the whole shell 500s. I wanted isolated recovery. I also got height zero.
The homepage is a stack of async Sanity server sections. While each one resolved, the fallback rendered nothing, zero height, then the HTML arrived and the block appeared. One section is a twitch. A page of Sanity sections is a stampede.
The empty fallback felt honest. Nothing to show yet, so show nothing. The shell stayed up. Recovery still worked. Shoppers on a cold cache watched the page assemble under their finger anyway.
P75 hid the tail
Vercel Speed Insights told two stories. P75 CLS stayed 0 and P95 CLS went Poor, so P75 is the wrong success signal. The median visitor never saw the stream: cached HTML, warm RSC payload, fast region.
The tail did: cold cache, slow CMS, first paint of collapsed slots. Those people watched the product grid, the fold, and the leftover strips pop in under their thumb. That is a layout shift. Recovery still happened. The layout did not hold.
Lighthouse on a warm laptop agrees with P75. The people who pay for the clothing site are not all on that laptop. I needed reserved height, and I needed to look at P95.
Height keyed by section type
An empty fallback shows nothing, including the height the grid needs. I keyed the helper on section type.
The collection grid, sectionSearchFiltersGrid, gets min-h-[80vh]. Large fold sections get min-h-80. Everything else gets min-h-32. Only the product-grid section is critical: it rethrows to the route boundary instead of returning null. A Shopify storefront without a grid is not a storefront, and a storefront without a promo strip still is.
In the demo, toggle Height 0 fallback vs Reserved min-height. Height zero looks clean until content lands and shoves the layout. Reserved min-height looks slightly empty, then stays put when the section resolves.
Height 0 vs type-keyed min-height
Empty fallbacks collapse the Shopify storefront, then shove every Sanity CMS section down the page. Reserved height holds the slots. Demo boxes are scaled. Production uses the real classes on the labels.
Fallbacks are height 0. Stream to watch the Shopify storefront reconstruct under the fold.
export function sectionSuspenseFallbackClassName(sectionType: string): string {
if (sectionType === 'sectionSearchFiltersGrid') return 'w-full min-h-[80vh]';
if (LARGE_FOLD_SECTION_TYPES.has(sectionType)) return 'w-full min-h-80';
return 'w-full min-h-32';
}
// inside the client error boundary
return (
<Suspense fallback={<div aria-hidden className={sectionSuspenseFallbackClassName(sectionType)} />}>
{children}
</Suspense>
);The type map is a guess about typical height. A short min-h-32 strip that comes back as a tall campaign will still shift a little. Oversized fallbacks that collapse when real content is shorter are also CLS. I would rather tune the map than go back to height zero, and I would rather a small leftover shift than min-h-svh on every block.
P75 will stay smug. I watch P95 until the tail stops punching the grid.
Happy coding! Sander