How to Lazy Load Images with JavaScript to Improve Webpage Performance
Use native lazy loading for ordinary offscreen images, and JavaScript when you need custom control. Keep visible and LCP images eager to avoid delaying them.
For ordinary images below the fold, start with the browser’s native loading="lazy" attribute. Use JavaScript with IntersectionObserver when you need to choose how far ahead images load, defer content that is not a regular image element, or support browsers that need a fallback. Keep images likely to appear in the initial viewport—including the Largest Contentful Paint (LCP) image—eager: lazy loading those can delay their download and hurt LCP.
Lazy loading can reduce early network and rendering work by postponing noncritical images. It does not automatically make every page faster: the outcome depends on image placement, viewport size, browser heuristics, and network conditions. Reserve each image’s layout space, then check the network waterfall and rendered page on representative screen sizes.
1. Choose the right approach
| Approach | Use it when | Control and tradeoff |
|---|---|---|
Native loading="lazy" |
Ordinary below-the-fold <img> and <picture> images |
Smallest implementation; the browser chooses when to fetch. |
IntersectionObserver |
You need a custom preload buffer, nonstandard targets, or a fallback for a supported browser population | You control the root and rootMargin; you must handle setup, fallbacks, and image URLs. |
| Scroll and resize handlers | A required browser lacks IntersectionObserver and a polyfill is not appropriate |
Fully custom, but adds event and geometry work that needs careful throttling and cleanup. |
Native loading is broadly supported and needs no library for most use cases. Browsers that do not recognize the attribute ignore it and load the image normally. The native hint’s distance threshold is browser-controlled, not configurable through HTML. A JavaScript observer is justified when that limitation matters to the design or product.
2. Use native lazy loading for regular images
<img
src="/images/gallery-01.webp"
loading="lazy"
width="800"
height="600"
alt="A red bicycle leaning against a brick wall"
>
Use the real URL in src and provide intrinsic dimensions. The width and height let the browser reserve the image’s aspect ratio before the file arrives, reducing layout shifts. If the source has a different ratio, use dimensions that match the actual asset or reserve space with CSS aspect-ratio and a dimensionally consistent placeholder.
Responsive images and picture sources
For <picture>, put loading on its fallback <img>. The browser can choose among the sources, while the image element carries the loading hint:
<picture>
<source
type="image/avif"
srcset="/images/gallery-01.avif 800w, /images/gallery-01-large.avif 1600w"
sizes="(max-width: 700px) 100vw, 800px"
>
<source
type="image/webp"
srcset="/images/gallery-01.webp 800w, /images/gallery-01-large.webp 1600w"
sizes="(max-width: 700px) 100vw, 800px"
>
<img
src="/images/gallery-01.jpg"
srcset="/images/gallery-01.jpg 800w, /images/gallery-01-large.jpg 1600w"
sizes="(max-width: 700px) 100vw, 800px"
loading="lazy"
width="800"
height="600"
alt="A red bicycle leaning against a brick wall"
>
</picture>
Leave initially visible images eager
Do not apply loading="lazy" to the hero or another image likely to be visible at initial load. Normal image loading is eager by default, so you usually do not need to write loading="eager". If an image is a likely LCP candidate, keep its URL discoverable in the initial HTML and consider fetchpriority="high" when appropriate. Use that priority hint sparingly and verify the browser’s actual resource priority.
3. Implement custom loading with IntersectionObserver
Use this pattern when you need to decide how far before visibility an image starts loading. Store deferred URLs in data-src, observe only noncritical images, assign src when an image approaches the viewport, and unobserve it afterwards. The following runnable example also handles browsers without IntersectionObserver by loading the images immediately.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Deferred image example</title>
<style>
.photo { display: block; width: min(100%, 800px); height: auto; }
.photo-frame { aspect-ratio: 4 / 3; background: #eee; }
</style>
</head>
<body>
<!-- Keep a likely above-the-fold or LCP image eager. -->
<img class="photo" src="/images/hero.webp" width="1600" height="900"
alt="A mountain lake at sunrise">
<!-- Use data-src only for deferrable images and keep dimensions stable. -->
<div class="photo-frame">
<img class="photo js-lazy photo" data-src="/images/gallery-01.webp"
width="800" height="600" alt="A red bicycle by a wall">
</div>
<div class="photo-frame">
<img class="photo js-lazy photo" data-src="/images/gallery-02.webp"
width="800" height="600" alt="A street market at dusk">
</div>
<script>
const deferredImages = document.querySelectorAll('img.js-lazy[data-src]');
function loadImage(img) {
const url = img.dataset.src;
if (!url) return;
img.src = url;
img.removeAttribute('data-src');
}
if ('IntersectionObserver' in window) {
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: '0px 0px 256px 0px',
threshold: 0
});
deferredImages.forEach((img) => observer.observe(img));
} else {
// Safe fallback: show all content if the observer API is unavailable.
deferredImages.forEach(loadImage);
}
</script>
</body>
</html>
root: null uses the browser viewport. A positive bottom rootMargin starts loading before an image becomes visible; 256px is an illustrative starting point, not a universal optimum. A larger margin may reduce the chance of a user seeing an unloaded image but fetches more assets earlier. Tune it against actual scrolling, connection conditions, image sizes, and the number of images.
threshold: 0 means an entry can trigger as soon as it intersects the expanded root. Unobserving after assigning the URL avoids repeat work. For a scrolling panel rather than the viewport, pass that panel as root and ensure the observed images are descendants of it. If you need failed-image recovery, add an error handler and a visible fallback appropriate to your product; do not silently leave a broken placeholder.
JavaScript disabled and crawler visibility
Native loading="lazy" does not require JavaScript. In a JavaScript observer implementation, an image that only has data-src will not load if JavaScript does not run. If that content matters, keep it available through a non-JavaScript fallback or use native loading instead. Google recommends loading relevant lazy content when it becomes visible without requiring user interaction. Inspect rendered HTML and confirm the final image URL appears in an image’s src attribute; do not rely on a click or scroll event as the only way to reveal content to crawlers.
4. Keep the layout stable
- Set
widthandheighton images, or reserve an equivalent aspect ratio in CSS. - Use a placeholder with the same ratio as the final image when the asset is deferred.
- Check galleries in which many images begin with zero height. Without reserved space, a browser may initially treat them all as fitting in the viewport and fetch more than intended.
- Keep meaningful
alttext and an actual image URL insrcfor native loading.
5. Verify the performance change
- Test mobile and desktop viewports separately. The same image may be offscreen on one and visible on another.
- Check that the hero and likely LCP image start fetching promptly and are not marked lazy.
- Inspect the browser network waterfall, request start times, and resource priority. Confirm below-the-fold images are deferred and begin loading before the user reaches them.
- Compare page behavior under representative network conditions. A wide preload margin can erase some savings; a narrow margin can expose blank space during fast scrolling.
- Review both lab measurements and field performance when available. Look at LCP and layout stability as well as the number of initial image requests.
- Use Google Search Console’s URL Inspection Tool to inspect rendered HTML for important lazy images and verify their URLs appear in
src.
Browser heuristics vary with viewport, connection, and image placement, so a single lab run or request count does not establish a page-wide speed improvement. Measure the actual page and keep the simplest strategy that meets the loading behavior you need.
6. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| The hero or LCP image appears late | A critical image was marked lazy, or its URL is injected only after JavaScript runs. | Remove lazy loading from initially visible images. Keep their URL in initial HTML; consider a sparingly used fetchpriority="high" hint and verify actual priority. |
| Images jump into place after loading | No intrinsic dimensions or reserved aspect ratio. | Add accurate width and height, or reserve matching space with CSS and a correctly sized placeholder. |
| All gallery images download immediately | Images initially occupy zero layout space, so the browser may consider them all in view; alternatively a custom script assigned all URLs at once. | Reserve each image’s dimensions. In the observer version, observe only deferrable targets and assign each URL on intersection. |
| An image never appears | The observer script did not run, the selector missed the element, data-src is absent or invalid, the request failed, or the target is in an unexpected scroll root. |
Check the console and network panel, confirm the selector and URL, use the correct root, and provide a fallback for unsupported APIs or failed requests. |
| Blank space shows while scrolling | The preload margin is too small for the user’s scroll speed, image size, or connection. | Increase the positive bottom rootMargin incrementally and measure the additional early requests. |
| Too many images load before they are visible | The preload margin is too large or the page has many nearby images. | Reduce the margin, observe only images that can be deferred, and compare request starts during real scrolling. |
| Search rendering misses lazy images | The final URL is hidden in a custom attribute or content waits for user interaction. | Ensure visible content loads without interaction and inspect rendered HTML to confirm the image URL is in src. |
7. Performance, reliability, and cost notes
Native loading has the lowest implementation and maintenance cost for ordinary images: it needs no observer setup, event listeners, or dependency, and degrades to normal loading where the hint is unsupported. Custom JavaScript brings control at the cost of more paths to maintain, including script failure, incorrect selectors, missing URLs, observer roots, and JavaScript-disabled behavior. A compatibility polyfill or scroll-handler fallback should be added only if the browsers your project supports require it.
Lazy loading reduces unnecessary early image downloads; it does not shrink the image files or guarantee lower total data use if visitors eventually scroll to every image. Image dimensions, responsive source selection, compression, and caching are separate levers. Avoid triggering many large images at once if decode work or network contention affects the page. HTMLImageElement.decode() can be useful in specialized rendering flows, but is not required for ordinary images and adds complexity.
8. Capture a page to inspect its images
A screenshot can help compare whether above-the-fold content is present at a chosen viewport, but a single image cannot show request timing, resource priority, crawler rendering, or field performance. Use browser network and performance tools for those checks. For repeatable visual checks across URLs and viewport sizes, ScreenshotNeo is a website screenshot API and MCP server for developers. Its viewport, full-page, and device options can capture page states for visual review.
Or skip the browser setup
Send one GET request to capture a page as an image or PDF. See the ScreenshotNeo API documentation for parameters and options.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
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)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
ScreenshotNeo removes cookie banners, newsletter popups, and chat widgets before the shot. Bot checks, blank pages, and failed loads are never billed. Its MCP server lets AI agents take screenshots. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000.
Sign up for 1,000 free screenshots a month, with no card.
FAQ
Should I use JavaScript or loading="lazy"?
Use native loading for ordinary offscreen images. Use JavaScript when you need a chosen preload distance, nonstandard targets, or a required fallback.
Does lazy loading improve page speed?
It can reduce early work by deferring offscreen images. It can worsen LCP if applied to a critical image, so measure the page rather than assuming every lazy image helps.
Can I control the native lazy loading threshold?
No. The browser chooses it. Use an observer and tune rootMargin if you need author-controlled timing.
Do lazy images work if JavaScript is disabled?
Native loading="lazy" works without JavaScript. A custom data-src implementation needs a fallback if the image must remain available without script execution.


