The Problem
A client's product pages started failing Core Web Vitals last month. CLS jumped from a steady 0.04 to 0.18 on mobile in the Search Console field data report. Desktop looked fine. Lab tests in PageSpeed Insights showed CLS of 0.02. Real users were getting hammered, lab tests were clean.
The shift was happening 3 to 5 seconds after first paint, well after the LCP element rendered. That timing pattern is almost always a late-loading third-party widget pushing content down. In this case it was the Trustpilot Mini Review Count widget the marketing team had dropped above the price block on every product page during a campaign push. On mobile, the script took an extra 2 to 4 seconds to come back and the widget then expanded from 0px to about 70px of height, shoving the price, the Add to Cart button, and the variation selector down the screen, usually right as someone was about to tap.
The kind of thing that doesn't show up in lab tests because the lab freezes at first-contentful-paint plus a couple of seconds, but it tanks the field score because real mobile users on slow networks see the shift every single time.
Why It Happens
Trustpilot's widget loader, tp.widget.bootstrap, is a JS file that fetches widget config, then injects an iframe sized to the rendered content. The default markup Trustpilot tells you to paste looks like this:
<div class="trustpilot-widget"
data-locale="en-GB"
data-template-id="..."
data-businessunit-id="..."
data-style-height="24px"
data-style-width="100%">
</div>
The data-style-height attribute is read by the loader script and applied to the iframe after the loader finishes booting. The wrapper <div> itself has no height. Until the script runs, the browser reserves zero pixels for the widget. Whatever is below it sits at a "wrong" position until the iframe pops in and pushes everything down.
The mobile slowness compounds this. Trustpilot's CDN serves the loader as a render-blocking-then-network-fetching pipeline: small loader, large config request, iframe HTML, fonts, ratings JSON. On a fast 4G connection this is 200 to 400ms. On a slow 3G or a saturated suburban LTE connection it's 3 to 6 seconds. The CLS event lands well after the user has visually committed to the page layout, which is what makes it feel like a content jump rather than a normal load.
Google's CLS guide is explicit that any element that changes layout after the user is interacting with the page counts toward CLS, and there is no "this came late so it's free" exemption. The Sentinel of CLS is the longest single-frame shift, and a 70px widget late-injecting above the fold qualifies easily.
The Fix
Two pieces. Reserve the space before the script ever runs, then defer the script until the widget is near the viewport. The first stops the shift, the second stops the widget from blocking LCP and contributing to INP.
Step 1: Reserve the height the iframe will eventually take. Wrap the Trustpilot div in a sized container that matches the rendered widget height for each breakpoint. For the Mini Review Count widget it is 24px on desktop and roughly 52px on mobile because of the wrapping line:
<div class="tp-slot" aria-label="Customer reviews">
<div class="trustpilot-widget"
data-locale="en-GB"
data-template-id="5419b6a8b0d04a076446a9ad"
data-businessunit-id="..."
data-style-height="24px"
data-style-width="100%">
</div>
</div>
.tp-slot {
min-height: 52px;
contain: layout;
}
@media (min-width: 768px) {
.tp-slot {
min-height: 24px;
}
}
contain: layout is the important bit: it tells the browser the children of .tp-slot will not affect outside layout, which means the late-injected iframe cannot push the rest of the page down even if the height calculation is slightly off. min-height reserves the visual space, contain enforces the boundary.
Measure your specific widget. The Trustpilot template gallery shows pixel heights for each option at a 320px reference width, but the rendered height differs slightly per locale. Open DevTools at mobile width, let the widget load, copy the iframe's computed height. That is your min-height.
Step 2: Lazy load the loader script. Don't put the Trustpilot bootstrap in the <head>. Inject it when the wrapper is near the viewport using IntersectionObserver:
<script>
(function () {
if (!('IntersectionObserver' in window)) {
loadTrustpilot();
return;
}
var slots = document.querySelectorAll('.tp-slot');
if (slots.length === 0) return;
var loaded = false;
var observer = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting && !loaded) {
loaded = true;
loadTrustpilot();
observer.disconnect();
}
});
}, { rootMargin: '300px 0px' });
slots.forEach(function (slot) { observer.observe(slot); });
function loadTrustpilot() {
var s = document.createElement('script');
s.src = '//widget.trustpilot.com/bootstrap/v5/tp.widget.bootstrap.min.js';
s.async = true;
document.head.appendChild(s);
}
})();
</script>
rootMargin: '300px 0px' starts loading 300px before the widget enters the viewport, so by the time the user scrolls to it the iframe is already in place. On product pages where the widget is above the fold, the observer triggers on the first frame and load behaves the same as a normal async script — but you have not paid LCP cost on pages that scroll past it.
Step 3: Confirm the fix in field data, not lab. Lab tests already passed when this was broken, so they will continue to pass. Watch CrUX. The Search Console Core Web Vitals → Mobile report updates the 28-day rolling p75 over about a week. If you have Speed Insights or a RUM tool, filter by "Trustpilot" as part of the URL pattern or by the slow product page templates and watch the CLS distribution shift. For my client, mobile p75 CLS dropped from 0.18 to 0.04 over six days. The widget still renders, the field data goes back to green, and the marketing team gets to keep their reviews badge above the price.
The Lesson
Third-party widgets that inject sized iframes always shift layout unless you reserve their height before the script runs. The shift typically happens late enough to dodge lab tests, so lab passes while field data tanks. Reserve min-height matched to the rendered widget, add contain: layout so the late injection cannot push outside, and lazy load the bootstrap with IntersectionObserver so the widget pays no LCP tax.
If your product pages are quietly failing field CLS while lab tests stay green, that is the kind of mobile-RUM detective work I do for clients. See my services. For a related CLS gotcha with consent banners, read Cookie consent banner CLS fix.
Field CLS failing while lab tests pass? Let me fix it.
