How to Lazy Load Images with Infinite Scroll
Use native lazy loading for appended feed images, or IntersectionObserver when you need control over preload distance or a nested scroll container.
For most infinite-scroll feeds, create each image with loading="lazy" and explicit dimensions before appending it. The browser decides when to fetch it as it approaches the viewport. Use IntersectionObserver instead when you need a chosen preload distance, a nested scroll container, or to assign image URLs only when they are near view.
1. Choose native lazy loading or IntersectionObserver
Native lazy loading is the simplest option when an image URL is already available and you do not need precise control over when the request starts. It works for images added to the document after initial render, too. The browser chooses its own loading distance; there is no universal pixel threshold to rely on.
Use an observer when you need to set that lead distance with rootMargin, track images inside a particular scrolling element, or keep the real image URL out of src until the image approaches view.
| Need | Use |
|---|---|
| Simple deferral for appended images | loading="lazy" |
| Specific preload distance | IntersectionObserver with a positive rootMargin |
| Images inside a nested scroll panel | IntersectionObserver with that panel as root |
| Image is important in the initial viewport | Load it normally; do not lazy-load it |
Do not combine native lazy loading with delayed src assignment unless you have a specific reason: both mechanisms defer the request and can make loading behavior harder to reason about.
2. Native lazy loading for dynamically appended images
Give every image a known width and height, set loading, and append it. The browser can reserve layout space and decide when to request the image.
const feed = document.querySelector("#feed");
function appendImage({ url, alt, width, height }) {
const img = document.createElement("img");
img.src = url;
img.alt = alt;
img.width = width;
img.height = height;
img.loading = "lazy";
img.decoding = "async";
feed.append(img);
}
appendImage({
url: "/images/article-42.webp",
alt: "A mountain trail at sunrise",
width: 1200,
height: 800,
});
The loading value is a browser hint, not a request to fetch at an exact distance. Keep above-the-fold or otherwise critical images eager. If your feed uses responsive variants, use srcset and sizes as you would for ordinary images; lazy loading does not select or compress assets for you.
3. Use IntersectionObserver for a chosen lead distance
A positive vertical rootMargin expands the observer root, so an image can intersect before it is visible. Set root to the scrolling feed element when that element, rather than the document, scrolls. A single observer can watch many targets with the same configuration; unobserve each image after its one-time trigger.
const feed = document.querySelector("#feed");
const feedScroller = document.querySelector("#feed-scroller");
const imageObserver = new IntersectionObserver((entries, observer) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const img = entry.target;
img.src = img.dataset.src;
img.removeAttribute("data-src");
observer.unobserve(img);
}
}, {
root: feedScroller, // Use null for the document viewport.
rootMargin: "300px 0px", // Begin before the image is visible.
});
function appendDeferredImage({ url, alt, width, height }) {
const img = document.createElement("img");
img.dataset.src = url;
img.alt = alt;
img.width = width;
img.height = height;
feed.append(img);
imageObserver.observe(img);
}
appendDeferredImage({
url: "/images/article-43.webp",
alt: "A coastal path",
width: 1200,
height: 800,
});
In this example, the image has dimensions before its URL is assigned. That gives the browser a real box to observe and reserves space in the feed. Tune the margin to the experience you want; the example is illustrative, not a measured performance recommendation.
4. Append feed items as well as images
Infinite scroll usually has two separate jobs: fetch another page of feed data near the end of the current list, then lazy-load images within the newly appended items. A sentinel element observed near the list end is a common way to request another data page. Disconnect or stop observing it when there are no more results, and guard against duplicate requests while one is in flight.
const feed = document.querySelector("#feed");
const sentinel = document.querySelector("#feed-sentinel");
let nextPage = 1;
let loadingPage = false;
let hasMore = true;
const imageObserver = new IntersectionObserver((entries, observer) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const img = entry.target;
img.src = img.dataset.src;
img.removeAttribute("data-src");
observer.unobserve(img);
}
}, { rootMargin: "300px 0px" });
const feedObserver = new IntersectionObserver(async (entries) => {
if (!entries.some(entry => entry.isIntersecting)) return;
if (loadingPage || !hasMore) return;
loadingPage = true;
try {
const response = await fetch(`/api/feed?page=${nextPage}`);
if (!response.ok) throw new Error(`Feed request failed: ${response.status}`);
const items = await response.json();
for (const item of items) {
const card = document.createElement("article");
const img = document.createElement("img");
img.dataset.src = item.imageUrl;
img.alt = item.imageAlt ?? "";
img.width = item.imageWidth;
img.height = item.imageHeight;
card.append(img);
feed.append(card);
imageObserver.observe(img);
}
nextPage += 1;
hasMore = items.length > 0;
if (!hasMore) feedObserver.unobserve(sentinel);
} catch (error) {
console.error(error);
// Keep the sentinel observed so a later intersection can retry.
} finally {
loadingPage = false;
}
}, { rootMargin: "500px 0px" });
feedObserver.observe(sentinel);
Adapt the endpoint, item fields, and end-of-feed signal to your API. If the server returns a cursor or explicit hasMore value, use that rather than inferring completion from an empty page. For production feeds, also consider a visible “Load more” control so people can continue when automatic loading is unavailable or undesirable.
5. Reserve layout space and handle image state
Always provide intrinsic dimensions or reserve an aspect ratio. An unloaded image without dimensions can have no useful layout box; it may not intersect the viewport as expected. Reserving space also prevents content from jumping when the image arrives.
.feed-image {
display: block;
width: 100%;
height: auto;
aspect-ratio: 3 / 2;
object-fit: cover;
}
Use the actual aspect ratio for each image when it varies. If CSS controls the rendered size, the HTML width and height attributes can still communicate intrinsic proportions.
Do not use the window load event as proof that every lazy image has finished: lazy images may still be pending then. Check an individual image’s complete property when you need its load state, and also inspect naturalWidth to distinguish a successfully decoded resource from a broken image.
function imageState(img) {
if (!img.complete) return "pending";
return img.naturalWidth > 0 ? "loaded" : "failed";
}
img.addEventListener("load", () => console.log("loaded", img.currentSrc));
img.addEventListener("error", () => console.error("image failed", img.currentSrc));
6. Accessibility, fallback, and browser behavior
- Set meaningful
alttext for informative images; usealt=""for purely decorative images. - Provide dimensions even when using an observer, so targets have layout boxes before loading.
- Keep a retry or “Load more” path if feed requests fail, and communicate loading state when needed.
- Native lazy loading is conditioned on JavaScript being enabled as an anti-tracking measure. If your compatibility requirements include browsers without the needed support, verify target browser behavior and use a fallback strategy.
Where compatibility requires it, MDN lists an IntersectionObserver polyfill or scroll, resize, and orientation handlers as alternatives. Event-handler fallbacks should avoid expensive repeated intersection calculations. Check the browser support matrix that matters to your project; this guide does not assume a complete browser-by-browser compatibility list.
7. Performance, reliability, and cost
Lazy loading can avoid requests for images a visitor never reaches. It does not make each requested image smaller. Choose appropriately sized responsive assets, modern formats where suitable, and compression as separate delivery optimizations.
- Preload distance: An observer margin that is too small may leave visible gaps while an image downloads; one that is too large can start many requests before they are useful. Tune against your content and network conditions rather than assuming one value fits every feed.
- Observer reuse: Share one observer for targets with the same root and margin. Unobserve targets after triggering to avoid needless ongoing observation.
- Feed reliability: Prevent duplicate page fetches, handle non-OK responses and retries, and stop observing the sentinel when pagination ends.
- Layout stability: Reserve dimensions for every image so loading does not shift content and the observer can detect the target.
- Request cost: Deferral reduces downloads that never happen, but images actually viewed still transfer data. Correct sizing and compression address bytes per requested image.
There is no current topic-specific benchmark here, so no fixed speed or bandwidth improvement should be assumed. Measure the feed under the devices, network conditions, and image sizes your users encounter.
8. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Image never appears with native lazy loading | No dimensions or reserved box; invalid URL; or image remains far from the browser’s loading range | Set width and height or an aspect ratio, inspect currentSrc and network errors, and scroll nearer to the image. |
| Observer callback never fires | Wrong root, target is not inside that root, or the target has no layout area |
Use null for the document viewport or the actual scroll container; set dimensions and confirm the target is appended. |
| Images load only after they enter view | rootMargin is zero or too small |
Use a positive margin such as "300px 0px", then adjust based on observed behavior. |
| Large blank gaps or layout jumps | Image dimensions are missing or incorrect | Provide the real intrinsic ratio or reserve the correct aspect ratio in CSS. |
| Some images are requested twice | Native lazy loading and custom source deferral are combined, or the observer is not unobserved | Choose one deferral mechanism for each image and unobserve after assigning its source. |
| Images look soft or transfer too much data | Lazy loading deferred the request but did not resize or compress the file | Serve responsive variants with srcset/sizes, and optimize dimensions, format, and compression. |
| Code says all images are done at window load, but some are missing | Lazy images can remain pending after the window load event |
Track each image’s load/error event or inspect complete and naturalWidth. |
| Feed keeps requesting the same page | Sentinel intersects repeatedly while a request is in progress, or pagination state is not advanced | Guard with an in-flight flag, advance the cursor only after success, and unobserve when the server indicates the end. |
9. ScreenshotNeo: inspect the rendered feed without browser setup
For debugging a feed’s rendered state, ScreenshotNeo is a website screenshot API and MCP server. It can capture a URL as an image or PDF; its capture options include waiting for a selector, a delay, or network idle, and capturing a selected element. Lazy content may require scrolling or other page-specific setup before it exists in the rendered state, so choose a wait condition that matches the page.
Or skip the browser setup:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com/feed -o feed.webp
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://example.com/feed"},
timeout=90,
)
open("feed.webp", "wb").write(r.content)
const q = new URLSearchParams({
access_key: 'YOUR_API_KEY',
url: 'https://example.com/feed'
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await Bun.write('feed.webp', res);
See the ScreenshotNeo API documentation for request options. Cookie banners, popups, and chat widgets are removed before the shot. Bot checks, blank pages, and failed loads are never billed. An 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.
10. FAQ
Does lazy loading automatically fetch the next page of an infinite feed?
No. It defers image requests for elements already in the document. Your application still needs pagination logic to fetch and append more feed items.
Can a lazy image be missing from a screenshot?
Yes. A capture may happen before the image is near the viewport or before the feed has appended it. Scroll or otherwise trigger the page’s loading behavior before capture, then wait for the relevant image or content state.
Should every image use lazy loading?
No. Keep images needed immediately in the initial viewport eager; defer non-critical off-screen images.
Does an IntersectionObserver margin change the image’s displayed size?
No. It changes when the observer considers the target intersecting. Image dimensions and CSS determine layout size.


