Preventing Layout Shift from Late CMS Content
This guide, part of Core Web Vitals Optimization, deals with a class of layout shift that image dimensions do not fix: content that arrives after the first paint. Personalized blocks, announcement banners, A/B test variants, streamed sections, client-fetched widgets and consent notices all insert or resize content once the page is visible, and every insertion above the reader’s position pushes content down.
Headless architectures make late content common, because they encourage composing pages from several sources and rendering parts of them on the client. Each part can be fast on its own, but if it arrives after the first paint without a reserved place, the page jumps. Readers lose their place, tap the wrong link, and Cumulative Layout Shift climbs above the 0.1 threshold.
The Problem
An e-commerce site’s product pages had a p75 CLS of 0.27 on mobile despite correctly sized images. Recordings showed three shifts on most loads: a promotional banner fetched from the CMS by a client component appeared at the top after about 800 milliseconds; a stock-availability widget grew from nothing to two lines; and an A/B test replaced the product description block with a variant that was 120 pixels taller. Each shift alone was small, but together, and all above or around the fold, they failed CLS on most sessions.
How to Prevent Late-Content Shifts
Render on the server where possible. Site-wide banners come from the CMS and change rarely; fetch them on the server with the page and cache them with a tag. The banner is then part of the first paint.
Reserve fixed slots for client content. When content must load on the client, reserve its space in the server HTML: a slot with the expected height, using a skeleton or a neutral placeholder. The widget fills the slot without changing its size, or changes it only below the viewport.
Decide variants before rendering. A/B tests and personalization should choose the variant at the edge or on the server, so the first paint already shows the final variant. Client-side swapping after paint almost always shifts layout.
Match streamed fallbacks to content. With streaming server rendering, suspense fallbacks must have the same size as the content that replaces them, or at least not be smaller.
Overlay, do not insert. Consent notices and similar prompts should appear as fixed overlays at the bottom of the viewport, not inserted at the top of the document.
Implementation
The site-wide banner is fetched on the server with the page and rendered in the layout. It is cached with a tag so publishing a new banner revalidates it.
// app/[locale]/layout.tsx (excerpt)
import { getAnnouncement } from "@/lib/cms/announcement";
export default async function LocaleLayout({ children, params }: { children: React.ReactNode; params: Promise<{ locale: string }> }) {
const { locale } = await params;
const announcement = await getAnnouncement(locale); // fetch tagged "announcement:{locale}"
return (
<>
{announcement && <div className="announcement" role="region" aria-label="Announcement">{announcement.text}</div>}
{children}
</>
);
}
Client-side widgets render into a slot whose size is known in advance. The slot’s height comes from the CMS block settings or a design constant.
// components/stock-slot.tsx
"use client";
import useSWR from "swr";
export function StockSlot({ sku }: { sku: string }) {
const { data } = useSWR(`/api/stock/${sku}`, (u: string) => fetch(u).then((r) => r.json()));
return (
<div className="stock-slot" style={{ minHeight: "3rem" }} aria-live="polite">
{data ? <p>{data.inStock ? "In stock, ships tomorrow" : "Out of stock"}</p> : <span className="skeleton-line" aria-hidden="true" />}
</div>
);
}
For streamed sections, give the suspense fallback the same dimensions as the loaded content.
// app/[locale]/products/[slug]/page.tsx (excerpt)
import { Suspense } from "react";
<Suspense fallback={<div className="reviews-skeleton" style={{ minHeight: 420 }} aria-hidden="true" />}>
<Reviews productId={product.id} />
</Suspense>
A/B variants are chosen in middleware from a cookie, and the variant id is passed to the page, which renders the chosen block on the server. The client never swaps content after paint.
Designing slots with content teams
Reserved slots need predictable content sizes, which is partly an editorial question. A banner that may contain one line or four cannot have a fixed height. Agree on constraints: announcement text limited to one line on mobile, product badges limited to two, recommendation carousels with a fixed card height. Encode them as validation in the CMS where possible. Where content genuinely varies, reserve the most common size and let rare larger content expand below the viewport, or truncate with a “more” control.
Measuring real content heights
Reserved slots only work when their sizes match real content, and real content changes. Measure it rather than guessing: a small script in staging, or a sample of production page views, records the rendered height of each slot’s content per breakpoint and locale, and reports the distribution. Set the reserved height at a value that covers most cases, typically the 90th percentile, and revisit it when designs or content rules change. German and Finnish text often needs taller slots than English; right-to-left layouts sometimes change heights too. Reviewing these numbers once a quarter keeps slots accurate without constant tuning.
Configuration Reference
| Late content | Treatment | Notes |
|---|---|---|
| Announcement banner | server-rendered, tagged cache | Part of first paint. |
| Personalization, A/B tests | variant chosen at the edge | No client swap. |
| Stock, prices, reviews | reserved slot with skeleton | Height from design or CMS. |
| Streamed sections | fallback with matching height | Measure real content heights. |
| Consent notice | fixed overlay at the bottom | Never inserted at the top. |
| Ads | fixed-size containers | Reserve the largest common size. |
Gotchas & Edge Cases
- Shifts during scrolling. Content that expands below the viewport does not count towards CLS, but content that expands above it does, even after the reader scrolled. Lazy-loaded blocks above the reader’s position must have reserved height.
- Fonts in slots. A slot sized for fallback font metrics can overflow when the web font loads. Use matched fallback fonts or slightly generous slots.
- Hidden-then-shown content. Content hidden with
display: noneand shown after hydration shifts layout. Render its final state on the server. - Interaction exemption. Shifts within 500 milliseconds of a user interaction are excluded from CLS; expanding an accordion on click is fine, unprompted expansion is not.
Worked Example
The e-commerce site moved the promotional banner to server rendering with a tagged cache, gave the stock widget a fixed 48-pixel slot with a skeleton, and moved its A/B test to edge middleware so variants rendered on the server. Mobile p75 CLS on product pages fell from 0.27 to 0.05 in the next field window. The banner also appeared faster, since it no longer waited for client JavaScript, and the experiment’s results became more reliable because every visitor saw only one variant from the first paint.
Finding the Shifts
Before fixing, find out which elements move. In the lab, the browser’s performance panel highlights layout shift regions, and recording a slow mobile load of each template usually reveals the main culprits in minutes. In the field, the web-vitals attribution build reports the element responsible for the largest shift in each page view; resolving it to the nearest block or component, as described in measuring Core Web Vitals per content type, tells you which blocks cause shifts for real users and how often. Field data matters here because many late-content shifts depend on network timing and personalization that lab tests do not reproduce, such as a banner that appears only for returning visitors or a variant shown to a fraction of traffic.
Rollout Checklist
- List every block that can appear after first paint on each template.
- Render site-wide CMS content on the server with tagged caching.
- Choose personalization and experiment variants before rendering.
- Reserve fixed slots with skeletons for client-fetched widgets.
- Match streamed fallback sizes to real content heights.
- Show consent notices and prompts as overlays.
Frequently Asked Questions
Are skeletons bad for perceived performance?
No, when they have the right size. They signal that content is coming and prevent shifts when it arrives. Avoid long-lived skeletons for content that could simply have been rendered on the server in the first place.
Can CSS alone fix layout shift?
For known sizes, yes: min-height, aspect-ratio and fixed containers. For unknown sizes, the fix is architectural, rendering earlier or placing content differently.
Do overlays affect other metrics?
A large overlay can become the LCP element. Keep consent notices modest in size and render them in HTML.
Does server rendering personalization hurt caching?
It splits the cache by variant, which is manageable for a few variants chosen at the edge. For many personalized variants, render a shared page and reserve fixed-size slots for the personalized parts.
What about ads?
Reserve the most common ad size in a fixed container and collapse it only when no ad is served and the container is below the viewport.