Fixing CLS From Late-Loading Embeds
Once images have dimensions and fonts stop reflowing, the remaining layout shift on a static site comes from things that were never in the build output: a comment thread fetched on scroll, a video player injected by a script, a map that hydrates into an empty div, a consent banner prepended to the body. All of them share one property — they take up space that was not reserved for them.
This guide covers the reservation patterns for each type of embed, the overlay pattern for banners, and the measured result on a documentation page carrying three of them. It is part of Cumulative Layout Shift Fixes for Static Sites.
Prerequisites
- A page where images and fonts are already stable, so the remaining score is attributable to embeds.
- Chrome DevTools Performance panel, or the Layout Instability script from the parent guide, to name the shifted node.
- The ability to change the markup around the embed — most third-party snippets can be wrapped even when they cannot be modified.
The Four Shapes of Embed Shift
The Recipe
1. Emit the container at build time
The generator already knows the widget will be there — the only unknown is its height. Emit the box during the build and let the script fill it:
<section class="comments" data-comments="post-42" style="min-height:480px">
<p class="comments-loading">Loading discussion…</p>
</section>
The placeholder text is not decoration: it keeps the box from looking broken while the fetch runs, and it gives readers on failed loads something honest to read.
2. Take overlays out of flow
Anything that is not part of the article should not be in the article's flow:
.consent {
position: fixed;
inset: auto 0 0 0; /* pinned to the bottom, over the page */
z-index: 50;
}
A fixed element participates in no layout calculation, so it can appear at any moment for free. This one change is usually the largest single CLS win on a site with a consent banner.
3. Give iframes a ratio, always
<div class="embed embed-video">
<iframe src="https://player.example.com/v/abc" title="Deploy walkthrough"
loading="lazy" allowfullscreen></iframe>
</div>
.embed { position: relative; width: 100%; }
.embed-video { aspect-ratio: 16 / 9; }
.embed-map { aspect-ratio: 4 / 3; }
.embed > iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
4. Prefer click-to-load for heavy players
A third-party video player is typically 400-900 KB of JavaScript. Replacing it with a poster image and a play button gives you a naturally fixed box, removes the payload from the initial load, and only pays the cost for readers who press play:
<button class="video-facade" data-video="abc" aria-label="Play: Deploy walkthrough">
<img src="/img/poster-abc.avif" width="1280" height="720" alt="">
<span class="video-facade-play" aria-hidden="true">▶</span>
</button>
document.querySelectorAll('.video-facade').forEach((el) => {
el.addEventListener('click', () => {
const frame = document.createElement('iframe');
frame.src = `https://player.example.com/v/${el.dataset.video}?autoplay=1`;
frame.title = 'Deploy walkthrough';
frame.allow = 'autoplay; fullscreen';
el.replaceWith(frame);
}, { once: true });
});
Because the replacement happens inside 500 ms of a click, the swap is inside the user-interaction exclusion window and costs nothing even though the sizes differ slightly. The byte savings are covered further in Lazy-Loading YouTube Embeds on Static Sites.
5. Never insert above existing content
If content must arrive late and cannot be reserved — a live status strip, a personalised recommendation row — append it below the fold or render it into a fixed overlay. Prepending is the single most expensive thing a script can do to a page's layout.
There is one exception worth knowing: an element inserted above content that has not painted yet costs nothing, because nothing moved. That is why server-rendering the banner into the build output is strictly better than injecting it, even when the banner itself is unavoidable. If the decision of whether to show it depends on a cookie, render it hidden and reveal it with a class toggle — a visibility change on an already-reserved box is not a layout shift.
The same reasoning applies to A/B tests and personalisation. Swapping a hero's text after paint shifts everything below it; swapping it at the edge, before the HTML reaches the browser, shifts nothing. When a static site has an edge layer in front of it, that is usually the cheapest place to solve the whole class of problem — the pattern is sketched in Cloudflare Pages Edge Caching Setup.
Inventory Before You Fix
Most teams underestimate how many late-arriving elements a page carries, because each was added by a different person for a different reason. Take a census before touching anything: load the page with a paused debugger, then diff the served HTML against the live DOM.
// Paste in the console after the page settles — lists nodes not present in the HTML response
const served = await fetch(location.href).then((r) => r.text());
const shipped = new DOMParser().parseFromString(served, 'text/html');
const inHtml = new Set([...shipped.querySelectorAll('[class]')].map((el) => el.className));
[...document.querySelectorAll('body [class]')]
.filter((el) => !inHtml.has(el.className))
.forEach((el) => console.log(el.tagName, el.className, el.getBoundingClientRect().height));
Each row is an element the build did not produce, together with the height it eventually occupies — which is exactly the number to reserve. Sort by height descending and fix from the top; on the site measured below, four of the eleven rows accounted for the entire score.
Measured Impact
A documentation article carrying a consent banner, a comment thread and one video embed, Chrome 4× CPU throttle on a fast 3G profile, median of five runs:
| Change | CLS | JS transferred | Notes |
|---|---|---|---|
| Baseline: all three in flow, unreserved | 0.38 | 940 KB | Banner prepend dominates the burst |
| Banner as fixed overlay | 0.19 | 940 KB | Removes the whole-page push |
min-height on the comment container | 0.08 | 940 KB | Residual is the player growing |
aspect-ratio wrapper on the iframe | 0.01 | 940 KB | All three boxes now reserved |
| Video facade instead of the player | 0.01 | 210 KB | 730 KB removed from the initial load |
Pitfalls & Rollback
- Reserving too little. An under-sized box still shifts, just less. Measure the real rendered height in production and reserve at the median, not the minimum.
- Reserving with JavaScript. Setting
min-heightfrom a script runs after paint and reserves nothing. It must be in the HTML or the stylesheet. - Assuming lazy loading is a fix. Deferring moves the shift to scroll time, where it still counts. Reserve first, then lazy-load.
- Overlay without a scroll lock. A fixed banner covering content is fine for CLS but can trap readers on small screens; give it a dismiss control and keep it short.
- Third-party heights that change. Ad slots and status widgets change size across releases. Re-measure quarterly, or use a container with a fixed ratio the vendor guarantees.
- Rollback: every change is a wrapper element or a CSS rule. Removing the class restores the previous rendering; the facade pattern falls back to a plain link if its script never loads.
Conclusion
Embeds shift pages because nothing reserved their space, and a static generator is in the perfect position to reserve it — the widget's existence is known at build time even when its height is not. Emit the container, wrap iframes in a ratio box, put banners in a fixed overlay, and use facades for heavy players. Then confirm with field data, since a lab run rarely triggers the scroll-loaded ones: see Measuring CLS in the Field With web-vitals.js.
FAQ
Why does a consent banner hurt CLS so much?
Because it is usually prepended to the body, so it pushes the entire page down by its own height. The impact fraction is close to the whole viewport and the distance fraction is large, which is the worst possible combination. Rendering it as a fixed overlay removes it from the document flow and scores zero.
How do I reserve space for a comment thread whose height varies?
Use a min-height set to the median rendered height you observe in production. A thread that opens at 520 pixels in a container reserved for 480 shifts the page by 40 pixels rather than 520, which is usually the difference between passing and failing.
Does IntersectionObserver lazy loading avoid the shift?
No. Deferring the load only moves the shift later, to the moment the reader scrolls the element into view, and shifts in the visible viewport count fully. Lazy loading is a bytes optimisation; the reservation is the layout fix and you need both.
Should I replace a video embed with a click-to-load placeholder?
Usually yes for third-party players. A static poster image with a play button gives you a fixed box, removes hundreds of kilobytes of player JavaScript from the initial load and only pays that cost for readers who actually watch.
How do I know which embed caused a field CLS score?
Record the layout-shift entries with the web-vitals attribution build, which reports the largest shifted element for each entry. That tells you the node rather than the page, which is what you need to fix it.
Related
- Parent: Cumulative Layout Shift Fixes for Static Sites — the full set of causes.
- Reserving Space for Images and Embeds — the dimension recipe these patterns build on.
- Lazy-Loading YouTube Embeds on Static Sites — the facade pattern in full, from the payload angle.
- Third-Party Script Performance on Static Sites — budgets for everything a third party ships.
- Measuring CLS in the Field With web-vitals.js — attributing a score to a specific node.