The pattern that satisfies both constraints is straightforward once the two things being lazy-loaded are separated: use native loading="lazy" on images and iframes to defer the loading of heavy assets, while keeping SEO-critical text content (headings, body copy, structured data, links) present in the initial HTML/DOM rather than deferred behind scroll position or user interaction. The rule of thumb is defer the loading of an asset, never defer the existence of critical content in the DOM. Native lazy-loading handles the performance side (not making the browser or renderer fetch every image on the page immediately), while keeping text in the initial DOM handles the indexing side (making sure Googlebot’s render pass actually has the content available regardless of scroll behavior or timing).
Why this specific split works: what native lazy-load actually defers versus what scroll-based patterns defer
The loading="lazy" attribute is a standard HTML attribute, not a custom JavaScript implementation, supported natively by browsers and explicitly documented by Google as the recommended approach for lazy-loading images and iframes. When you set loading="lazy" on an <img> or <iframe>, the element itself, along with its attributes (src, alt, width, height) is present in the DOM immediately, it’s the actual fetching of the resource (the image bytes, the iframe’s document) that gets deferred until the element is near the viewport. This is a meaningful distinction: the browser (and Googlebot’s renderer, which is Chromium-based and understands this same native attribute) already knows the element exists, what it is, and its metadata, it just hasn’t downloaded the heavy payload yet. Google’s own documentation on lazy loading explicitly recommends the native attribute for this reason and separately warns against relying on custom scroll-triggered or IntersectionObserver-only patterns for content that needs to be crawlable, because those patterns can prevent the content itself, not just an asset, from ever entering the DOM during an automated render pass.
That warning points to the actual mechanism that causes problems. Custom lazy-loading implementations built years ago (or still built today, out of habit or copied boilerplate) often work by leaving a placeholder in the DOM and only inserting the real content, including text, once a scroll event or IntersectionObserver callback fires. This is a fundamentally different situation from native loading="lazy", because in the custom pattern, the content doesn’t exist in the DOM at all until the trigger fires. Googlebot’s rendering pass executes JavaScript and captures a DOM snapshot, but it does not replicate arbitrary human scrolling behavior through an entire page the way a person testing manually would. If your SEO-critical text is one of those elements sitting behind a scroll-triggered insertion, there’s real risk it simply isn’t present when the snapshot is captured, even though it looks completely normal to anyone testing by scrolling through the page themselves.
So the mechanism split is:
- Native
loading="lazy"on images/iframes: element and its metadata exist in the DOM immediately; only the resource fetch is deferred. Safe for both users (real performance benefit, since deferring off-screen image downloads reduces initial page weight and improves loading-related Core Web Vitals like Largest Contentful Paint when the LCP element itself isn’t lazy-loaded) and Googlebot (renderer sees the element and its attributes without needing to trigger anything). - Scroll/IntersectionObserver-gated content insertion: the content itself doesn’t exist in the DOM until a trigger fires. Risky for indexing because an automated, non-interactive render pass may capture its snapshot before that trigger condition is ever met, since it isn’t scrolling the page the way a human tester does.
This is also why testing lazy-loaded content by scrolling through it yourself in a browser is not sufficient verification, that test always passes, since you’re providing the exact trigger the implementation is waiting for. The relevant test is what’s in the DOM before any scroll interaction happens at all.
Practical implementation pattern
For images: add loading="lazy" directly as an HTML attribute on <img> tags for anything below the fold or off-screen. Always include explicit width and height attributes (or aspect-ratio CSS) alongside it, since this lets the browser reserve the correct space before the image loads, preventing layout shift, which is what protects your Cumulative Layout Shift score. Do not apply loading="lazy" to the actual LCP image (typically a large above-the-fold hero image), since deferring that specific asset can delay your Largest Contentful Paint measurement; reserve lazy-loading for images genuinely outside the initial viewport.
For iframes: the same loading="lazy" attribute works directly on <iframe> elements (embedded videos, maps, widgets), deferring their load until they approach the viewport, which is particularly valuable since iframes often pull in substantial third-party weight.
For SEO-critical text content: render it into the initial HTML unconditionally. This means server-side rendering, static generation, or otherwise ensuring the text is part of the document the server sends back, not something inserted only after a scroll event, an intersection observer callback, a click, or a delayed client-side fetch. If a page has expandable sections (accordions, “read more” content, tabs), the safest pattern is to have the full text present in the DOM from the start and use CSS to visually hide or truncate it, rather than never inserting it into the DOM until interaction. Content visually hidden with CSS (not display:none misused to cloak unrelated content, but a legitimate collapsed-by-default UI pattern) is still present for indexing purposes, whereas content that doesn’t exist in the DOM at all until a JavaScript event fires is not.
Verify with URL Inspection, not just visual testing. Use Search Console’s “View Crawled Page” against a sample of pages using this pattern to confirm the critical text is actually present in Google’s rendered HTML. This is the only reliable check, since manual scroll-testing in a browser will always show the content correctly regardless of whether the underlying implementation is safe.
Don’t conflate “lazy” with “conditional.” The safe pattern lazy-loads the fetching of a known, already-declared resource. The unsafe pattern conditionally creates content that doesn’t exist until something happens. Keeping that boundary intact, defer the download, never defer the DOM presence of critical text, is what lets you get real Core Web Vitals benefits from lazy loading without gambling on whether Google’s renderer happens to trigger the same conditions a human tester would.
Hypothetically, imagine a review site, call it “Example Reviews,” where a long product review page places its second half, methodology notes and comparison tables, behind a custom IntersectionObserver script that only inserts that HTML once the user scrolls near it. A staff member manually scrolling through the page sees everything looking fine and signs off on it. Let’s say Search Console’s URL Inspection tool, checked afterward, shows the rendered HTML cuts off exactly where that script would have fired, meaning the comparison tables were never actually in the snapshot Googlebot captured. In this hypothetical, switching that section to render unconditionally in the initial HTML, and reserving loading="lazy" only for the product images further down the page, would resolve the gap without sacrificing the page-weight savings the image lazy-loading was providing.