I once pulled the request waterfall on a client's article page and counted 14 ad calls firing inside the first 900 milliseconds, half of them for slots the reader wouldn't reach for another 40 seconds of scrolling, if they ever got there at all. That page had a 3.9-second LCP and a bid density problem nobody could explain. Lazy loading fixed the speed number in a week. It took another month to stop it from quietly gutting fill rate on the slots that mattered.
What's Actually Deferred When You Lazy Load A Slot
Lazy loading an ad unit means you're not defining the GPT slot, kicking off the header bidding auction, or calling display/refresh until that slot's container is within some distance of the viewport. Everything before that point — the div sits on the page, empty, reserved, inert. No ad server call, no bid request, no creative render. Compare that to eager loading, where every slot on the page resolves at roughly the same moment during page load, regardless of whether it's visible in the first viewport or 6,000 pixels down the DOM.
The mechanism almost everyone uses now is IntersectionObserver rather than scroll-event polling, because polling scroll position on every pixel of movement is exactly the kind of main-thread work that tanks Total Blocking Time. IntersectionObserver instead registers a callback that the browser fires asynchronously when a watched element crosses a defined boundary relative to the viewport — no repeated layout recalculation on your end. You attach one observer per slot (or one observer watching many slots), and when the callback fires, that's your trigger to kick off the actual request pipeline.
It's worth being precise about what's being deferred, because publishers often conflate lazy loading images with lazy loading ads and assume the same script will work for both. Image lazy loading defers a single asset fetch. Ad lazy loading defers a request-to-render pipeline that can include a header bidding auction with eight or ten demand partners, a GAM ad server call, a creative render, and sometimes a viewability pixel fire — four to six times more moving parts than swapping a src attribute.
- Slot definition (googletag.defineSlot) — often done upfront regardless
- Header bidding auction (Prebid.js or equivalent requestBids call)
- GPT ad request (googletag.display or googletag.pubads().refresh())
- Creative render and any nested creative-side pixel or script fires
Why This Started As A Speed Fix, Not A Monetization Strategy
Lazy loading ads wasn't invented to help RPM. It got adopted, almost universally, as damage control after Google's 2021 Page Experience update folded Core Web Vitals into ranking signals, and agencies started telling every publisher on their roster to lazy load everything below the fold immediately. Before that, it was common to see article pages with 18-22 ad calls firing on DOMContentLoaded, each one contributing its own network request, its own JS execution, its own layout reflow. On a mid-range Android device on a throttled 4G connection, that's the difference between a 2.1-second LCP and a 4.6-second one.
The broader context matters here, and it's worth reading in full if you haven't audited your stack against it — see the technical checklist for page experience factors that Core Web Vitals sits inside of, because ad loading is one lever among six or seven that determine whether Google's field data classifies your pages as "Good" or "Needs Improvement." Lazy loading ads was never going to fix a bloated hero image or an unoptimized web font, but it was the single highest-leverage change for pages carrying heavy ad density, because ad tags are disproportionately responsible for long tasks and third-party script weight.
The problem is that "lazy load everything" became gospel advice repeated in guide after guide without much nuance about where the line should sit. That blanket approach is what causes the revenue problems I'll get into further down — because the fix for a genuine speed problem became, in a lot of implementations, an indiscriminate rule applied to units that never needed it.
It's also worth understanding that Core Web Vitals is a ranking signal that moves in step with Google's broader algorithm changes, not a one-time checkbox you fix and forget. If you track how past algorithm updates have shifted ad revenue for publishers, you'll notice page experience keeps resurfacing as a factor every twelve to eighteen months, usually alongside a tightening of what counts as "Good" versus "Needs Improvement" thresholds. Treat lazy loading as ongoing maintenance, not a project you close out once and never revisit.
Tuning rootMargin And Thresholds: Where The Buffer Actually Belongs
The rootMargin property is what determines how far in advance a slot triggers before it's physically visible. Set rootMargin: '0px 0px 300px 0px' and the observer treats the viewport as if it extended 300px further down than it really does, so the callback fires while the slot is still 300px below the visible screen. Get this number wrong in either direction and you've undone most of the benefit of lazy loading in the first place, just in opposite ways.
Too small a buffer — say 50px or relying on the default of 0px — means the ad request doesn't start until the slot is basically already on screen. On a fast mobile scroll, users cover 300-500px in well under a second, so a 50px buffer gives your auction almost no lead time before the user is staring at an empty slot. That's the visible pop-in everyone complains about: blank space, then a jarring reflow as the creative drops in half a second after the user has already started reading the next paragraph.
Too large a buffer defeats the purpose entirely. I've seen implementations with a 1500px rootMargin on every slot, which on most article layouts means the ad four screens down starts its request practically the moment the page loads — you've reintroduced the exact request pile-up you were trying to eliminate, just dressed up as "lazy loading." The sweet spot in most of the accounts I've tuned sits at 200-400px for desktop and 300-600px for mobile, since mobile scroll velocity tends to run higher and the auction round trip needs more lead time relative to how fast the slot approaches.
- Above-the-fold and first near-fold slot: no lazy load, or a 0px trigger with immediate auction start
- Mid-page slots (positions 2-4): rootMargin 200-350px on desktop, 300-450px on mobile
- Deep-page slots (position 5+): rootMargin can go tighter, 100-200px, since users reaching that point are lower-value moments to over-invest lead time in
- Threshold: most implementations leave this at 0 and do all the tuning through rootMargin instead
Where Lazy Loading Quietly Breaks Header Bidding Auctions
This is the failure mode almost nobody diagnoses correctly on a first pass, because the symptom looks like a demand problem when it's actually a timing problem. A typical Prebid.js auction with eight to ten bidders takes somewhere between 800ms and 1,500ms round trip, sometimes longer if you've got slower server-to-server bidders in the mix. If your lazy load script waits for the IntersectionObserver callback to fire and only then kicks off requestBids, and the user is scrolling at normal reading speed, the slot can physically enter the viewport before the auction has resolved.
When that happens, GPT either renders with whatever bids arrived in time — a thinner set than what actually competed — or it renders empty and falls back to a house creative or passback tag while late bids get discarded on arrival. I worked with a publisher where bid density on lazy-loaded slots sat at 71% while the same line items on eagerly-loaded slots hit 92%. Same demand partners, same floor prices, same slot sizes. The only variable was that the auction wasn't getting enough lead time to complete.
The fix is to decouple when the auction starts from when the ad actually renders. Trigger requestBids at a wider rootMargin — 800-1000px out — so bids have time to come back, but hold the actual googletag.display or refresh call for a tighter boundary, 200-300px, so you're not rendering (and burning an impression opportunity) before the user can plausibly see it. After making that change on that same publisher's site, bid density recovered to 89%, close to the eager-load baseline, while the page still got the network-request deferral benefit for anything genuinely far down the page.
The Core Web Vitals Numbers, Before And After
On a mid-size news site I worked on last year — roughly 40 ad slots across a typical article template, heavy header bidding stack — the before/after numbers looked like this after implementing tiered lazy loading with the auction-decoupling fix above: mobile LCP went from 3.8s to 2.4s, Total Blocking Time dropped from 420ms to 180ms, and CLS improved from 0.18 to 0.06. Desktop numbers moved less dramatically since desktop connections and CPUs absorb the third-party JS load better, but LCP still improved from 2.6s to 2.1s.
None of that CLS improvement came from lazy loading alone, and this is a point worth being blunt about: lazy loading defers when a request fires, it does nothing on its own to prevent the layout shift that happens when an ad renders into a slot that had no reserved space. You still need min-height or aspect-ratio placeholders on every ad container, sized to the most common creative dimension that slot serves, before lazy loading gets you anywhere near a passing CLS score. Skip that step and you'll defer the shift instead of eliminating it.
The field data (CrUX, 28-day rolling) took about three to four weeks to reflect the lab improvements, which is normal and worth setting expectations around internally — if you're checking Search Console's Core Web Vitals report the day after deployment expecting green, you'll be disappointed. Lab tools like PageSpeed Insights or WebPageTest show the change almost immediately; the field data lags because it's aggregating real user sessions over a rolling window.
- Mobile LCP: 3.8s to 2.4s
- Mobile TBT: 420ms to 180ms
- Mobile CLS: 0.18 to 0.06 (with reserved slot sizing, not lazy loading alone)
- Desktop LCP: 2.6s to 2.1s
- Field data (CrUX) lag: 3-4 weeks to fully reflect lab-measured gains
The Overcorrection That Costs More Than It Saves: Lazy-Loading Near-Fold Units
Here's where I'll disagree with a lot of the advice still circulating. Once publishers get comfortable with lazy loading and see the speed numbers improve, the instinct is to apply the same script to every single slot on the page, including the one sitting right at or just below the fold. That's an overcorrection, and it costs real money for very little additional speed benefit, because that slot was probably going to get requested and rendered within the first second or two of page load anyway.
Adding an IntersectionObserver layer to a slot that's already visible on load introduces artificial latency: the observer has to initialize, the JS has to execute, the callback has to fire, and only then does the request pipeline start — instead of starting immediately during initial page construction. On accounts where I've caught this and reverted the top one or two units to eager loading, RPM on those specific positions recovered 12-18%, mostly from viewability rate climbing back up. A slot that renders late relative to when the user's eyes are already on that part of the page captures less of the required in-view time to count as a viewable impression under IAB standards.
This connects directly to a mistake worth reading up on separately: chasing a speed metric at the direct expense of viewability is a bad trade almost every time, because viewability is what your demand partners are actually pricing against. If you haven't gone through why viewability drives the CPMs you're actually paid on, it's worth doing before you decide how aggressively to lazy load anything near the fold. The rule I use with clients now: the first slot, and often the second, load eagerly, full stop. Everything from position three downward is a candidate for tiered lazy loading.
A Step-by-Step Rollout If You're Doing This For The First Time
Don't start by dropping a lazy-load library into every ad tag on the page and hoping for the best — that's how you end up with the bid density problem described above, discovered three weeks later when someone asks why RPM dipped. Work through it in order, testing each stage before moving to the next, and keep a rollback plan for at least the first two weeks after launch.
Start with an honest audit of what you're currently running: how many slots per page template, which ones sit above the fold versus 2,000+ pixels down, and what your current LCP/CLS/TBT baseline looks like on both lab tools and field data. You can't measure improvement against a baseline you never captured, and I've had clients skip this step and then have no way to prove the change worked when a stakeholder asked.
Roll it out on one page template first, not the whole site at once. Article pages, category pages, and homepage typically have different ad densities and scroll behavior, so a rootMargin tuned for a long-form article template can be badly wrong for a homepage with a short scroll depth and a completely different slot layout. Ship to the template with the highest traffic first, watch it for a full reporting cycle, then extend the same tiered logic to the rest of the site once you've confirmed the auction-decoupling and buffer values are actually holding up under real traffic.
- Audit current ad density, slot positions, and baseline Core Web Vitals (lab and field) before touching anything
- Reserve space for every slot with min-height or aspect-ratio CSS matched to your most common creative size
- Classify slots into eager (position 1-2), moderate lazy (position 3-4, rootMargin 200-400px), and deep lazy (position 5+, tighter rootMargin)
- Decouple header bidding auction start (wider rootMargin, 800-1000px) from the actual render trigger (tighter rootMargin, 200-300px)
- Test on a throttled 4G / mid-range device profile, not just your office wifi and a current-gen phone
- Monitor bid density, viewability, and fill rate per slot for two weeks before declaring it done
The QA Step Most Publishers Skip Before Shipping
Testing lazy-loaded ads on office wifi with a current-generation laptop is close to useless, because that environment hides the exact timing problem you're trying to catch. Auctions that would take 1.2 seconds on a throttled 4G connection resolve in 180ms on fiber, so a broken rootMargin-to-auction relationship looks completely fine in a demo and falls apart the moment real mobile traffic hits it. Throttle the connection in Chrome DevTools to "Fast 3G" or "Slow 4G" and test on an actual mid-range Android device, not just a simulated viewport on desktop, before you sign off on any buffer configuration.
Two tools catch almost everything that manual scrolling misses. WebPageTest's filmstrip view, run at a throttled connection profile, shows you frame-by-frame whether a slot renders before or after the user's scroll position reaches it — that's how you spot pop-in objectively instead of eyeballing it. And Prebid.js's own debug mode (pbjs.getEvents() or the built-in debug logging) will show you, per slot, exactly when requestBids fired relative to when the auction resolved and when the ad actually rendered, which is the fastest way to confirm your auction-decoupling fix is doing what you think it's doing rather than assuming it from the aggregate RPM number.
Run this QA pass on at least three page templates and two device tiers before calling the rollout done. A configuration that looks clean on a flagship phone with strong signal will frequently expose timing gaps on a three-year-old Android device on a weak connection — and that's a meaningfully large slice of traffic for most publisher audiences, not an edge case worth ignoring.
What To Actually Watch In The First Two Weeks
The metric everyone checks first is Core Web Vitals, and that's fine, but it's not the metric that tells you whether you broke monetization. Pull viewability rate, bid density, and unfilled rate broken out by slot position, comparing the two weeks before and after launch. A drop in unfilled rate on your deepest slots is expected and good — those units were sitting further from a completed auction before. A drop in bid density or viewability on your top two or three slots is a red flag that you lazy-loaded something that should have stayed eager.
It's also a reasonable point to check whether your account is set up to take advantage of the header bidding and programmatic features that make this whole exercise worth doing in the first place — if you haven't run your setup through something like an eligibility check for your current ad stack, a speed optimization pass is a good moment to do it, since you'll want to know whether you're leaving demand on the table independent of load timing.
Give the field data its full 28-day rolling window before you make any final call on whether the Core Web Vitals side worked. And keep watching for regressions after the fact — a redesign, a new ad unit added by someone who didn't know the tiering convention, or a Prebid wrapper update can all quietly widen or shrink your buffers without anyone noticing until the numbers move.
Audit your slot positions before you touch a single tag, reserve layout space everywhere, keep the first one or two units eager, and decouple your auction start time from your render trigger. Measure bid density and viewability alongside Core Web Vitals for two full weeks — speed gains that cost you fill rate aren't a win.
Frequently Asked Questions
Written by Ismael Inacio
Founder, Ismael Ads
15+ years helping publishers across LATAM, North America and Europe grow ad revenue through Google AdSense, Ad Manager, AdX and header bidding. Every article here comes from work inside real publisher accounts, not secondhand research.