Lazy Loading Without Breaking SEO

Lazy loading is one of the rare performance techniques that sounds too good to be true and mostly isn't: don't download images until the user is about to see them, and a 6 MB page becomes a 1.5 MB page for anyone who doesn't scroll to the bottom. WordPress believed in it enough to turn it on by default for every image on every site back in version 5.5.

And yet "lazy loading broke our SEO" is a real genre of postmortem. Product images missing from Google Images. Blog posts indexed without their content. Category pages where Googlebot saw twelve items and the humans saw two hundred. Every one of those incidents traces back to the same root cause: someone lazy-loaded the wrong thing, or with the wrong mechanism.

So let's separate the two questions people tend to blur together: what should be lazy, and how should it be lazy. Get both right and lazy loading is pure upside.

How lazy loading actually works — the two mechanisms

There are two fundamentally different ways to lazy load, and they have very different SEO risk profiles.

Native lazy loading: the loading attribute

Every modern browser understands <img loading="lazy">. The browser itself defers the download until the image approaches the viewport. Crucially, the image URL stays right there in the HTML, in the src attribute, exactly where crawlers expect it. Googlebot doesn't need to scroll, execute JavaScript, or simulate anything — it reads the markup and finds the image. Native lazy loading is, for SEO purposes, essentially risk-free for images.

This is what WordPress does automatically: it adds loading="lazy" to images and iframes during output. If you're on WordPress and haven't installed anything exotic, you already have this.

JavaScript lazy loading: placeholder swapping

The older pattern — still shipped by many plugins, sliders, and themes — puts the real URL in a data-src attribute and a tiny placeholder (or nothing) in src, then uses JavaScript to swap them when the image nears the viewport:

<img src="placeholder.gif" data-src="real-photo.jpg">

Now the real image only exists if JavaScript runs and the swap logic fires. Google does render pages with JavaScript, but its renderer famously does not scroll — it loads the page into a very tall viewport instead. If the swap logic depends on actual scroll events rather than IntersectionObserver, images below the fold may never swap during rendering, and Google indexes a page full of gray placeholder pixels. That's the classic "our images vanished from Google Images" failure.

Decision rule: use native loading="lazy" unless you have a specific, articulated reason not to. The JavaScript pattern made sense before browser support was universal. It no longer is the default choice, and any plugin that still rewrites your src attributes into data-src is adding risk to save bytes that native lazy loading would have saved anyway.

The image you must never lazy load

Here's the counterintuitive part: the biggest lazy-loading mistake on most sites isn't lazy loading too little. It's lazy loading too much — specifically, the hero.

Your Largest Contentful Paint (LCP) element — usually the big image at the top of the page — needs to load as early as possible. Marking it loading="lazy" does the opposite: it tells the browser "this can wait," deprioritizing the one image your Core Web Vitals score depends on. Field data across the web consistently shows lazy-loaded LCP images loading hundreds of milliseconds to over a second later than eagerly loaded ones. You will feel this directly in your LCP metric.

The rule of thumb:

  • Above the fold (roughly the first screenful): eager. Hero image, logo, first product photo. Better yet, give the hero fetchpriority="high" so the browser bumps it up the queue.
  • Everything below: lazy. Gallery images, author avatars, footer badges, the seventeen screenshots in your tutorial.

Modern WordPress is smart about this — it skips the lazy attribute on the first image(s) it prints and can add fetchpriority="high" to the likely hero. But themes and page builders that generate their own markup often reintroduce the mistake, and some optimization plugins blanket-apply lazy loading to literally everything. It's worth checking: view source on your homepage, find your hero image, and confirm it does not say loading="lazy". Two minutes, real payoff.

Lazy loading content: here be dragons

Images are the safe end of the spectrum. The risky end is lazy loading content — text, product grids, comments, anything that constitutes the substance of the page.

Infinite scroll and "load more"

If your category page shows 20 products and loads more as the user scrolls, ask: how does a crawler get to product 21? Googlebot doesn't scroll and doesn't click "Load more." If the answer is "it can't," then products 21–200 are invisible from that page, and if that page is their only discovery path, they may not get crawled at all.

The fixes, in order of preference:

  1. Paired pagination. Keep infinite scroll for humans, but back it with real paginated URLs (/category/page/2/) that exist as plain links in the HTML — even if visually hidden in a "page 2" link at the bottom. Crawlers follow links; give them links.
  2. A complete sitemap. Make sure every product/post appears in your XML sitemap so discovery doesn't depend on the scrolling page at all. This is a safety net, not a substitute — internal links still carry ranking signals that sitemap entries don't.
  3. Server-render a generous first batch. Fifty items in the HTML instead of twelve costs little and covers most crawl paths.

Tabs, accordions, and deferred text

Content that's in the HTML but visually hidden (a closed accordion) is fine — Google indexes it. Content that's fetched only when the user clicks the tab is not fine — as far as crawlers are concerned it doesn't exist. The test is always the same: is it in the HTML response, or does it require a user gesture to come into being? Hidden-but-present: indexed. Fetched-on-interaction: invisible.

How to verify what Google actually sees

Stop guessing; the tooling is free and takes five minutes.

  1. URL Inspection in Google Search Console. Run "Test live URL" on a lazy-heavy page, then open the rendered HTML and the screenshot. Search the rendered HTML for a filename of an image far down the page. Present? You're fine. Missing, or showing a placeholder path? You've found your problem.
  2. Disable JavaScript and reload. In your browser's DevTools, turn JavaScript off and load the page. Whatever survives is what the most pessimistic crawler sees. With native lazy loading, all your images survive (the browser just loads them eagerly). With a broken JS pattern, you'll see the gray placeholders immediately.
  3. Check Google Images. Search site:yourdomain.com in Google Images. A site with hundreds of photos and a dozen image results is a symptom worth investigating.

A quick reference table

ThingLazy load?How
Hero / LCP imageNeverEager + fetchpriority="high"
Below-fold imagesYesNative loading="lazy"
Embedded videos / mapsYes, aggressivelyFacade pattern: static thumbnail, load iframe on click
Product grids / post listsCarefullyInfinite scroll only with real paginated URLs behind it
Primary text contentNoShip it in the HTML, always
CommentsAcceptableLazy is fine; comments are rarely the content you rank for

The iframe row deserves a word: a single embedded YouTube video pulls in something like half a megabyte of JavaScript before anyone presses play. The "facade" pattern — show a static thumbnail with a play button, inject the real iframe only on click — is the single highest-value lazy-loading move on pages with embeds, and several WordPress performance plugins do it automatically.

WordPress specifics worth knowing

  • Core already lazy loads images and iframes. Before adding a lazy-loading plugin, ask what it does that core doesn't. If the answer is "it uses data-src swapping," that's a downgrade wearing a feature's clothes.
  • Beware double lazy loading. An optimization plugin doing JS-based lazy loading on top of core's native attribute can produce images that wait for both conditions. If you use a plugin's lazy loading, it should disable core's, and vice versa.
  • Sliders and galleries are repeat offenders. Many carousel scripts lazy load their own slides via data attributes, badly. If your hero is a slider (a separate performance conversation, but let's stay focused), confirm slide one loads eagerly.
  • Test after every optimization plugin change. The combination of minification, deferral, and lazy loading is where these plugins interact unpredictably. One live-URL inspection after each config change is cheap insurance.

The bottom line

Lazy loading earns its default-on status: it's a genuine, large win for page weight and for users on slow connections. The failures are not inherent to the technique — they come from three specific, avoidable mistakes: lazy-loading the LCP hero, using scroll-dependent JavaScript swapping instead of native attributes, and letting interactive loading become the only path to your content. Fix those three and you keep the megabytes saved without sacrificing a single indexed image or product.

Check your hero image tonight. It's a view-source and a Ctrl-F. You'd be surprised how many sites are quietly deprioritizing the most important image they have.

Photo: Quang Nguyen Vinh on Pexels