Skip to content

Core: allow viewability to be measured without yielding the main thread - #15421

Open
dgirardi wants to merge 11 commits into
prebid:masterfrom
dgirardi:agent/percent-in-view-defer-allocation
Open

Core: allow viewability to be measured without yielding the main thread#15421
dgirardi wants to merge 11 commits into
prebid:masterfrom
dgirardi:agent/percent-in-view-defer-allocation

Conversation

@dgirardi

Copy link
Copy Markdown
Collaborator

Type of change

  • Bugfix

  • Feature

  • New bidder adapter

  • Updated bidder adapter

  • Code style update (formatting, local variables)

  • Refactoring (no functional changes, no api changes)

  • Build related changes

  • CI related changes

  • Does this change affect user-facing APIs or examples documented on http://prebid.org?

  • Other

Description of change

Prebid 11 (#14433) added a startAuction hook that waits for IntersectionObserver entries for
every ad unit element before the auction may begin, so that percentInView() can report
observer-derived viewability. That wait is a pre-auction delay that did not exist in Prebid 10, and it
is a plausible contributor to the slowdown reported in #15403.

The headline of this PR is the last commit: auctionOptions.viewabilityMeasurement, which lets a
publisher choose not to wait at all. Everything before it exists to make that a real choice rather
than a trade of latency for wrong numbers — the bounding-rect path it falls back to was substantially
less accurate than the observer, so opting out used to mean accepting bad viewability.

The new setting

pbjs.setConfig({ auctionOptions: { viewabilityMeasurement: 'boundingBox' } });
  • 'observer' (default) — reading the measurement costs no layout at all and correctly accounts for
    everything that clips the ad, including the bounds of a cross-origin iframe. But the auction cannot
    start until the observer reports, which requires yielding the main thread; on a page busy with long
    tasks, that yield is only taken once the longest of them finishes.
  • 'boundingBox' — nothing is waited for, so the auction never queues behind the rest of the page.
    In exchange each measurement forces a layout, and inside a cross-origin iframe it can only measure
    against the frame's own viewport.

It also follows pbjs.yield: with no explicit setting, pbjs.yield = false selects 'boundingBox',
since that flag already asks Prebid not to yield the main thread. An explicit
viewabilityMeasurement wins over pbjs.yield in both directions.

Making the wait bind, and usually unnecessary

The wait was capped by racing against delay(20), which does not bound it: intersection delivery and
an ordinary timer both queue behind the same ready work, so on a busy page both sides of the race are
held up together.

  • urgentDelay (new, in src/utils/promise.ts) schedules through scheduler.postTask at
    user-blocking priority, falling back to a timer where that is unavailable or rejects the options.
    Measured against a single 100 ms blocking task, the worst-case wait drops from ~201 ms to ~101 ms
    bounded by the longest blocking task rather than the whole backlog. delay() is unchanged.
  • Pre-warming: ad unit elements are now observed from a requestBids hook rather than from the
    startAuction hook that waits on them. Observation previously began in the same hook that awaited
    it, so the asynchronous work running beforehand (consent, price floors, currency) gave the observer
    no main-thread time it could use. First entries arrive 0.1–0.3 ms after observe(), so where
    those modules are present the auction hook now resolves without waiting.

Making 'boundingBox' accurate enough to choose

The bounding-rect path only intersected the element's own box with the viewport. Measured against the
observer as ground truth:

scenario before after observer
half-clipped by an overflow:hidden ancestor 100 50 50
fully clipped by an overflow:hidden ancestor 100 0 0
scrolled out of a scrolling ancestor 100 0 0
below a same-origin frame's bounds 100 0 0
visibility:hidden / opacity:0 (element or ancestor) 100 0 100
position:fixed or absolute escaping a clipper 100 100 100

Costs 222 bytes gzipped, and 0.06–0.71 ms per auction for 5–30 ad units at DOM depth 10–30
(~0.85 µs per ancestor for the style read, 3.2 µs per clipping ancestor for its rect). Clipping is
approximated in one respect, documented on getClipRects: clip boxes include ancestor borders rather
than stopping at the padding edge.

Out-of-flow elements are excluded from clipping by ancestors that do not contain them — an
untransformed overflow:hidden ancestor does not clip a position:fixed descendant. Without that,
anchored and sticky ad units reported 0% while fully on screen.

Other fixes in the observer path

  • Zero-area elements reported 100%. Intersection observers define the ratio of a zero-area target
    as 1 when it is intersecting, so a collapsed slot read as fully viewable; callers that pass no size
    override (taboola, for one) rounded that onto the bid request. Now 0, matching the bounding-rect
    path.
  • A deferred was allocated per intersection entry. The observer is a page-lifetime singleton
    registered with 101 thresholds, so this ran on every threshold crossing of every observed element
    for as long as the page lived, whether or not anything was waiting. Over a 6000 px scroll with 30
    observed elements, promise allocations drop from 1294 to 0.
  • Size overrides forced a layout for a rect the observer had already reported.

Other information

Addresses #15403.

Labels: feature, minor

Docs PR: required for auctionOptions.viewabilityMeasurement — to be linked here.

Lint: npx eslint clean on all changed files.

Tests: npx gulp test — 8/8 chunks, 23,911 tests, 0 failures. Added coverage for the new setting
and its resolution against pbjs.yield, the clipping and containing-block behaviour, urgentDelay's
scheduling and fallbacks, and the pre-warm hook.

Scope: this is deliberately one PR because the earlier commits are what make the new setting
usable, but it does span a bugfix set and a feature. Happy to split it into (1) the accuracy and
allocation fixes to percentInView, (2) urgentDelay plus pre-warming, and (3) the config option, if
that reviews better.

Known limitations, unchanged by this PR: in a cross-origin iframe the bounding-rect path cannot
determine the frame's position in the page, so an off-screen ad can still read as fully in view — no
JS in a cross-origin child can obtain that geometry. Neither path detects an ad occluded by other page
content; that needs IntersectionObserver v2 trackVisibility, which mandates delay >= 100 and is
Chromium-only.

dgirardi and others added 9 commits July 30, 2026 11:09
… entry

The intersection cache woke waiters through a single shared deferred, which the
observer callback had to resolve and replace for every entry it stored. Since
the observer is a page-lifetime singleton registered with 101 thresholds, that
allocation ran on every threshold crossing of every observed element for as long
as the page lived, whether or not anything was waiting.

Replace the shared deferred with a per-element map of pending resolvers, so a
promise is created only when a caller actually waits on an element and is
dropped as soon as that element's first entry arrives. Waking is now targeted
rather than a broadcast that every waiter re-checked itself against.

Measured over a 6000px scroll with 30 observed elements: promise allocations
drop from 1294 to 0 once the initial observations have settled.

Behavior is unchanged - `waiting` is a WeakMap so it adds no retention, and
check-then-subscribe stays synchronous so there is no lost-wakeup window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Intersection observers define the ratio of a zero-area target as 1 whenever it
is intersecting, so an element that had collapsed to no width or no height read
back as 100% in view. Callers that pass no size override - taboola, for example
- rounded that straight onto the bid request as full viewability for a slot
that renders nothing.

Return 0 for an element with no area instead, which is what the bounding-client-
rect path already reports for the same element. The check sits after the size
override test so a caller supplying w/h still gets the substituted size.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
When a size override applies, the element has collapsed to no area and the
percentage has to be recomputed from the substituted w/h. That recomputation
measured the element again with getBoundingClientRect, which flushes pending
layout - on a page with a dirty, expensive layout that call alone can cost tens
of milliseconds. The observer has already reported where the element is, so the
rect is available for free.

Split the geometry out of percentInViewStatic into percentInViewOfBox and feed
it the rect from the intersection entry. The arithmetic and its inputs are
unchanged, so the resulting percentage is the same; only the layout flush goes
away.

The entry's rootBounds is deliberately not used as the viewport here: for an
observer inside an iframe, boundingClientRect is expressed in the iframe's own
viewport while rootBounds describes the top level one, so intersecting the two
would report a below-the-fold frame as fully in view.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bounding rect path only intersected the element's own box with the viewport,
so it reported an element as fully in view whenever its layout position happened
to fall inside the viewport - even when an overflow-hidden ancestor, a scroll
container, or the bounds of a containing frame meant that nothing was actually
painted. It also ignored styling that renders nothing at all, reporting a
transparent or hidden slot as fully viewable.

Collect the boxes of the element's scrolling and overflow-hidden ancestors, and
of every frame containing it, and intersect those along with the viewport;
report 0 when the element or an ancestor is hidden or fully transparent.
getIntersectionOfRects already took a list of rectangles, so the intersection
itself is unchanged. Costs 222 bytes gzipped, and roughly a microsecond per
ancestor walked.

Clipping is approximated, as documented on getClipRects: the boxes include
ancestor borders rather than stopping at the padding edge, an ancestor clipping
one axis is treated as clipping both, and an out-of-flow element is treated as
clipped by ancestors outside its containing block chain.

Walking out of the frame containing the tests exposed that the harness patch
faking the frame's box has always thrown, since a Window has no
getBoundingClientRect - which went unnoticed because getViewportOffset catches
and zeroes the offset, the same result the patch intended. Move it next to the
other percentInView mocks, where it works and where a test that needs the frame's
real geometry can turn it off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Auctions wait for intersection entries for their ad unit elements, capped by
racing against a timer. The cap does not hold: intersections are delivered as
part of the rendering lifecycle and the timer callback queues behind the same
pending work, so on a busy page both sides of the race are held up together and
the wait runs well past its deadline.

Two changes. `urgentDelay` asks for the continuation through
scheduler.postTask at user-blocking priority, so it is dispatched ahead of
ordinary timers and network callbacks once the main thread frees, falling back to
a timer where postTask is missing or rejects the options. This is not a real time
bound - nothing preempts a running task - but it bounds the wait by the longest
single blocking task rather than by the whole backlog of ready work.

Second, start observing when bids are requested rather than when the auction
starts. Observation previously began in the same hook that waited on it, so the
asynchronous work that runs beforehand - consent, price floors, currency, user
ids - gave the observer no main thread time it could use. Observing from a
requestBids hook, at a priority ahead of those, means entries are normally
cached by the time the auction hook runs and it resolves without waiting.

The requestBids wrapper resolves the global ad unit array before any hook runs,
and passes the unfiltered set, so this sees every element the auction hook will
ask about and possibly a few more, which is harmless. `delay` is left alone; it
is used widely and its scheduling should not change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… element

Out of flow elements are laid out against something further up the tree and are
not clipped by the ancestors they skip over: an untransformed overflow-hidden
ancestor does not clip a fixed position descendant, and a statically positioned
one does not clip an absolutely positioned descendant. Clipping every ancestor
regardless reported such an element as 0% in view while it was fully on screen -
the largest error possible, and one that lands on anchored and sticky ad units,
which are typically fixed inside a wrapper that hides its overflow.

Track how the subtree being walked is positioned, and only collect a clip box
from an ancestor that contains it: for an absolutely positioned element, one that
is itself positioned, and for either an absolute or a fixed one, any ancestor
establishing a containing block through a transform, perspective, filter,
will-change or contain. Once an ancestor is found to carry the element, its own
positioning decides which of the remaining ancestors can clip it.

Hidden and transparent ancestors are still checked whether they contain the
element or not, since an ancestor that is not painted hides a fixed descendant
even though it does not clip it.

The properties this needs are read from the style already being fetched for the
overflow check, and only for elements that are out of flow, so an in flow element
costs nothing extra and an absolutely positioned one about 3us per call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… at 20ms

The cap read as a safety net that was not expected to fire, which is what let it
go unnoticed that it did not bound anything. Record what the value is chosen
against: the first observation for an element arrives as a queued task rather than
with the rendering lifecycle that carries later updates, so it needs well under a
millisecond of free main thread, and a much smaller cap would be
counterproductive because a user-blocking task outruns the observer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…abled

Reading viewability from an intersection observer means waiting for the observer
to report, and so yielding the main thread. Setting `pbjs.yield = false` asks
Prebid not to do that, so honour it here too: skip the wait before the auction,
skip observing ad unit elements ahead of it, and measure from the element's
bounding rect instead.

Skipping the two hooks is not enough on its own to keep to the bounding rect
path, because percentInView starts an observation itself whenever it has no entry
for an element - so later calls would find one and go back to reading the
observer. It now goes straight to the bounding rect, without observing.

The check is made where the measurement is taken rather than when the hooks are
attached, because attachment happens as this module loads, while `pbjs.yield` is
read lazily wherever else it is used and so may still be set from the command
queue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Whether viewability comes from an intersection observer or from the ad element's
bounding rect is a trade between processing work and how much the auction can be
held up by the rest of the page: reading an observer entry costs no layout at all,
but the auction cannot start until the observer has reported, which on a page busy
with long tasks means waiting out the longest of them. The bounding rect is
immediate but forces a layout for every measurement, and inside a cross origin
iframe can only measure against the frame's own viewport.

That choice was only reachable through `pbjs.yield`, which turns off main thread
yielding everywhere and is too broad an instrument for it. Give it a setting of
its own, defaulting to the observer, or to the bounding rect where yielding is
turned off.

The auctionOptions validator only knew how to check booleans and arrays, so it
would have rejected the whole object; it now also takes keys limited to a set of
values, and warns which ones are accepted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

This PR introduces changes that may not work on all browsers. According to Babel, the following polyfills may be needed, and they are not automatically included:

  • Changes to libraries/percentInView/percentInView.js may need:
    • es.array.push
    • es.iterator.filter
    • esnext.iterator.filter
  • Changes to test/spec/libraries/percentInView_spec.js may need:
    • es.iterator.constructor
    • es.iterator.for-each
    • esnext.iterator.constructor
    • esnext.iterator.for-each

The best way to address this is to provide good test coverage, as normal PR checks run unit tests on older browsers.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 67f19c793b

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread libraries/percentInView/percentInView.js
@barecheck

barecheck Bot commented Jul 30, 2026

Copy link
Copy Markdown

Barecheck - Code coverage report

Total: 96.66%

Your code coverage diff: 0.00% ▴

Uncovered files and lines
FileLines
src/auction.ts312, 401-402, 412-413, 428, 437, 440, 490, 492, 502, 526, 824, 908, 910, 916, 961, 1100, 1135
src/config.ts88-89, 151, 198, 402, 469, 482-483, 528-529, 642
test/spec/libraries/percentInView_spec.js110
test/spec/unit/utils/promise_spec.js36
test/test_deps.js5, 20

dgirardi and others added 2 commits July 30, 2026 16:07
…yet reported

Observing an element records it with a null entry until the observer first reports
on it. Observing it a second time in that window found it already recorded and
resolved straight away with that null, rather than waiting - so once ad unit
elements were observed as bids are requested, the hook that waits for them before
the auction had nothing left to wait on. Where nothing yields the main thread in
between, no entry has arrived by then, and every measurement falls back to the
bounding rect even though the observer was asked for.

Distinguish an element that has never been observed from one that is observed and
pending, and wait in the second case. An element that already has an entry still
resolves immediately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant