Capturing Web Vitals Inside iframes
Core Web Vitals are measured for the top-level document only. An iframe has its own document, its own performance timeline and its own observers — and none of what happens inside it produces an LCP or CLS entry in the parent. If a substantial part of your page is an embed (a checkout widget, a video player, an ad slot, a documentation sandbox), you are blind to it unless you instrument deliberately. This guide sits under Web Vitals API Implementation and covers what crosses the frame boundary, how to report from inside a frame you control, and which parent-frame metrics an iframe can still damage.
Prerequisites
- Control of the iframe’s document, or a cooperative provider who will run a snippet inside it. Without one of those, only the indirect measurements in the last section are available.
- A message channel between frames, and agreement on an origin allowlist for
postMessage. - The parent’s beacon pipeline able to accept a
framedimension, as described in Self-Hosted Beacon Collection.
How to measure across the frame boundary
Step 1 — Know what the boundary blocks
| Signal | Visible to the parent? | Notes |
|---|---|---|
| LCP inside the iframe | No | The iframe element itself can be a parent LCP candidate only via its poster or background |
| CLS inside the iframe | No | Shifts inside the frame do not score in the parent |
| CLS caused by the iframe resizing | Yes | The <iframe> element moving parent content does score |
| INP for interactions inside the frame | No | Event Timing is per-document |
| Long tasks inside a same-process frame | Partly | Attribution is coarse; cross-origin frames may be isolated |
| Resource Timing for the frame document | Yes, with Timing-Allow-Origin |
Gives you load timing, not paint timing |
The practical consequence: an embedded checkout that takes four seconds to become interactive contributes nothing to the parent’s INP, and the parent’s dashboard says the page is fine while the conversion funnel says otherwise.
Step 2 — Report from inside the frame
Inside a frame you control, run the same reporter you run anywhere else, then hand the results to the parent rather than sending them yourself. One beacon per page view is cheaper, and the parent holds the session context.
// inside-iframe.js — measure locally, report upward.
import { onLCP, onINP, onCLS } from 'web-vitals';
const PARENT_ORIGIN = 'https://www.example.com'; // never use '*'
const send = (metric) => {
if (window.parent === window) return; // not framed; report normally
window.parent.postMessage({
source: 'embed-vitals',
frame: 'checkout-widget',
name: metric.name,
value: Math.round(metric.value * 1000) / 1000,
rating: metric.rating,
id: metric.id,
}, PARENT_ORIGIN);
};
onLCP(send);
onINP(send);
onCLS(send);
Step 3 — Accept the messages in the parent, carefully
// parent.js — validate origin and shape before trusting anything.
const ALLOWED = new Set(['https://checkout.vendor.example', 'https://embed.example.com']);
addEventListener('message', (event) => {
if (!ALLOWED.has(event.origin)) return;
const data = event.data;
if (!data || data.source !== 'embed-vitals') return;
if (typeof data.value !== 'number' || !Number.isFinite(data.value)) return;
if (!['LCP', 'INP', 'CLS', 'FCP', 'TTFB'].includes(data.name)) return;
queueBeacon({
metric: `frame_${data.name}`, // a distinct name: never mix with the parent metric
value: data.value,
frame: String(data.frame).slice(0, 40),
rating: data.rating,
route: window.__route,
});
});
Two rules that are not optional. Validate the origin against an allowlist — a wildcard listener accepts messages from any frame on the page, including ad creatives. And store the values under a distinct metric name: an iframe LCP is not the page LCP, and mixing them corrupts the number your origin is assessed on.
Step 4 — Measure the frame from outside when you cannot measure inside
For third-party embeds you do not control, three indirect signals are available and worth collecting.
Resource Timing for the frame document. If the provider sets Timing-Allow-Origin, you get connection, response and transfer timings for the frame’s own document — enough to see whether a slow embed is network-bound.
Element Timing on the iframe element. Annotating the <iframe> with elementtiming gives you the paint time of the frame’s box in the parent, which is when its first pixel became visible.
<iframe src="https://checkout.vendor.example/widget"
elementtiming="checkout-frame"
width="600" height="720"
loading="lazy"
title="Secure checkout"></iframe>
Your own interaction proxy. Time from the user’s click on your page to the frame reporting readiness, using a postMessage handshake if the vendor supports one, or a visibility check if not. The Element Timing route is covered in more depth in Tracking Hero Render Time with Element Timing.
Step 5 — Contain the damage the frame does to the parent
Even unmeasured, an iframe affects the parent in three ways you can control.
| Effect | Cause | Fix |
|---|---|---|
| Parent CLS | Frame resizes itself after load | Fixed width/height or aspect-ratio on the element |
| Parent LCP delay | Frame competes for bandwidth and connections | loading="lazy" below the fold; preconnect if critical |
| Parent INP | Frame script runs in the same process | Cross-origin frame gets its own process under site isolation |
| Main-thread contention | Same-origin frame shares the event loop | Move to a distinct origin, or defer the embed |
The resize case is the common one: a frame that posts its content height and gets resized by the parent produces a layout shift in the parent every time, and it will be attributed to your page. Reserve the maximum height, or accept a scrollbar inside the frame.
Verifying it works
frame_*metrics appear with plausible values and are never mixed into the parent metric series.- Message volume matches page views carrying the embed. A large shortfall means the frame is being blocked or the origin check is rejecting valid messages.
- Parent CLS drops once the frame element has reserved dimensions — check the attributed shift source no longer names the iframe.
- No wildcard origins in either direction. Audit both the sender’s target origin and the receiver’s allowlist.
- The embed’s own numbers move when you change the embed. If they never change, you may be measuring a cached frame that is not the one users see.
Deciding whether the embed belongs in an iframe at all
Instrumentation makes the cost visible; the next question is whether to keep paying it. An iframe buys isolation — separate origin, separate process under site isolation, a security boundary that a vendor script running in your page does not have. It costs a second document: another HTML response, another stylesheet, another framework runtime, and a layout the parent cannot influence.
The trade is usually worth it for anything handling payment details or credentials, where the security boundary is the point, and rarely worth it for presentational widgets that could have been a component.
| Embed type | Keep the iframe | Reason |
|---|---|---|
| Payment or card entry | Yes | Compliance scope and credential isolation |
| Third-party checkout | Yes | Vendor code should not run in your context |
| Video player | Usually | Vendor controls a heavy runtime you do not want to host |
| Ad slot | Yes | Untrusted creative; also the standard the ad stack expects |
| Map or chart widget | Often not | A component avoids a whole second document |
| Comments or reviews | Often not | Server-rendered markup is faster and measurable |
Where the answer is “keep it”, the instrumentation above is how the embed becomes accountable: a vendor whose widget reports a 3.9-second LCP inside its own frame has a number to be held to, and a renewal conversation that includes performance. Where the answer is “no”, the measurement is what justifies the migration — a component that removes a second document typically returns more than any tuning of the embed would have.
Edge cases and gotchas
postMessageto'*'leaks data. Any frame on the page, including an ad, can read a message sent with a wildcard target. Always name the origin.- Same-origin frames share the main thread. An embed on your own origin competes directly for the event loop, so it can damage parent INP invisibly. A distinct origin buys process isolation in Chromium.
- Sandboxed frames may lack
crypto.randomUUID. Feature-detect before generating ids inside the frame. - Frames restored from bfcache re-run nothing; if your handshake happens once at load, a restored parent will never receive frame metrics again. Re-handshake on
pageshow. - Ad frames rewrite themselves. Measuring an ad slot is measuring a moving target; report the slot’s paint time, not the creative’s.
- Cross-origin Resource Timing is opaque without
Timing-Allow-Origin. You get the entry but nearly all fields are zero, which looks like a fast load rather than a missing measurement.
FAQ
Does content inside an iframe affect my Core Web Vitals?
Not directly — LCP, CLS and INP are measured per document, and the iframe has its own. Indirectly it very much does: the frame competes for bandwidth and, if same-origin, for the main thread, and it can shift parent layout when it resizes.
Should I send iframe metrics as LCP and CLS?
No. Store them under distinct names such as frame_LCP. Merging them into the parent series produces an aggregate that matches nothing the browser or CrUX reports.
How do I measure a third-party embed I cannot modify?
Element Timing on the <iframe> element gives you its paint time in the parent, and Resource Timing gives you the frame document’s network timing when the provider sets Timing-Allow-Origin. Beyond that, ask the vendor for a readiness event.
Will a cross-origin iframe protect my INP?
Under site isolation the frame gets its own renderer process, so its script execution does not block your main thread. Layout, compositing and bandwidth are still shared, so a heavy embed can still hurt.
Related
- Web Vitals API Implementation — the reporter this pattern extends across frames.
- Element Timing API — measuring the paint of a specific element, including a frame box.
- Reducing Input Delay from Third-Party Scripts — the main-thread half of the third-party problem.