Most ad lazy loading advice is too simple: “wait until the slot is visible, then load the ad”. That sounds clean, but in production it usually produces one of two bad outcomes.
If you wait too long, the user sees an empty slot or a layout shift. If you load too early, you spend auction and rendering work on slots nobody reaches, and the viewability numbers suffer.
The useful mental model is not “visible or not visible”. It is a slot lifecycle:
- Reserve space for the slot.
- Start the auction before the slot is visible.
- Render close enough to the viewport that the creative is ready in time.
- Track whether the rendered slot had a realistic chance to be seen.
- Refresh only when your policy, demand partners, and ad server setup allow it.
This article is about that lifecycle. It is based on building lazy loading logic for real ad stacks with Prebid.js and browser visibility APIs. It is not an accredited measurement recipe. If you need billable viewability measurement, use the measurement system your ad stack requires.
Impression Is Not Viewable Impression
Do not mix these words.
An ad request, an ad server impression, and a viewable impression are not the same event. The important industry baseline is usually described as 50% of the ad pixels in view for at least 1 continuous second for display, and 2 continuous seconds for video. The IAB/MRC guidelines exist exactly because a lot can happen between fetching an ad and having it actually in view.
Lazy loading cannot guarantee viewability by itself. It can only make bad timing less likely:
- avoid requesting slots the user will probably never reach;
- avoid rendering so late that the user sees a blank slot;
- avoid refreshing a slot before the previous creative had a fair chance.
That is already worth doing.
Think in Time, Not Pixels
People often configure lazy loading with fixed margins:
- request bids 600px before the viewport;
- render 200px before the viewport.
That is a useful starting point, not a law.
What you are really estimating is time:
- how long the auction usually takes;
- how long your ad server call and creative render take;
- how fast the user is scrolling;
- how much main-thread pressure the page has.
If a user scrolls slowly, 600px may be too early. If the user scrolls fast on a slow network, 600px may be too late. Treat margins as a latency budget expressed in pixels.
The simple version:
farther from viewport -> request bids
closer to viewport -> render ad
inside viewport -> measure exposure / allow refresh timer
For example:
const AUCTION_MARGIN = '700px 0px';
const RENDER_MARGIN = '250px 0px';
Tune those numbers from field data. Guessing once and never revisiting them is how lazy loading quietly becomes cargo cult.
Reserve the Slot First
Lazy loading does not excuse layout shift. If the slot can appear, reserve its size before the ad is requested.
.ad-slot {
min-height: 250px;
}
.ad-slot[data-size='leaderboard'] {
min-height: 90px;
}
The exact sizing strategy depends on your placements and responsive rules. The important part is that the page should not jump when a creative arrives.
Use IntersectionObserver for the Triggers
IntersectionObserver is the right default tool for proximity triggers. MDN describes it as an asynchronous way to observe when a target intersects the viewport or another root. That matters because the browser can manage this more efficiently than your own scroll handler that repeatedly calls geometry APIs.
Use separate observers for separate lifecycle stages:
const slots = new Map();
function observeAdSlot({ element, code, gptSlot }) {
slots.set(code, {
element,
code,
gptSlot,
auctionStarted: false,
rendered: false,
});
auctionObserver.observe(element);
renderObserver.observe(element);
}
const auctionObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const slot = findSlot(entry.target);
if (!slot || slot.auctionStarted) continue;
slot.auctionStarted = true;
requestBidsForSlot(slot);
auctionObserver.unobserve(entry.target);
}
},
{
root: null,
rootMargin: '700px 0px',
threshold: 0,
},
);
const renderObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const slot = findSlot(entry.target);
if (!slot || slot.rendered) continue;
slot.rendered = true;
renderSlot(slot);
renderObserver.unobserve(entry.target);
}
},
{
root: null,
rootMargin: '250px 0px',
threshold: 0,
},
);
function findSlot(element) {
for (const slot of slots.values()) {
if (slot.element === element) return slot;
}
return null;
}
This is deliberately boring. Boring is good here. A slot enters the wide margin, so the auction starts. Later it enters the smaller margin, so rendering starts.
One correction to a common myth: an observer is not a one-shot API. It can notify when thresholds are crossed, and it also runs an initial observation callback. If your lazy loading action should happen once, make that explicit with state and unobserve().
Prebid: Request Bids Before You Render
Prebid’s requestBids API lets you request specific ad units with adUnitCodes, set a timeout, and run a callback when responses are back or the timeout hits.
The exact integration depends on your ad server, but the shape is usually:
const PREBID_TIMEOUT = 1000;
function requestBidsForSlot(slot) {
pbjs.que.push(() => {
pbjs.requestBids({
adUnitCodes: [slot.code],
timeout: PREBID_TIMEOUT,
bidsBackHandler: () => {
slot.bidsReady = true;
},
});
});
}
For a Google Ad Manager setup, rendering often means setting Prebid targeting and refreshing the GPT slot:
function renderSlot(slot) {
pbjs.que.push(() => {
pbjs.setTargetingForGPTAsync([slot.code]);
googletag.cmd.push(() => {
googletag.pubads().refresh([slot.gptSlot]);
});
});
}
That example is intentionally minimal. In a real wrapper, you also need a failsafe path: if bids are not ready by render time, you either wait a bounded amount of time or call the ad server without Prebid demand. Prebid’s own timeout docs frame this as a balance: wait long enough for bids, but not so long that revenue drops because the ad server call is delayed.
Do not make the auction margin bigger forever to “avoid blanks”. Bids have time-to-live constraints. Prebid also has cache-related behavior and TTL buffer settings. Requesting too early can create a different class of bugs: bids that are stale by the time you render, confusing refresh behavior, or demand-side discrepancies.
Refresh Is a Separate Policy
Lazy loading and refresh are related, but they are not the same feature.
For refresh, I use a stricter rule than for first render:
- the slot must have rendered;
- the slot must have spent enough time in view according to your policy;
- the user must still be engaged with the page;
- the refresh must start a new auction for that slot;
- the previous bid should not be reused accidentally past its useful lifetime.
The “enough time” part is a product and monetization policy, not a browser API truth. Use your ad server rules, demand partner contracts, and analytics data here.
A reasonable implementation keeps a small state machine per slot:
const MIN_VIEW_TIME_FOR_REFRESH = 30000;
function markVisible(slot) {
slot.visibleSince = performance.now();
}
function markHidden(slot) {
slot.visibleSince = null;
}
function canRefresh(slot) {
if (!slot.rendered || slot.visibleSince == null) return false;
return performance.now() - slot.visibleSince >= MIN_VIEW_TIME_FOR_REFRESH;
}
This is not enough for billing-grade viewability. It is enough to avoid obvious bad refreshes.
What About getBoundingClientRect?
getBoundingClientRect() is useful for debugging and fallback code. I do not use polling around it as the primary lazy loading mechanism unless I have no choice.
This kind of code is easy to write:
function isNearViewport(element, buffer) {
const rect = element.getBoundingClientRect();
const height = window.innerHeight || document.documentElement.clientHeight;
return rect.top <= height + buffer && rect.bottom >= -buffer;
}
The problem is not that this is always wrong. The problem is that a production page can have many slots, nested scroll containers, sticky elements, iframes, CMPs, refresh timers, and other scripts competing for the main thread. A polling loop that is harmless in a demo can become noisy in the exact environment where ad lazy loading is supposed to help.
Use it as a fallback or sanity check. Prefer IntersectionObserver for the main trigger.
What About trackVisibility?
trackVisibility sounds perfect for ads because it tries to avoid reporting elements that the browser considers visually compromised. The catch is in the details.
MDN currently marks IntersectionObserver.trackVisibility as limited and experimental. The algorithm is conservative, can omit elements that are technically visible, and the visibility calculation is computationally expensive. When enabled, it should be paired with delay, with a minimum delay of 100ms.
That makes it interesting for some analytics experiments. It does not make it my default trigger for ad loading.
If you use it, use it as an additional signal:
const supportsTrackVisibility =
'IntersectionObserver' in window &&
'trackVisibility' in IntersectionObserver.prototype;
const observerOptions = supportsTrackVisibility
? {
threshold: 0.5,
trackVisibility: true,
delay: 250,
}
: {
threshold: 0.5,
};
Do not build the whole revenue path on an experimental visibility signal.
What About checkVisibility()?
checkVisibility() is useful, and it is no longer some obscure toy API. MDN lists it as Baseline 2024.
But it answers a narrower question than ad viewability. It checks whether an element has a box and whether certain CSS visibility conditions apply. Optional flags add checks for opacity, visibility, and content-visibility: auto.
That is helpful for catching obvious “this slot cannot be seen” cases:
function isRenderEligible(element) {
if (!('checkVisibility' in element)) return true;
return element.checkVisibility({
opacityProperty: true,
visibilityProperty: true,
contentVisibilityAuto: true,
});
}
I would not use it as the only gate when reliability must be 100%. In production, I have seen checkVisibility() fail to match the operational answer I needed for ad slots. That is not surprising: “CSS-visible element” and “ad was actually viewable to a human for the required time” are different problems.
My practical rule:
- use
IntersectionObserverfor proximity and exposure timing; - use
checkVisibility()as an extra guard when available; - keep your own slot state;
- verify the result against ad server and analytics data.
A Practical Slot State
The code below is not a drop-in library. It is the shape I want in production: explicit states, one-time transitions, and measurable timing.
function createSlotState({ code, element, gptSlot }) {
return {
code,
element,
gptSlot,
auctionStarted: false,
bidsReady: false,
rendered: false,
visibleSince: null,
lastRefreshAt: null,
};
}
function shouldRequestBids(slot) {
return !slot.auctionStarted;
}
function shouldRender(slot) {
return (
slot.auctionStarted && !slot.rendered && isRenderEligible(slot.element)
);
}
This sounds less exciting than a clever observer trick, but it makes bugs visible. When a slot behaves badly, you can log the state transitions and see whether the problem was timing, visibility, Prebid, GPT, or your own refresh policy.
Tune With Measurements
The default margins are not the end of the work. Capture at least:
- time from auction start to
bidsBackHandler; - time from render call to creative visible enough for your policy;
- scroll speed before first render;
- blank-slot time;
- refresh interval and in-view time before refresh;
- bidder timeout rates;
- viewability by placement.
Then tune per placement. A sticky sidebar, an in-article rectangle, and an infinite-scroll feed slot do not deserve the same lazy loading numbers.
TL;DR
- Lazy loading ads is a lifecycle, not a single visibility check.
- Reserve the slot before loading anything.
- Request bids before render, but not so early that bids become stale.
- Use
IntersectionObserverfor proximity triggers. - Use
trackVisibilitycautiously; it is limited, experimental, conservative, and more expensive. - Use
checkVisibility()as an extra guard, not as a 100% reliable source of truth. - Keep explicit slot state and tune margins from production data.
The goal is not to make the browser prove viewability for you. The goal is to stop wasting auctions on slots nobody sees while still rendering early enough that real users do not stare at empty boxes.