Cumulative Layout Shift spikes a second or two after load, the Performance panel attributes the shift to content below a vendor widget, and the widget’s iframe has grown from its default height to whatever the vendor decided it needed.
Triage: Attribute the Shift to the Frame
- Record a Performance trace of a cold load with 4× CPU throttling and open the Experience track.
- Click each layout-shift entry and read the Moved from / Moved to summary — the shifted nodes are the ones below the frame, not the frame itself.
- In the Elements panel, watch the iframe’s computed height across load. A value that changes after the vendor’s script runs is the cause.
- Check whether the height change comes from a
styleattribute the vendor set, from apostMessageyour code acted on, or from the frame’s own content growing withheight: autoon the element.
Root Cause: The Height Is Decided After Layout
An iframe has no intrinsic content size from the parent’s perspective. The embedding document lays it out at whatever dimensions the element specifies — a default of 150 pixels tall if nothing does — and only later, once the vendor’s document has rendered and measured itself, does anything know what it should have been. Every mechanism for correcting that runs after the first layout, so every one of them shifts the page unless the space was reserved beforehand.
This differs from an image, where width and height attributes let the browser reserve the correct box before the bytes arrive. There is no equivalent for a frame’s content, which is why the reservation has to be an explicit decision rather than something the platform infers.
The situation is made worse by height negotiation over postMessage, which is the standard remedy and introduces a second problem: an unvalidated height from a third party is a value that can be any number, arriving at any time, as many times as the vendor likes. A widget that reports a growing height on every interaction produces a shift on every interaction — and interaction-time shifts are the most heavily weighted, because the visitor is looking at and touching the page.
Resolution: Reserve, Then Negotiate Within Bounds
<!-- The wrapper owns the reserved box. The frame fills it, so a height change
inside the frame cannot alter the space the page has allocated. -->
<div class="widget" style="aspect-ratio: 4 / 3; position: relative;">
<iframe
src="https://widget.vendor.example/embed"
title="Availability calendar"
loading="lazy"
sandbox="allow-scripts allow-forms"
style="position:absolute; inset:0; width:100%; height:100%; border:0;"></iframe>
</div>
// Height negotiation, bounded. An unvalidated height is an arbitrary number
// from a third party applied directly to your layout.
const MIN_H = 240, MAX_H = 900;
let applied = null;
window.addEventListener('message', (event) => {
if (event.origin !== WIDGET_ORIGIN) return;
if (event.source !== iframe.contentWindow) return;
if (event.data?.type !== 'resize') return;
const h = Number(event.data.height);
if (!Number.isFinite(h)) return;
const next = Math.min(Math.max(h, MIN_H), MAX_H);
// Ignore sub-threshold changes: a widget reporting 401 then 403 pixels
// should not produce two shifts.
if (applied !== null && Math.abs(next - applied) < 8) return;
applied = next;
wrapper.style.aspectRatio = ''; // switch from ratio to explicit height
wrapper.style.height = `${next}px`;
});
Three properties make this safe. The aspect ratio reserves a plausible box before anything loads, so the common case produces no shift at all. The clamp means a vendor cannot demand an arbitrary height. And the threshold suppresses the small oscillations that otherwise turn one shift into many.
Where you can choose the initial ratio well — from the vendor’s own default, or from what the widget renders at for most visitors — the negotiated height frequently lands inside the reserved box and no adjustment is needed. That is the outcome to aim for: negotiation as a correction for the unusual case rather than as the normal path.
Verification: No Shift Entry at the Resize
Re-record the trace. The Experience track should contain no layout-shift entry at the moment the widget settles. Then interact with the widget in a way that changes its content and confirm no entry appears then either — the interaction case is the one that costs most.
Choosing the Reserved Ratio
The reservation is only free if it is roughly right. Reserve too little and the negotiated height still shifts the page; reserve too much and you have a permanent gap below the widget for every visitor, which is a worse experience than a small shift and does not show up in any metric.
Derive it from data rather than from the vendor’s default. Instrument the negotiated height for a week and take a high percentile — the value that covers most sessions without reserving for the outlier — and use that as the ratio. A widget whose height varies wildly between visitors is a signal in itself, usually meaning it renders different content for different cohorts, in which case reserving per cohort may be worth the complexity.
Responsive behaviour needs the same treatment. A widget that is 400 pixels tall on desktop and 700 on mobile needs two ratios, applied by media query, or you will trade a desktop gap for a mobile shift. Because aspect-ratio scales with width, expressing the reservation as a ratio rather than a fixed height handles most of this automatically — which is the main reason to prefer it.
Common Pitfalls
-
Putting the ratio on the iframe rather than the wrapper. A vendor stylesheet or an inline style from their script can override the element; the wrapper is yours.
-
Applying an unclamped height. A value from a third party applied directly to layout is a value they choose, and some widgets report their content’s full scroll height including elements you did not expect.
-
Reserving with
min-heightalone. It prevents the box collapsing but not growing, so the shift still occurs when the negotiated height exceeds it. -
Ignoring the interaction case. A widget that resizes on every expand or filter produces repeated shifts, each weighted more heavily than the one at load.
-
Letting the vendor set the height directly. A widget that writes to the element’s style attribute bypasses your clamp entirely. Where the vendor does this, the wrapper’s fixed box is the only defence — the frame fills it and its own height becomes irrelevant.
Frequently Asked Questions
Does loading="lazy" make the shift worse?
Not if the box is reserved, and that is precisely why reservation and lazy loading belong together. Deferring the load means the frame arrives later, when the visitor may be looking at it, so an unreserved box shifts content at the worst possible moment — a lazy frame with no reservation is worse than an eager one.
With a reserved box, deferring is strictly good: the space is held from the initial layout, nothing moves when the frame eventually loads, and you have avoided the request entirely for visitors who never scroll that far. The rule is simply that reservation is a prerequisite rather than an optional companion.
What if the vendor does not support height messages?
Then the reservation is the whole solution, and it has to be right rather than approximate. Measure the widget across the viewports and cohorts you serve, pick a ratio that covers the great majority, and accept a scrollbar inside the frame for the remainder — an internal scroll is a far better outcome than a page-level shift.
Avoid the temptation to measure the frame’s content from the parent. Cross-origin frames do not expose their document, and any technique that appears to work is either relying on same-origin content or on a side channel that will stop working. If you need the height and the vendor will not send it, that is a request to make of the vendor.
Should the clamp maximum be generous or tight?
Tight enough to bound the damage, generous enough not to truncate legitimate content. A maximum a little above the highest value you observed in a week of instrumentation is usually right: it accommodates the real range while preventing a bug or a hostile message from claiming the whole viewport.
Log clamp events rather than applying them silently. A widget repeatedly hitting the ceiling is telling you either that your maximum is too low or that the widget has changed what it renders, and both are worth knowing before a visitor reports content being cut off.