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

Four ways an embed shifts the page Four labelled panels. Prepended banner pushes the whole page down and scores 0.19. Mid-page widget with no reserved box scores 0.11. Iframe without an aspect ratio scores 0.08. A fixed overlay banner and a reserved box both score 0.00. Where the score comes from, by placement Prepended banner banner whole page moves CLS 0.19 Mid-page widget comments appear CLS 0.11 Ratio-less iframe grows to 315 px CLS 0.08 Reserved / fixed box waiting CLS 0.00 Measured on one docs template, Chrome 4× CPU throttle · the placement matters more than the size A small element inserted at the top outscores a large one appended at the bottom
Position dominates: an element inserted above existing content moves everything below it, which is why banners are worse than comment threads even though they are smaller.

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.

Census of elements added after the HTML response A ranked list of late-added elements with their eventual heights: consent banner 96 pixels prepended, comment thread 520 pixels mid-page, video player 315 pixels mid-page, newsletter form 180 pixels appended, and six smaller elements under 40 pixels each. Only the first four are marked as worth reserving. Eleven late elements, four that matter .consent · 96 px · prepended reserve or overlay .comments · 520 px · mid-page reserve iframe.player · 315 px · mid-page ratio wrapper .newsletter · 180 px · appended reserve 6 more elements, all under 40 px ignore for now
Ranking by eventual height turns a vague "the page is jumpy" into a four-item task list — and tells you the exact `min-height` to reserve for each.

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:

ChangeCLSJS transferredNotes
Baseline: all three in flow, unreserved0.38940 KBBanner prepend dominates the burst
Banner as fixed overlay0.19940 KBRemoves the whole-page push
min-height on the comment container0.08940 KBResidual is the player growing
aspect-ratio wrapper on the iframe0.01940 KBAll three boxes now reserved
Video facade instead of the player0.01210 KB730 KB removed from the initial load
CLS and JavaScript weight across five changes A step chart with two series. The CLS line falls from 0.38 to 0.19 after the banner overlay, 0.08 after reserving the comment box, and 0.01 after the iframe wrapper, then stays flat. The JavaScript line stays at 940 kilobytes until the final change, where the video facade drops it to 210 kilobytes. Layout and payload are separate wins CLS 0.38 0.19 0.08 0.01 JS 940 KB 210 KB baseline overlay comment box iframe ratio video facade Solid line: CLS (lower is better) · Dashed line: JavaScript transferred on first load
The first three changes are pure layout work and cost no bytes; the facade is the only one that also removes payload — which is why it is worth doing even after the score is already green.

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-height from 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

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.