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 frame dimension, 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.

What the parent metrics missThe parent page passes on both metrics while the embedded checkout is comfortably in the Poor band. Neither number is wrong; only one of them describes what the user is doing.What the parent metrics missParent-frame p75 against the same measures taken inside the embedParent LCP2,140 msEmbed LCP (measured inside)3,860 msParent INP168 msEmbed INP (measured inside)540 ms0 ms1,250 ms2,500 ms3,750 ms5,000 ms
The parent page passes on both metrics while the embedded checkout is comfortably in the Poor band. Neither number is wrong; only one of them describes what the user is doing.

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.

Parent-frame shift attributed to embedsSelf-resizing embeds dominate parent CLS wherever they appear. The fix is in the parent markup, not in the vendor code.Parent-frame shift attributed to embedsShare of parent CLS by source on pages carrying an embedCheckout page62%18%12%8%Article with video47%26%17%10%Docs with sandbox71%11%10%8%iframe resizeImagesFontsOther
Self-resizing embeds dominate parent CLS wherever they appear. The fix is in the parent markup, not in the vendor code.

Verifying it works

  1. frame_* metrics appear with plausible values and are never mixed into the parent metric series.
  2. 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.
  3. Parent CLS drops once the frame element has reserved dimensions — check the attributed shift source no longer names the iframe.
  4. No wildcard origins in either direction. Audit both the sender’s target origin and the receiver’s allowlist.
  5. 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.

What crosses the frame boundary, and howThe rename in the third stage is the important one: an embed LCP stored as LCP corrupts the metric the origin is assessed on.What crosses the frame boundary, and howMeasurement inside, reporting outsideFrame documentown observerspostMessageorigin-checkedParent handlervalidate and renameCollectorstored as frame_LCP
The rename in the third stage is the important one: an embed LCP stored as LCP corrupts the metric the origin is assessed on.

Edge cases and gotchas

  • postMessage to '*' 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.