Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

For ordinary off-screen images, add loading="lazy" to the <img> element and reserve its dimensions. Use JavaScript with the Intersection Observer API when you need custom timing, CSS background images, video posters, or another resource that native image loading does not cover. Keep hero and other above-the-fold images eager so the browser can request them immediately.

Choose the right lazy-loading method

Approach Best for Timing control Maintenance
loading="lazy" Standard off-screen <img> elements Browser decides a pre-viewport distance Minimal markup; no script
Intersection Observer Custom image behavior, dynamically added images, background images and other resources Your observer options and application logic Requires fallback, error handling and responsive-source logic

Native loading is the default choice because it lets the browser schedule requests using its knowledge of the viewport, connection and device. The browser does not necessarily wait until an image touches the viewport; it can begin at a calculated distance before that point. Exact thresholds vary by browser and can change over time.

Native lazy loading for regular images

Basic markup

<img
  src="photo.jpg"
  loading="eager"
  width="800"
  height="600"
  alt="Description of the scene"
>

The loading attribute accepts lazy and eager. Use lazy for content that is initially below the fold. Use eager (or omit the attribute) for a hero image, logo or likely Largest Contentful Paint image that should be discovered immediately.

Reserve the layout before the request finishes

Include accurate width and height attributes, or reserve the same aspect ratio in CSS. An unloaded lazy image can otherwise have no useful dimensions, and the page may reflow when the bytes arrive.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
.card-image {
  width: 100%;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  display: block;
}

Keep meaningful alternative text in alt. If an image is purely decorative, use alt="" rather than lazy-loading it with missing accessibility information.

Responsive sources

Native lazy loading works with srcset and sizes; the browser still chooses an appropriate candidate.

<img
  src="photo-800.jpg"
  srcset="photo-400.jpg 400w, photo-800.jpg 800w, photo-1600.jpg 1600w"
  sizes="(max-width: 600px) 100vw, 50vw"
  loading="eager"
  width="1600"
  height="1200"
  alt="A mountain lake"
>

Do not lazy-load every image indiscriminately. Images visible in the first viewport should remain discoverable in the initial HTML. Lazy loading is intended to avoid requests for content the visitor may never reach, not to delay content they need immediately.

Lazy load with Intersection Observer

Intersection Observer asynchronously reports when an element intersects the viewport or a scrollable ancestor. A custom loader can store the real URL in a data attribute, assign it when the image approaches view, and then stop observing it.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Complete image example

<img
  class="deferred-image"
  src="placeholder.jpg"
  data-itgw-was-lazy-src="photo.jpg"
  data-itgw-was-lazy-srcset="photo-400.jpg 400w, photo-800.jpg 800w"
  sizes="(max-width: 600px) 100vw, 50vw"
  width="800"
  height="600"
  alt="A mountain lake"
>

<script>
(() => {
  const images = document.querySelectorAll('img[data-src]');

  const loadImage = (img) => {
    const src = img.dataset.src;
    const srcset = img.dataset.srcset;

    if (srcset) img.srcset = srcset;
    if (src) img.src = src;
    img.removeAttribute('data-src');
    img.removeAttribute('data-srcset');
  };

  if (!('IntersectionObserver' in window)) {
    images.forEach(loadImage);
    return;
  }

  const observer = new IntersectionObserver((entries, currentObserver) => {
    for (const entry of entries) {
      if (!entry.isIntersecting) continue;
      loadImage(entry.target);
      currentObserver.unobserve(entry.target);
    }
  }, {
    root: null,
    rootMargin: '300px 0px',
    threshold: 0
  });

  images.forEach((img) => observer.observe(img));
})();
</script>

The rootMargin starts the request before the image enters the viewport, giving the network time to fetch it. Increase it for large images or slow connections, but remember that a larger margin causes more images to load before they are seen. A nonzero threshold can require a particular visible fraction; 0 is usually sufficient for preloading on approach.

Use a real fallback

The example displays src="placeholder.jpg" before JavaScript runs and loads all deferred images when Intersection Observer is unavailable. Do not leave src empty: users with disabled JavaScript, unsupported browsers or script failures still need a usable image or placeholder.

Handle errors and loading state

function loadImage(img) {
  const source = img.dataset.src;
  if (!source) return;

  img.classList.add('is-loading');
  img.addEventListener('load', () => {
    img.classList.remove('is-loading');
    img.classList.add('is-loaded');
  }, { once: true });
  img.addEventListener('error', () => {
    img.classList.remove('is-loading');
    img.classList.add('is-error');
  }, { once: true });
  img.src = source;
  delete img.dataset.src;
}

For production code, decide how an error should appear: retain the placeholder, show an accessible error message, or retry through your image service. Remove an observer only after assigning the source so the same target cannot trigger duplicate requests.

Lazy loading content other than <img>

CSS background images

Native loading does not apply to a URL inside CSS. Observe the element and add a class that contains the background image.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
<div class="hero-tile" data-background="url('/images/tile.webp')"></div>

<script>
const tiles = document.querySelectorAll('[data-background]');
const tileObserver = new IntersectionObserver((entries, observer) => {
  entries.forEach((entry) => {
    if (!entry.isIntersecting) return;
    const tile = entry.target;
    tile.style.backgroundImage = tile.dataset.background;
    tile.removeAttribute('data-background');
    observer.unobserve(tile);
  });
}, { rootMargin: '300px 0px' });
tiles.forEach((tile) => tileObserver.observe(tile));
</script>

Reserve the tile’s height with CSS so adding the background does not move surrounding content. For decorative backgrounds, do not put essential text in the image; provide that information as HTML.

Video posters and dynamically inserted images

Observe a video element if its poster is expensive, or assign the poster when it approaches view. For images inserted after the initial query, either call observer.observe(newImage) when creating them or use a MutationObserver to discover new nodes. A one-time querySelectorAll does not automatically include future content.

Timing, events and browser behavior

A lazy image may still be pending when the window’s load event fires. If an application needs to know whether a particular image is ready, use that image’s load event or inspect its complete property, while also handling an error state.

function whenReady(img) {
  if (img.complete) {
    return img.naturalWidth > 0 ? Promise.resolve(img) : Promise.reject(new Error('Image failed'));
  }
  return new Promise((resolve, reject) => {
    img.addEventListener('load', () => resolve(img), { once: true });
    img.addEventListener('error', () => reject(new Error('Image failed')), { once: true });
  });
}

Lazy loading is intentionally deferred only when JavaScript is enabled in browsers supporting the feature. If your page must work without JavaScript, keep a real src fallback or use a <noscript> image for your chosen custom pattern.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Performance checklist

  • Mark only below-the-fold images as lazy; leave hero and other immediately visible images eager.
  • Set width and height, or an equivalent aspect-ratio, for every deferred image.
  • Use responsive srcset and sizes so a phone does not download a desktop-sized file.
  • Choose an observer margin that gives the network a head start without loading an entire page at once.
  • Compress and appropriately size source images; lazy loading prevents some requests but does not reduce the bytes of images that are eventually viewed.
  • Measure real pages rather than promising a fixed percentage improvement. The result depends on how many images are never reached, their sizes, the connection and the browser.

Common problems and fixes

Images never appear

  • Cause: only data-src was set and the observer never ran. Fix: check for JavaScript errors, confirm the script executes after the DOM exists, and verify that each target is observed.
  • Cause: a scrollable container is used as the viewport. Fix: pass that container as the observer’s root instead of relying on the document viewport.
  • Cause: a restrictive content security policy blocks the image host. Fix: allow the host in the policy and inspect the browser console.

Images load too late

Increase rootMargin, reduce source dimensions, or keep frequently visible images native and eager. A custom observer should not replace early discovery for a hero image.

The page jumps while scrolling

Add intrinsic dimensions or an aspect-ratio box before the request starts. Check that responsive candidates share the intended ratio and that CSS does not overwrite the reserved height.

Images load twice

Do not assign both src and srcset from competing handlers, and remove data-src after promotion. Unobserve the target immediately after scheduling its load.

“Everything loaded” checks fail

Do not equate the window load event with completion of lazy images. Track each image’s own load/error event or wait for a deliberate set of promises.

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Or skip the browser setup

If your goal is to capture a page image rather than implement lazy loading inside that page, ScreenshotNeo provides a website screenshot API and MCP server. It accepts the page URL, handles consent banners before capture, removes more than 60 known consent platforms plus newsletter popups and chat widgets, and bills only clean shots. Bot checks, CAPTCHAs, blank pages, timeouts, failed loads and cache hits are not billed, with the result identified by X-Page-Verdict and X-Billed headers.

One-call cURL example (see the ScreenshotNeo documentation for all options):

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

Python:

import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)

Node.js:

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

You can also use its MCP tools—take_screenshot, get_page_info and capture_pdf—from Claude, Cursor or another MCP client. Every plan includes the feature set, including custom CSS and JavaScript, selector waits, network-idle waits, device presets, full-page captures with lazy images loaded, PDFs, signed links, asynchronous webhooks and bulk capture. The free plan includes 1,000 screenshots each month with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Frequently asked questions

Does loading="lazy" work without JavaScript?

Browsers that implement native lazy loading defer supported images, but the feature is intentionally tied to JavaScript being enabled as an anti-tracking measure. Keep a normal src so the image remains usable when your custom code or feature support is absent.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Should I lazy-load an image in a modal?

Usually yes if the modal opens only after an interaction and the image is not needed for the initial view. Load it when the modal opens, reserve its dimensions, and avoid delaying content that appears immediately when the modal is shown.

Can Intersection Observer watch an element inside a scrolling panel?

Yes. Set the panel as the observer’s root, ensure the target has measurable dimensions, and choose a root margin appropriate to that panel’s scroll speed.

Frequently Asked Questions

Does loading="lazy" work without JavaScript?

Browsers that implement native lazy loading defer supported images, but the feature is intentionally tied to JavaScript being enabled as an anti-tracking measure. Keep a normal src so the image remains usable when your custom code or feature support is absent.

Should I lazy-load an image in a modal?

Usually yes if the modal opens only after an interaction and the image is not needed for the initial view. Load it when the modal opens, reserve its dimensions, and avoid delaying content that appears immediately when the modal is shown.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Can Intersection Observer watch an element inside a scrolling panel?

Yes. Set the panel as the observer’s root, ensure the target has measurable dimensions, and choose a root margin appropriate to that panel’s scroll speed.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.