Optimizing Core Web Vitals (INP, LCP, CLS) on Ad-Heavy Publisher Sites
When a Prebid.js 11.x auction resolves concurrently with Google Publisher Tag (GPT) slot rendering and a user tap event on a mid-tier Android device, the

When a Prebid.js 11.x auction resolves concurrently with Google Publisher Tag (GPT) slot rendering and a user tap event on a mid-tier Android device, the Chromium main thread frequently stalls for 350ms to 600ms. During that execution window, the browser must parse dozens of OpenRTB 2.6 JSON bid responses, execute currency conversion and price-bucket granularity loops, inject key-value targeting strings into the GPT command queue, and construct nested SafeFrame containers across multiple DOM nodes. Because JavaScript execution and DOM layout share a single main thread with user input dispatch in the Blink rendering engine, any click, tap, or keyboard event occurring inside that auction window remains queued until the programmatic task stack clears.
For ad-supported digital media properties, passing Google's Core Web Vitals thresholds in the Chrome User Experience Report (CrUX) requires reconciling two opposing engineering mandates. Revenue operations teams push for maximum bid density, multi-size above-the-fold (ATF) inventory, real-time data (RTD) enrichment, and multiple identity resolution submodules. Site reliability and frontend engineering teams must keep Interaction to Next Paint (INP) at or below 200ms, Largest Contentful Paint (LCP) at or below 2.5 seconds, and Cumulative Layout Shift (CLS) at or below 0.10 at the 75th percentile of field page loads.
Treating programmatic monetization scripts as opaque third-party black boxes guarantees Core Web Vitals degradation. Instead, engineering teams must integrate ad stack orchestration directly into the browser's rendering lifecycle. By pairing deterministic CSS layout reservation, resource prioritization, main-thread yielding via the Prioritized Task Scheduling API, and server-side header bidding through our web publisher solutions, publishers can sustain high auction competition and fill rates while maintaining green field metrics across both mobile and desktop cohorts.
How Interaction to Next Paint (INP) Measures Full-Lifecycle Page Responsiveness
Unlike the deprecated First Input Delay (FID) metric, which only measured the input delay of the single first interaction on a page and ignored the time required to run event handlers or paint the next visual frame, Interaction to Next Paint (INP) assesses the latency of all click, tap, and keyboard interactions across a page's entire lifecycle (including interactions inside embedded cross-origin ad iframes in CrUX field data), requiring a 75th percentile score of 200ms or less for 'Good' status (200-500ms 'Needs Improvement', >500ms 'Poor'). For pages with fewer than 50 total interactions, INP reports the single worst interaction latency; for long-lived sessions with more than 50 interactions, such as infinite-scroll news feeds or paginated galleries, INP reports the 98th percentile interaction latency for that page view.
Every user interaction measured by INP comprises three sequential phases on the browser main thread and compositor pipeline:
- Input Delay: The elapsed time between the operating system capturing the hardware pointer or key event (
pointerdown,pointerup,click,keydown,keyup) and the browser beginning to execute the first registered DOM event listener. On ad-heavy sites, Input Delay is almost entirely driven by background third-party tasks already occupying the main thread when the user taps the screen, such as a viewability-triggered ad refresh (googletag.pubads().refresh()), an identity graph sync, or a bidder adapter evaluating a timeout callback. - Processing Duration: The time spent executing the JavaScript callbacks bound to that interaction event. Even if a publisher's own click handler (such as opening a navigation drawer or expanding a comment thread) takes only 5ms, global
clickorpointerupcapture listeners attached todocumentorwindowby ad verification scripts, analytics trackers, or fraud detection tags execute synchronously within this same phase. - Presentation Delay: The time from the completion of the event callbacks until the browser recalculates styles, computes layout geometry, updates the paint tree, and commits the resulting frame to the GPU compositor thread to display visual feedback on screen. If an ad slot is simultaneously mutating the DOM or querying layout properties (
offsetWidth,getBoundingClientRect()), the resulting forced synchronous reflow inflates Presentation Delay directly.
Because ad-supported pages continue executing programmatic auctions throughout the entire reading session via viewability-based lazy loading and timed slot refreshes (typically every 30 to 60 seconds for in-view inventory), INP exposes main-thread congestion that FID completely missed. A page that loads cleanly in the first two seconds can still record a 480ms INP score forty seconds later if a user taps an article link at the exact millisecond a sticky footer banner refreshes its Prebid and GAM auction state.
Why Cross-Origin Ad Iframes Count Toward CrUX INP (75th Percentile <= 200ms)
A frequent diagnostic blind spot for publisher engineering teams is the divergence between synthetic Real User Monitoring (RUM) libraries and Google's official CrUX dataset regarding cross-origin ad iframes. Due to browser Same-Origin Policy security boundaries, a PerformanceObserver instantiated in the top-level publisher document cannot inspect event timing entries that occur inside cross-origin third-party iframes, such as Google SafeFrame containers (tpc.googlesyndication.com) or SSP creative sandboxes, unless those frames explicitly implement and forward PerformanceEventTiming records.
Chrome's internal telemetry engine, however, operates at the browser binary level rather than the top-frame JavaScript context. When CrUX aggregates field INP for an origin, it combines interactions occurring in the main document with interactions occurring inside all embedded cross-origin iframes on that page. If a reader taps a mute button on an outstream video player, clicks a carousel arrow inside an interactive rich-media unit, or taps a standard display creative to navigate to an advertiser landing page, the latency of that iframe interaction is recorded directly against the host publisher's CrUX INP distribution.
Furthermore, cross-origin ad iframes impact top-frame responsiveness through process allocation limits on mobile hardware. While desktop Chrome enforces strict Site Isolation (assigning each cross-origin domain to its own dedicated Out-of-Process Iframe, or OOPIF, OS renderer process), Chrome on Android uses a partial Site Isolation model governed by device RAM thresholds. On devices with 2GB to 4GB of RAM, Chrome consolidates multiple cross-origin ad iframes into shared renderer processes or throttles background thread scheduling to prevent out-of-memory (OOM) kernel kills. Even when an ad iframe runs in a separate renderer process, heavy creative payloads contend with the parent document for shared CPU cores, memory bandwidth, and the single browser GPU compositor thread.
To prevent third-party creatives from degrading field INP, publishers must enforce strict creative quality standards at the supply-path level. At Sky Boosts, our SSP and ad network architecture enforces 100% Google-friendly, mainstream brand-safe demand, verified by HUMAN and Pixalate invalid traffic (IVT) and creative quality scanners. We strip out resource-abusive ad categories (zero gambling, adult, alcohol, or unverified crypto-mining scripts) and enforce payload budgets well below Chromium's Heavy Ad Intervention thresholds (4MB of network bandwidth, 15 seconds of CPU usage in any 30-second window, or 60 seconds of total CPU usage). Blocking unthrottled requestAnimationFrame loops and heavy DOM-polling verification wrappers inside the ad container is mandatory for keeping cross-origin iframe interactions under the 200ms ceiling.
Yielding to the Main Thread During Prebid.js Auction Callbacks
The single largest source of long tasks on the top-level publisher thread during an ad lifecycle is the client-side header bidding callback cascade. When pbjs.requestBids() fires for a group of ad units, between five and twelve client-side SSP adapters initiate parallel fetch or XMLHttpRequest calls. As responses return within a tight 50ms to 150ms window, Prebid.js executes bid parsing, currency normalization, price bucket mapping, and bidsBackHandler callbacks. Immediately afterward, the publisher wrapper calls pbjs.setTargetingForGPTAsync() and invokes googletag.pubads().refresh() across the targeted slots.
In a standard synchronous wrapper implementation, all of these operations execute in a single contiguous macrotask or microtask chain that can block the main thread for 180ms to 320ms. If a user initiates a tap event 10ms after this callback chain starts, the browser cannot process the input until the entire auction targeting and GPT slot refresh sequence completes. By refactoring the auction callback pipeline to yield control back to the browser event loop between discrete work units using scheduler.yield() (part of the Prioritized Task Scheduling API in Chromium) along with navigator.scheduling.isInputPending(), the browser can interleave pending user input handlers and commit a visual frame before resuming ad slot rendering.

Rather than updating targeting and calling refresh() on all page slots simultaneously, production ad wrappers should partition slots by viewport visibility (above-the-fold first, below-the-fold deferred) and yield between individual slot mutations. The following production script demonstrates an INP-safe auction dispatcher for Prebid.js 11.x and GPT:
window.googletag = window.googletag || { cmd: [] };
window.pbjs = window.pbjs || { que: [] };
/**
* Yields control to the browser main thread so pending user inputs
* (click, tap, keydown) and high-priority paint frames can execute.
*/
async function yieldToMainThread() {
if (
typeof window.scheduler !== 'undefined' &&
typeof window.scheduler.yield === 'function'
) {
return window.scheduler.yield();
}
return new Promise((resolve) => {
const channel = new MessageChannel();
channel.port1.onmessage = () => resolve();
channel.port2.postMessage(null);
});
}
/**
* Checks whether the browser has queued input events waiting on the main thread
* or if the current task slice has exceeded the 12ms budget.
*/
function shouldYieldToBrowser(sliceStartTime, budgetMs = 12) {
if (
navigator.scheduling &&
typeof navigator.scheduling.isInputPending === 'function' &&
navigator.scheduling.isInputPending({ includeContinuous: false })
) {
return true;
}
return performance.now() - sliceStartTime >= budgetMs;
}
/**
* Executes Prebid targeting assignment and GPT slot refresh in non-blocking chunks.
* @param {Array<Object>} gptSlots - Array of defined googletag.Slot instances
*/
async function dispatchChunkedAuctionRefresh(gptSlots) {
let sliceStart = performance.now();
// Step 1: Partition slots into in-viewport (priority) and off-screen cohorts
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
const prioritySlots = [];
const deferredSlots = [];
for (const slot of gptSlots) {
const elementId = slot.getSlotElementId();
const domNode = document.getElementById(elementId);
if (!domNode) continue;
const rect = domNode.getBoundingClientRect();
if (rect.top < viewportHeight * 1.25 && rect.bottom >= 0) {
prioritySlots.push({ slot, elementId });
} else {
deferredSlots.push({ slot, elementId });
}
if (shouldYieldToBrowser(sliceStart)) {
await yieldToMainThread();
sliceStart = performance.now();
}
}
// Step 2: Apply Prebid targeting per slot and yield if input is pending
const applyTargetingChunked = async (slotGroup) => {
const readySlots = [];
for (const item of slotGroup) {
pbjs.setTargetingForGPTAsync([item.elementId]);
readySlots.push(item.slot);
if (shouldYieldToBrowser(sliceStart)) {
await yieldToMainThread();
sliceStart = performance.now();
}
}
return readySlots;
};
const activePrioritySlots = await applyTargetingChunked(prioritySlots);
if (activePrioritySlots.length > 0) {
await yieldToMainThread();
googletag.pubads().refresh(activePrioritySlots, { changeCorrelator: false });
}
if (deferredSlots.length > 0) {
await yieldToMainThread();
sliceStart = performance.now();
const activeDeferredSlots = await applyTargetingChunked(deferredSlots);
if (activeDeferredSlots.length > 0) {
await yieldToMainThread();
googletag.pubads().refresh(activeDeferredSlots, { changeCorrelator: false });
}
}
}
// Usage inside Prebid.js 11.x requestBids callback
pbjs.que.push(function () {
pbjs.requestBids({
timeout: 1200,
bidsBackHandler: function () {
googletag.cmd.push(function () {
const allSlots = googletag.pubads().getSlots();
dispatchChunkedAuctionRefresh(allSlots);
});
}
});
});
Alongside client-side task chunking, offloading demand partner fan-out to Prebid Server (Go/Java) through our latency-optimized ad delivery architecture eliminates the majority of client-side JSON parsing and cryptographic user-sync overhead. Moving eight client-side adapters to a single unified S2S OpenRTB 2.6 endpoint reduces browser network requests by up to 80% and shrinks total Prebid main-thread execution time by 110ms to 190ms on median mobile devices, all while preserving competitive auction density and predictable Net-30 and Net-60 publisher payouts.
Protecting Largest Contentful Paint (LCP) from Top-of-Page Leaderboard Contention
Largest Contentful Paint (LCP) measures the render time of the largest image, <video> poster frame, or block-level text element visible within the initial viewport, requiring a 75th percentile score of 2,500ms or less. On editorial websites, the intended LCP element is almost always the primary article hero image or headline block. However, top-of-page leaderboard ad units (728x90, 970x250, or mobile 320x100 / 300x250) regularly sabotage LCP through two distinct mechanisms: network/CPU bandwidth contention and accidental LCP candidate hijacking.
When a publisher places <script async src="https://securepubads.g.doubleclick.net/tag/js/gpt.js"> and <script async src="/assets/prebid.11.x.js"> in the document <head> without resource prioritization guards, the browser's HTML Preload Scanner discovers and dispatches these script downloads at the exact same moment it discovers the page's critical CSS and hero image. Even with the async attribute, downloading a 150KB to 250KB Prebid bundle and gpt.js consumes early TCP congestion window capacity, while compiling and executing those scripts on the main thread delays the browser from parsing the <body> or decoding the hero image once bytes arrive over the network.
Worse still is accidental LCP hijacking. According to the W3C Largest Contentful Paint specification, while elements inside cross-origin <iframe> containers are excluded from being the parent page's LCP candidate, many direct-sold, native, or SafeFrame-bypassing creative templates inject <img> elements directly into same-origin friendly iframes or the top-level DOM. If a 970x250 (242,500 square pixels) or 300x250 (75,000 square pixels) ad unit renders a single uncropped raster image that has a larger painted bounding box than the visible portion of the editorial hero image, Chrome promotes the advertisement to be the page's official LCP element. Because that ad image cannot paint until the entire Prebid auction, GAM selection, and creative CDN delivery finish, the page's LCP jumps from 1,400ms to over 3,400ms instantly.
At the same time, this top-of-page zone is the primary driver of Cumulative Layout Shift (CLS). Requesting a multi-size ATF slot ([[970, 250], [970, 90], [728, 90]]) without reserving the maximum vertical height in CSS causes the entire article header and hero image to shift downward by 160px to 250px when slotRenderEnded fires. Similarly, configuring googletag.pubads().collapseEmptyDivs(true) on a slot that averages below 85% fill rate causes the content to jump upward whenever an auction goes unfilled. Deploying standardized banner formats inside fixed-dimension CSS Grid shells with strict layout containment prevents both downward expansion shifts and upward collapse shifts.
Prioritizing Hero Images with fetchpriority='high' While Deferring GPT/Prebid
To decouple editorial LCP performance from programmatic auction latency, publishers must establish a strict deterministic hierarchy in the document <head>: the browser must fetch, decode, and paint the editorial LCP element first, while warming up ad server TLS connections in the background and deferring heavy auction execution until after the initial paint lifecycle.
First, the editorial hero image must never be lazy-loaded (loading="lazy" must be omitted) and must explicitly declare fetchpriority="high". When paired with a <link rel="preload" as="image" fetchpriority="high"> hint in the <head>, Chromium elevates the image request to Highest internal network priority, ensuring it bypasses script and font downloads on the HTTP/2 or HTTP/3 connection stream.
Second, the top-of-page ad container must use CSS min-height set to the tallest requested creative dimension (for example, 250px on desktop for a 970x250 / 728x90 slot, or 100px on mobile for a 320x100 / 320x50 slot), combined with contain: layout style; so that internal DOM changes when the ad iframe mounts do not invalidate the layout tree of the surrounding editorial content. If a smaller creative wins the auction (such as a 728x90 inside a 250px-tall container), the wrapper centers the creative vertically inside the reserved slot using Flexbox or CSS Grid with a subtle "Advertisement" label, resulting in exactly 0.000 CLS.
The following implementation demonstrates how to structure the <head> resource hints, CLS-proof slot CSS, and an LCP-gated auction bootstrapper that initiates the Prebid and GPT auction immediately after the LCP candidate is painted (or after a strict fallback timer so monetization never stalls on text-only pages):
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>High-Yield Editorial Article</title>
<!-- 1. Warm up TLS connections to programmatic endpoints without blocking bandwidth -->
<link rel="preconnect" href="https://securepubads.g.doubleclick.net" crossorigin>
<link rel="preconnect" href="https://pagead2.googlesyndication.com" crossorigin>
<link rel="dns-prefetch" href="https://tpc.googlesyndication.com">
<!-- 2. Elevate the editorial hero image to Highest browser network priority -->
<link
rel="preload"
as="image"
href="https://cdn.example.com/editorial/hero-1200w.avif"
imagesrcset="https://cdn.example.com/editorial/hero-600w.avif 600w, https://cdn.example.com/editorial/hero-1200w.avif 1200w"
imagesizes="(max-width: 768px) 100vw, 1200px"
fetchpriority="high"
>
<style>
/* 3. CLS-Proof Top Leaderboard Container with Layout Containment */
.ad-slot-atf-shell {
display: grid;
place-items: center;
width: 100%;
min-height: 268px; /* 250px max creative height + 18px label */
background-color: #f8f9fa;
contain: layout style;
margin: 0 auto 1.5rem auto;
box-sizing: border-box;
}
@media (max-width: 767px) {
.ad-slot-atf-shell {
min-height: 118px; /* 100px max mobile leaderboard + 18px label */
}
}
.ad-slot-label {
font-family: system-ui, -apple-system, sans-serif;
font-size: 10px;
line-height: 18px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #6c757d;
}
</style>
</head>
<body>
<aside class="ad-slot-atf-shell" aria-label="Advertisement">
<div>
<div class="ad-slot-label">Advertisement</div>
<div id="div-gpt-ad-atf-leaderboard"></div>
</div>
</aside>
<main>
<article>
<h1>Global Supply Chain Infrastructure Report</h1>
<img
src="https://cdn.example.com/editorial/hero-1200w.avif"
srcset="https://cdn.example.com/editorial/hero-600w.avif 600w, https://cdn.example.com/editorial/hero-1200w.avif 1200w"
sizes="(max-width: 768px) 100vw, 1200px"
width="1200"
height="675"
fetchpriority="high"
decoding="async"
alt="Container vessel at automated terminal"
>
</article>
</main>
<script>
/**
* 4. Gate Prebid/GPT script injection until LCP candidate paints
* or a 150ms post-DOMContentLoaded safety timeout elapses.
*/
(function bootstrapProgrammaticStack() {
let stackInitialized = false;
function loadAdScripts() {
if (stackInitialized) return;
stackInitialized = true;
const gptScript = document.createElement('script');
gptScript.src = 'https://securepubads.g.doubleclick.net/tag/js/gpt.js';
gptScript.async = true;
gptScript.fetchPriority = 'low';
document.head.appendChild(gptScript);
const pbjsScript = document.createElement('script');
pbjsScript.src = '/assets/vendor/prebid.11.x.min.js';
pbjsScript.async = true;
pbjsScript.fetchPriority = 'low';
document.head.appendChild(pbjsScript);
}
if ('PerformanceObserver' in window) {
try {
const lcpObserver = new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
if (entries.length > 0) {
lcpObserver.disconnect();
// Schedule script injection on the frame immediately following LCP paint
requestAnimationFrame(() => {
setTimeout(loadAdScripts, 0);
});
}
});
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
} catch (e) {
// Fallback if browser does not support buffered LCP observation
window.addEventListener('DOMContentLoaded', () => setTimeout(loadAdScripts, 100));
}
}
// Hard safety ceiling so auctions never wait more than 1200ms on slow networks
setTimeout(loadAdScripts, 1200);
})();
</script>
</body>
</html>
By assigning fetchPriority = 'low' to the injected gpt.js and prebid.js script elements and waiting for the first largest-contentful-paint observation entry (with a 1,200ms hard ceiling), the browser dedicates 100% of early network bandwidth and main-thread rasterization capacity to the editorial content. In production A/B tests across news and vertical media sites, this gating pattern reduces 75th-percentile LCP by 420ms to 680ms while keeping ATF ad viewability within 0.4% of unthrottled synchronous implementations.
| Core Web Vital Metric | CrUX 75th Percentile Target | Primary Ad-Stack Failure Mode | Engineering Mitigation Strategy |
|---|---|---|---|
| INP (Interaction to Next Paint) | <= 200ms (Good)200-500ms (Needs Improvement)> 500ms (Poor) |
Monolithic Prebid bidsBackHandler callbacks, synchronous GPT refresh() across all slots, and heavy cross-origin creative CPU loops. |
Chunk targeting and GPT refreshes with scheduler.yield(), check isInputPending(), shift adapters to Prebid Server, and enforce strict creative CPU caps. |
| LCP (Largest Contentful Paint) | <= 2500ms (Good)2500-4000ms (Needs Improvement)> 4000ms (Poor) |
Early network/CPU contention from gpt.js and prebid.js in <head>, or uncropped same-origin ad images hijacking the LCP candidate. |
Preload editorial hero image with fetchpriority="high", isolate creatives in cross-origin SafeFrames, and gate ad script injection on the first LCP paint event. |
| CLS (Cumulative Layout Shift) | <= 0.10 (Good)0.10-0.25 (Needs Improvement)> 0.25 (Poor) |
Unreserved multi-size ad slots (970x250 / 728x90), late-injected interstitials/outstream units, and collapseEmptyDivs(true) on low-fill slots. |
Reserve maximum slot height via CSS min-height, apply contain: layout style, disable dynamic div collapse above the fold, and center smaller creatives. |
Auditing Slow Third-Party Scripts with the Event Timing and Long Animation Frames APIs
Optimizing wrapper code is only sustainable if engineering teams have continuous field telemetry that attributes main-thread delays to specific demand partners, identity modules, or creative verification scripts. Historically, the W3C Long Tasks API (PerformanceLongTaskTiming) provided limited operational value for AdOps teams: it flagged tasks exceeding 50ms but lacked script-level stack attribution, usually reporting generic "unknown" or "cross-origin-unreachable" container strings and providing no visibility into how those tasks impacted frame rendering or style recalculation.
Modern Chromium browsers (Chrome 123 and newer) solve this attribution gap by combining the Event Timing API (PerformanceEventTiming) with the Long Animation Frames API (PerformanceLongAnimationFrameTiming, or LoAF). Rather than measuring isolated CPU tasks in a vacuum, a Long Animation Frame entry captures the entire sequence of work from the start of a task through any subsequent microtasks, requestAnimationFrame callbacks, and the final style and layout recalculation phase whenever the total frame duration exceeds 50ms.
Crucially, each long-animation-frame entry exposes a scripts array containing PerformanceScriptTiming objects for every script that consumed at least 5ms during that slow frame. Each script record provides:
sourceURL: The exact script URL (for example,https://securepubads.g.doubleclick.net/pagead/managed/js/gpt/m2025.../pubads_impl.jsor a specific SSP adapter bundle).sourceFunctionNameandsourceCharPosition: The function name and character offset where execution started.invokerandinvokerType: Whether the execution was triggered by aclassic-scriptevaluation, anevent-listener(such asWindow.scroll.passiveorDOMWindow.message), auser-callback(setTimeout,requestAnimationFrame), or apromise-resolvehandler (such as afetchresponse from a bidder endpoint).forcedStyleAndLayoutDuration: The exact number of milliseconds the script spent forcing synchronous layout thrashing (for example, callingelement.getBoundingClientRect()or readingelement.offsetHeightimmediately after mutating DOM styles).
By correlating event timing entries that exceed the 200ms INP threshold with overlapping long-animation-frame records, AdOps engineers can pinpoint which third-party vendors are degrading field INP and automatically adjust bidder participation or move offending client-side adapters to server-side execution:
/**
* Production Telemetry Collector: Correlates slow user interactions (>200ms INP candidates)
* with Long Animation Frames (LoAF) script attribution to audit ad-tech vendor impact.
*/
(function initAdStackLoAFMonitor() {
if (
!('PerformanceObserver' in window) ||
!PerformanceObserver.supportedEntryTypes.includes('long-animation-frame') ||
!PerformanceObserver.supportedEntryTypes.includes('event')
) {
return;
}
// Rolling ring buffer of recent Long Animation Frames (last 25 entries)
const recentLoAFs = [];
const MAX_BUFFER_SIZE = 25;
const loafObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
recentLoAFs.push(entry);
if (recentLoAFs.length > MAX_BUFFER_SIZE) {
recentLoAFs.shift();
}
}
});
loafObserver.observe({ type: 'long-animation-frame', buffered: true });
const eventObserver = new PerformanceObserver((list) => {
for (const eventEntry of list.getEntries()) {
// Filter for discrete user interactions (non-zero interactionId) exceeding 200ms
if (!eventEntry.interactionId || eventEntry.duration <= 200) {
continue;
}
const eventStart = eventEntry.startTime;
const eventEnd = eventEntry.startTime + eventEntry.duration;
// Find any Long Animation Frames that overlapped with this slow interaction
const overlappingFrames = recentLoAFs.filter((loaf) => {
const loafEnd = loaf.startTime + loaf.duration;
return loaf.startTime <= eventEnd && loafEnd >= eventStart;
});
if (overlappingFrames.length === 0) continue;
const culpritScripts = [];
for (const frame of overlappingFrames) {
for (const script of frame.scripts) {
if (script.duration < 10) continue; // Ignore minor scripts < 10ms
culpritScripts.push({
sourceURL: script.sourceURL || 'inline-or-anonymous',
functionName: script.sourceFunctionName || 'anonymous',
invoker: script.invoker,
invokerType: script.invokerType,
executionDurationMs: Math.round(script.duration),
forcedLayoutDurationMs: Math.round(script.forcedStyleAndLayoutDuration),
executionStartOffsetMs: Math.round(script.executionStart - eventStart)
});
}
}
if (culpritScripts.length > 0) {
const payload = JSON.stringify({
pageUrl: window.location.pathname,
interactionType: eventEntry.name,
totalInteractionDurationMs: Math.round(eventEntry.duration),
inputDelayMs: Math.round(eventEntry.processingStart - eventEntry.startTime),
processingDurationMs: Math.round(eventEntry.processingEnd - eventEntry.processingStart),
presentationDelayMs: Math.round(eventEnd - eventEntry.processingEnd),
blockingDurationMs: Math.round(
overlappingFrames.reduce((sum, f) => sum + f.blockingDuration, 0)
),
scripts: culpritScripts
});
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/v1/telemetry/cwv-ad-audit', payload);
}
}
}
});
// Observe interactions with duration >= 40ms
eventObserver.observe({
type: 'event',
durationThreshold: 40,
buffered: true
});
})();
When analyzing the output of this telemetry pipeline in production, engineering and yield teams should govern their ad stack using three quantitative operational rules:
- Quarantine Layout-Thrashing Scripts: Any bidder adapter, RTD module, or viewability script recording a 75th-percentile
forcedStyleAndLayoutDurationabove 15ms during active scroll or tap interactions should be refactored to use asynchronousIntersectionObserverrecords or migrated to Prebid Server immediately. - Cap Client-Side Adapter Concurrency: Limit client-side Prebid.js 11.x auctions to a maximum of five high-yield, low-latency adapters on mobile viewports, routing all secondary SSP and DSP demand through server-side OpenRTB 2.6 connections.
- Enforce Deterministic Slot Shells: Audit every ad unit ID across mobile and desktop templates to verify that
min-heightandcontain: layout styleare applied in static CSS before the HTML Preload Scanner reaches the<body>, ensuring that programmatic revenue scaling never comes at the expense of search engine visibility or user experience.