Most of the advice about ad lazyloading you find online is either outdated, oversimplified, or just plain wrong if you actually care about viewability and money. This post is based on the pain of building a real lazyloading library from scratch, testing it in production, and seeing what works (and what doesn’t) with Prebid.js and modern web ads.
Load less, later. For ads, this is survival. If you load ads nobody sees, you waste money, slow down your site, and annoy users. Prebid.js (and header bidding in general) needs lazyloading done right — not for some boss’s spreadsheet, but for anyone who cares about clean code, real viewability, and not burning budget for nothing.
Ever seen a site where ads are loaded everywhere, even far below the fold? Most people never scroll there. Every time this happens, you throw money away and trash your stats. Good lazyloading means:
- Only load or show ads if there’s a real chance someone will see them
- You don’t kill the browser or the user’s network, you don’t turn user’s device into a heater
- Viewability is higher and advertisers don’t get mad.
Why Timing Matters
An impression isn’t just when the ad renders—it’s when it was rendered and met the viewability rules. If you render too early (before the user scrolls), you might burn impressions that never count as viewable. If you render too late, you get blank slots or content that jumps when the ad finally appears. The trick is finding the middle ground.
If you only render when the user is already looking, you get flicker or layout shift (annoying). If you render too early, you’re paying for ads nobody sees. Predict when the user will get there—and render just before that.
What is Viewability?
Viewability is just: did a human have a shot at seeing this ad? The IAB standard is simple: 50% of the ad’s pixels, in view, for at least 1 second (2 seconds for video). Of course, ads don’t load instantly—network, code, and rendering take time. You’ll never be perfect, but the closer you get, the more honest your stats.
You can’t always hit this perfectly. But you should at least try—don’t request or render ads if the user clearly isn’t going to see them.
When to Request and When to Render
Here’s where the concept of an ‘extended viewport’ comes in—sometimes called a buffer or using the rootMargin in IntersectionObserver. Imagine your browser’s viewport is 800px tall. If you set rootMargin: ‘300px 0px’, the observer will start tracking ads when they are up to 300px below (or above) the visible area. The extended viewport looks like this:
| |
[--- Above viewport ---]
| | <- buffer: 300px
[======================]
| VISIBLE AREA |
[======================]
| | <- buffer: 300px
[--- Below viewport ---]
| |
So, your code can trigger actions (like fetching bids or preparing an ad for render) before the ad is actually visible, which is crucial for good user experience and viewability stats.
Here’s another way to visualize the timing for fetch and render:
| |
[======================]
| VISIBLE AREA |
[======================]
| |
| 200px buffer | <- time to RENDER ad
| |
| |
| 600px buffer | <- time to FETCH BIDS
[--- below viewport ---]
| |
So in practice:
- When the ad slot is 600px away from viewport, you fire the auction (fetch bids)
- When the ad slot is 200px from viewport, you render the creative
- When the ad actually enters the viewport, everything is already in place—no jank, no blank slot.
Why not do both together? Some bidders are slow, so if you request too late, the slot will be empty for a moment. But if you request too early, bids expire or give worse prices. Always set reasonable timeouts, and use a buffer zone for both request and render.
Don’t wait until the ad is pixel-perfect in view. If your user scrolls fast, or has slow internet, you’ll miss your shot. Expand your extended viewport margins in the scroll direction:
- Fast scrolling? Use a bigger margin ahead.
- Slow device or network? Trigger even earlier.
Why Not Just Use getBoundingClientRect?
Well, it looks like it solves the issue:
const buffer = 300;
const adSlot = document.getElementById('your-ad-slot');
function isInExtendedViewport(el) {
const rect = el.getBoundingClientRect();
const vpHeight = window.innerHeight || document.documentElement.clientHeight;
const vpWidth = window.innerWidth || document.documentElement.clientWidth;
return (
rect.bottom >= -buffer &&
rect.right >= -buffer &&
rect.top <= vpHeight + buffer &&
rect.left <= vpWidth + buffer
);
}
const interval = setInterval(() => {
if (isInExtendedViewport(adSlot)) {
// It's time to do something cool
clearInterval(interval);
}
}, 300); // Check ~3 times per second
This checks if any part of the element is within 300px above/below or left/right of the viewport. For lots of elements, you should throttle or debounce, and avoid this if you can use IntersectionObserver. But:
- It’s not cheap: triggers layout recalculation, can slow down your page
- Not reliable for sticky, absolutely positioned, or hidden elements
- Polling every scroll/resize with many slots = browser meltdown
IntersectionObserver: Your Go-To Tool
IntersectionObserver is made for this. It notifies you when an element is near or inside the viewport, without hammering the browser. You can use the rootMargin option to trigger your logic a bit before the slot is visible—no blanks or jank.
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// It's time to do something!
}
});
},
{
rootMargin: '300px 0px', // Tune based on scroll speed
threshold: 0.5, // 0.5 for 50% pixels
},
);
observer.observe(adSlot);
But here’s the catch: you can’t update rootMargin
on the fly. If you want to change it (say, when scroll speed changes), you have to disconnect the observer and make a new one. Creating observers isn’t free, so do it in a throttled way—don’t spam it every scroll event.
Also note that observer won’t fire an intersection event twice for the same element. To do that you also have to recreate the observer with a small timeout.
Should You Use trackVisibility? Nope
IntersectionObserver v2 added trackVisibility
, which tries to tell you if an element is truly visible on the screen (not just geometrically intersecting). Here’s what it looks like:
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isVisible) {
// Element is actually visible (not just intersecting)
}
});
},
{
threshold: 0.5, // same as before, use 0.5 for 50% pixels
trackVisibility: true, // this enables the new behavior!
delay: 100, // optional, to avoid flicker in/out
},
);
observer.observe(adSlot);
But here’s the deal:
- Barely supported in browsers
- Easily gives false negatives, especially with quick scrolling or weird layouts
- Specs let browsers throttle or miss events
It sounds cool, but it’s just not anywhere near reliable in production yet.
checkVisibility(): A New Hope, With Caveats
Some new browsers have a method called checkVisibility() on elements. It checks more than just position—it looks at styles (display, opacity, hidden parents) to see if an element is actually visible.
Example:
if (adSlot.checkVisibility()) {
// This element is really visible to the user
}
If not available, you’ll have to fake it with bounding rects and style checks.
Here’s a simple polyfill for checkVisibility()
:
function checkVisibilityPolyfill(el) {
if (!el.isConnected) return false;
if (el.hidden || el.getAttribute('aria-hidden') === 'true') return false;
const computedStyle = getComputedStyle(el);
if (computedStyle.display === 'none' || computedStyle.visibility === 'hidden')
return false;
if (parseFloat(computedStyle.opacity) === 0) return false;
return el.offsetWidth > 0 || el.offsetHeight > 0;
}
This doesn’t check for intersection with viewport, only for CSS/DOM visibility, but that’s what checkVisibility()
does under the hood.
TL;DR
- Never load an ad unless there’s a real chance someone will see it.
- Fetch bids early (e.g., 600px before the viewport), render ads just in time (e.g., 200–300px before).
- Always separate request and render logic—don’t do both at once.
- Use IntersectionObserver, not manual polling.
- Adjust your buffer (rootMargin) based on scroll speed and device.
- Don’t trust new browser APIs blindly: trackVisibility is not reliable yet, checkVisibility is cool but rare.
That’s it. Good luck, and may your fill rates be high!