How to Take a Screenshot of a Page with Lazy-Loaded Images in Puppeteer
Scroll the page to trigger lazy-loaded images, wait for the images you need, then capture with Puppeteer’s fullPage option.
A Puppeteer full-page screenshot can include blank areas where lazy-loaded images should be. fullPage: true expands the capture area; it does not guarantee that offscreen images were requested. Scroll through the page to bring images near the viewport, wait for the images or page-specific signal you need, then capture.
1. Install Puppeteer and prepare a page
This example uses Node.js and Puppeteer. Install Puppeteer in a new project:
npm install puppeteer
Save the following as screenshot.js. It accepts a page URL and writes page.png. The readiness check treats failed images as errors so you can decide whether to accept or retry the capture.
const puppeteer = require('puppeteer');
async function main() {
const url = process.argv[2];
if (!url) throw new Error('Usage: node screenshot.js https://example.com');
const browser = await puppeteer.launch({ headless: true });
try {
const page = await browser.newPage({ viewport: { width: 1365, height: 900 } });
await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
await page.evaluate(async () => {
const step = Math.max(1, window.innerHeight);
const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
let previousHeight = 0;
let stablePasses = 0;
// Recheck height because lazy content can extend the document as it loads.
for (let pass = 0; pass < 100 && stablePasses < 2; pass++) {
const height = document.documentElement.scrollHeight;
for (let y = 0; y < height; y += step) {
window.scrollTo(0, y);
await pause(250);
}
await pause(250);
const nextHeight = document.documentElement.scrollHeight;
stablePasses = nextHeight === previousHeight ? stablePasses + 1 : 0;
previousHeight = nextHeight;
}
window.scrollTo(0, 0);
});
await page.waitForFunction(() =>
Array.from(document.images).every(img => img.complete),
{ timeout: 30000 }
);
const imageStatus = await page.evaluate(() =>
Array.from(document.images, img => ({
src: img.currentSrc || img.src,
complete: img.complete,
naturalWidth: img.naturalWidth,
naturalHeight: img.naturalHeight
}))
);
const broken = imageStatus.filter(img => !img.complete || img.naturalWidth === 0);
if (broken.length) {
throw new Error(`Image loading failed for ${broken.length} image(s): ${JSON.stringify(broken)}`);
}
await page.screenshot({ path: 'page.png', fullPage: true });
} finally {
await browser.close();
}
}
main().catch(error => {
console.error(error);
process.exitCode = 1;
});
Run it with node screenshot.js https://example.com. The scroll-and-pause pattern is a practical starting point, not a guarantee for every site. Adjust the pause and readiness logic to match the page’s loading behavior.
2. Why scrolling is needed
With native loading="lazy", the browser defers fetching an image until it reaches a calculated distance from the viewport. Images far down the document may not be fetched just because navigation completed. Puppeteer’s networkidle2 is useful for waiting for general network activity to settle, but it does not prove that every offscreen lazy image has loaded.
The load event likewise does not establish that deferred images are ready. Scroll in viewport-sized steps so those images approach or enter the viewport, pausing to give the browser time to start their requests. The loop checks document height again because content can load progressively and change the page length.
3. Choose a readiness check
Wait for image elements to finish
HTMLImageElement.complete lets you check whether each image element has finished loading. It can also be true for a failed image, so when pixels matter, inspect naturalWidth as in the runnable example. Decide whether a broken image should stop the job, be logged while you keep the screenshot, or trigger a retry.
The generic check only covers image elements present when it runs. If the page inserts new images after the check, wait for an application-specific signal or repeat the scan after the page settles.
Wait for a known selector or application marker
If the page exposes a reliable “gallery ready” marker or you only need particular images, use a targeted condition rather than requiring every image on the page to succeed:
await page.waitForFunction(() => {
const gallery = document.querySelector('[data-gallery-ready="true"]');
const images = Array.from(document.querySelectorAll('.gallery img'));
return Boolean(gallery) && images.length > 0 &&
images.every(img => img.complete && img.naturalWidth > 0);
}, { timeout: 30000 });
Replace the selector and marker with conditions the site actually provides. Puppeteer’s page.waitForFunction() waits for a page-context condition to become true.
Check images after each scroll for shifting pages
On pages where image loading moves content or reveals more items, a single final check may miss images added later. A more cautious strategy is to scan after each scroll step and repeat the pass until the document height and relevant image set stop changing. Set a pass limit and an overall timeout so an endlessly growing feed cannot hold the job forever.
4. Capture the full page or one element
When the desired output is the whole document, capture after the readiness check:
await page.screenshot({ path: 'page.png', fullPage: true });
If you only need one known component, an element handle screenshot is an alternative. Puppeteer scrolls the selected element into view before capturing it, but that does not guarantee all images inside it have loaded:
const card = await page.waitForSelector('.product-card', { timeout: 15000 });
await card.screenshot({ path: 'card.png' });
5. Handle page-specific lazy loading
- Native lazy images: scrolling usually triggers loading as images approach the viewport; wait for the resulting image requests to finish.
- JavaScript lazy loading: a site may use its own observer or data attributes. Inspect the page and wait for its real loaded state; scrolling alone may not activate its logic.
- Nested scroll containers: scrolling the window will not move a separately scrollable panel. Scroll that container and check its images.
- Virtualized lists: some pages remove offscreen items from the DOM. A single full-page capture may not contain every item because only a moving window of content exists. Capture items or sections as they appear, or use a page-provided export or print view.
- Interaction-gated content: tabs, accordions, consent actions, and “load more” controls may need to be activated before their images exist.
- CSS background images:
document.imagesdoes not include CSS backgrounds. Wait on the element’s rendered state or a page-specific signal if those images matter. - Layout stability: if you control the page, set image
widthandheight. This reserves space and helps reduce layout shifts while lazy content loads.
6. Useful navigation and capture options
| Option or check | Use | Limit |
|---|---|---|
page.goto(url, { waitUntil: 'networkidle2' }) |
Wait for general network activity to settle after navigation. | Not proof that offscreen lazy images have been requested. |
page.waitForFunction() |
Wait for an image, selector, or application readiness condition. | The condition must match the page and desired content. |
fullPage: true |
Capture the full page extent. | Does not trigger lazy loading on its own. |
ElementHandle.screenshot() |
Capture one selected element; Puppeteer scrolls it into view. | Does not ensure all descendant images are ready. |
timeout |
Bound navigation or readiness waits. | Choose a limit appropriate to the site; a timeout is not a load signal. |
7. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Blank image areas in the full-page screenshot | Capture extent was expanded, but offscreen images were never triggered. | Scroll through the document before capture, then check the relevant images. |
| The script hangs or times out waiting for images | An image is broken, slow, or continually added; the condition may include unrelated images. | Use a timeout, log the image URLs, and wait only for required selectors or a page marker. |
complete is true but the screenshot still has a missing image |
The image request may have failed; completion alone does not imply usable pixels. | Check naturalWidth > 0, inspect the URL and network behavior, and decide whether to retry or accept the missing asset. |
| Only part of a long feed appears | The page uses a nested scroller, a “load more” action, or virtualization. | Scroll the correct container, activate the needed control, and capture content incrementally if offscreen items are removed. |
| Images still appear late or in the wrong position | Layout is shifting after the capture condition succeeds. | Wait for a stable page-specific signal, repeat the scroll/check pass, or reserve image dimensions when you control the page. |
Some images are not represented by document.images |
They may be CSS backgrounds or custom-rendered content. | Wait for the relevant element or application state rather than relying on the image-element scan. |
8. Performance, reliability, and cost
Scrolling adds work proportional to page length: each step pauses and gives the browser a chance to fetch and decode content. Keep the step near the viewport height, use the shortest pause that works for the target site, and prefer a targeted readiness signal when a page-wide scan is unnecessary. A very short pause can miss slow requests; a long pause on every step increases capture time.
Bound navigation, readiness checks, and repeated passes with timeouts or limits. Always close the browser in a finally block, as in the example, so errors do not leave Chromium processes running. For scheduled or batch captures, record which images failed and retry only when the failure is plausibly transient; retries add latency and browser work. There is no universal wait duration or success rate for arbitrary websites.
Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server. Its API can capture a page with one GET request; see the ScreenshotNeo API documentation for the available parameters, including full-page capture and wait conditions.
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 accepts cookie and consent banners like a visitor, then removes 60+ known consent platforms, newsletter popups, and chat widgets before the shot; each step can be turned off. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and response headers report the page verdict and billing status. Its MCP server gives AI agents tools to take screenshots, get page information, and capture PDFs. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 screenshots.
Create a free ScreenshotNeo account to get 1,000 screenshots a month with no card.
FAQ
Does fullPage: true trigger lazy loading?
No. It expands the screenshot area. Scroll and wait for the images you need before capturing.
Is networkidle2 enough?
It is a useful navigation wait, but it does not establish that every offscreen lazy image was requested or loaded.
Can I use this for a page with infinitely loading content?
Set a maximum scroll range or pass count and define which portion or items belong in the capture. An unbounded feed has no natural final screenshot state.
Why check naturalWidth if complete is true?
Completion can describe a finished failed request as well as a successful load. A positive natural width is a practical check that an image has usable pixels.


