Googlebot renders pages inside a defined viewport and does not perform a real, incremental scroll the way a human visitor does, which means Intersection Observer callbacks fire based on how the rendering engine lays out the page within that viewport configuration rather than in response to a sequence of natural scroll positions. For most Intersection Observer implementations this works out fine because the technique reacts to layout and intersection state rather than requiring an actual scroll gesture as its trigger. But content whose observer logic is built around assumptions that only make sense in a real, gradual scroll (tight thresholds tied to specific intermediate scroll positions, or observers rooted to a scrolling container that Google’s rendering pass doesn’t traverse the way a user’s browser does) can still fall outside what gets captured, even though the Intersection Observer API itself is the right and recommended pattern.
Simulated viewport versus real scroll
It helps to separate two things that get conflated: the size and configuration of the viewport Googlebot renders a page inside, and the act of scrolling through that viewport over time. Google’s documentation on lazy loading explains that Googlebot’s rendering uses a viewport that’s resized to be considerably taller than a standard screen specifically so that more of a page’s vertical content is captured within a single rendering pass, rather than requiring multiple sequential views the way a real scrolling session would produce. This is a viewport-sizing decision, not a scroll simulation. There is no sequence of intermediate scroll positions being stepped through, no scroll events dispatched, and no gradual, human-like progression down the page.
Intersection Observer is built around watching for changes in whether a target element intersects with a root element, by default the browser viewport, and it doesn’t require a live user scroll session to determine that intersection state. When the rendering engine lays out the page inside Googlebot’s expanded viewport, it can evaluate intersection state for elements as part of that single layout pass, and observers configured with reasonable thresholds will typically report the appropriate intersection and fire their loading callback accordingly. This is exactly why Intersection Observer, along with the native loading="lazy" attribute, is the pattern Google recommends over legacy scroll-event-driven lazy loading: it’s compatible with a single expanded-viewport render because it’s reacting to layout and geometry, not to a live sequence of scroll events that never happens.
Where content still falls outside the simulated behavior
The compatibility above assumes a straightforward Intersection Observer setup: watching elements against the default viewport root, with thresholds and margins that don’t depend on a specific intermediate scroll position existing. Several patterns break that assumption and can leave content unobserved or unloaded in Googlebot’s render, distinct from the simpler scroll-event-listener failure mode:
Observers rooted to a nested scrolling container rather than the main viewport. Intersection Observer supports a root option that can be set to any scrollable ancestor element instead of the default browser viewport, commonly used for things like horizontally scrolling carousels, chat-style interfaces, or internal scroll panes within a page. If the rendering engine’s layout of that inner scrollable container, at the moment of Googlebot’s single-pass render, doesn’t place the target elements within that container’s own visible bounds (because the container itself retains a fixed, normal-sized viewport even while the outer page viewport is expanded), the observer may never report those elements as intersecting, and their content stays unloaded. This is a meaningfully different failure than the plain scroll-event problem, because the API in use is the recommended one; the issue is the root configuration, not the trigger mechanism.
Extremely tight or narrowly tuned thresholds that assume incremental scroll progression. An Intersection Observer configured with a threshold requiring, for instance, a very specific fraction of visibility combined with a narrow rootMargin, tuned during development against a real, gradual scroll session where elements cross the viewport boundary incrementally, may behave differently when the entire page layout is instead evaluated in one expanded-viewport pass. In a real scroll session, an element might cross multiple threshold boundaries in sequence as the user scrolls past it, giving the developer’s code several chances to catch the right state. In a single expanded-viewport layout pass, the geometry is computed once, and if the configuration was tuned around assumptions that only apply to a moving scroll position, unusual edge cases in threshold-boundary math can produce inconsistent results compared to what a real user’s browser session would show.
Content that depends on scroll position for anything beyond the load trigger itself, such as lazy-loaded elements that also rely on a scroll-linked animation library to become visible (element loads, but stays at opacity: 0 or transform values tied to a scroll-progress calculation that never advances because no real scroll occurred). Here the Intersection Observer correctly triggers the content load, but a separate piece of scroll-position-dependent styling or animation logic leaves the loaded content visually or programmatically suppressed in the rendered snapshot, which is a related but distinct problem from the loading trigger itself.
Very deep or very long pages where the practical expansion of the viewport has limits. While Google doesn’t publish a specific numeric viewport height as a hard rule, it’s reasonable to expect that Google’s tall-viewport approach has practical bounds rather than expanding infinitely to accommodate pages of unusual length, and content extremely far down an unusually long page, particularly infinite-scroll style feeds that keep generating more content the longer a session continues, may not all be captured in a single render regardless of the lazy-loading technique used, since that pattern depends on an ongoing session rather than a one-time layout evaluation.
What to do about it
Native loading="lazy" remains the simplest and safest choice for standard images and iframes, since it removes custom JavaScript and threshold tuning from the equation entirely and is handled directly by the rendering engine’s own resource-loading logic. For anything more complex than that, where Intersection Observer is genuinely necessary (custom components, non-image content, conditional rendering of larger DOM sections), keep the configuration close to the defaults: observe against the default document viewport rather than a nested scrolling container whenever the content is meant to be indexed, and use generous, forgiving thresholds and root margins rather than narrow ones tuned only against a specific manual scroll test in a personal browser.
If a nested scrolling container is unavoidable for the actual user experience (a genuine internal scroll pane that needs to stay that way visually), consider whether the content inside it needs to be indexable at all; if it does, an alternative is to ensure the same content is also present, unconditionally, in a server-rendered or non-lazy form specifically for that section, decoupling indexability from the interactive scroll-pane behavior entirely.
Verification should go through URL Inspection’s rendered HTML and screenshot in Search Console, checking specifically whether elements inside nested scroll containers or governed by tightly tuned thresholds actually appear populated in Google’s own rendered capture, rather than assuming that using Intersection Observer at all is automatically sufficient. The API being the right choice doesn’t guarantee every configuration of it behaves identically to a real scrolling session, and confirming the actual rendered result is the only way to know for a specific implementation.