How to Wait for Lazy-Loaded Images Before a Puppeteer Screenshot
Trigger deferred images, verify they loaded and decode, then capture with Puppeteer. Includes bounded waits, full-page handling, troubleshooting, and an API alternative.
To capture lazy-loaded images with Puppeteer, first scroll through the parts of the page you intend to capture so the browser triggers deferred requests. Then wait for the relevant images to load successfully and decode before calling page.screenshot(). Navigation events such as load and networkidle2 alone do not prove that images below the viewport are ready.
The key checks are img.complete && img.naturalWidth > 0 for successful loading, followed by img.decode() when you need decoded image data ready for rendering. The example below adds a timeout and reports failed or undecodable images instead of waiting forever.
1. Install Puppeteer and create a page
In a new Node.js project, install Puppeteer. This example uses its bundled browser. If your environment supplies Chrome separately, configure Puppeteer with that browser’s executable path and ensure the browser version is compatible with your Puppeteer release.
npm install puppeteer
Save the following as screenshot.js. Set TARGET_URL to a page you are authorized to access. The script scrolls the document to trigger native viewport-based lazy loading, returns to the top, waits for successful image loads and decodes, then saves a full-page PNG.
const puppeteer = require('puppeteer');
const TARGET_URL = 'https://example.com';
const OUTPUT_PATH = 'page.png';
const WAIT_TIMEOUT_MS = 30000;
async function main() {
const browser = await puppeteer.launch({ headless: true });
try {
const page = await browser.newPage();
page.setDefaultTimeout(WAIT_TIMEOUT_MS);
await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded' });
// Visit successive document regions to trigger viewport-based lazy loading.
await page.evaluate(async () => {
const step = Math.max(1, window.innerHeight);
const height = document.documentElement.scrollHeight;
for (let y = 0; y < height; y += step) {
window.scrollTo(0, y);
await new Promise(resolve =>
requestAnimationFrame(() => requestAnimationFrame(resolve))
);
}
window.scrollTo(0, 0);
await new Promise(resolve => requestAnimationFrame(resolve));
});
// Wait until every currently present image has either loaded successfully
// or failed. A failed image should not make this predicate wait forever.
await page.waitForFunction(() =>
[...document.images].every(img => img.complete),
{ timeout: WAIT_TIMEOUT_MS }
);
// Decode successful images and report failures explicitly.
const imageResults = await page.evaluate(async () => {
return Promise.all([...document.images].map(async (img, index) => {
const src = img.currentSrc || img.src || '(no source)';
if (!img.complete || img.naturalWidth === 0) {
return { index, src, ok: false, reason: 'load failed or no image data' };
}
try {
await img.decode();
return { index, src, ok: true };
} catch (error) {
return {
index, src, ok: false,
reason: error instanceof Error ? error.message : String(error)
};
}
}));
});
const failures = imageResults.filter(result => !result.ok);
if (failures.length) {
console.warn('Some page images were not ready:', failures);
}
await page.screenshot({ path: OUTPUT_PATH, fullPage: true });
console.log(`Saved ${OUTPUT_PATH}; ${imageResults.length - failures.length}/${imageResults.length} images decoded.`);
} finally {
await browser.close();
}
}
main().catch(error => {
console.error(error);
process.exitCode = 1;
});
Run it with node screenshot.js. The script treats image failures as reportable conditions and still captures the page. If your requirement is that every image must be present, change that policy to stop before the screenshot when failures.length is nonzero.
2. Understand what each wait does
Navigation completion is not image readiness
page.goto() can wait for load, domcontentloaded, or a network condition. Those waits describe navigation and network activity; they do not cause every below-the-fold lazy image to be requested. Native lazy loading defers fetching based on browser-calculated distance from the viewport, and lazy images may not be part of the eager-image work considered by the window load event. Puppeteer documents networkidle2 as no more than two network connections for at least 500 ms; a quiet network does not establish that an intended image was requested, succeeded, decoded, or painted.
Trigger the loading behavior first
Scrolling through document regions is a simple way to bring images near the viewport and activate native lazy loading or page logic driven by visibility. The example waits two animation frames at each step so the browser and page scripts can react. It then returns to the top because the screenshot should start at the page’s normal initial position.
Check success, then decode
HTMLImageElement.complete becomes true after an image finishes loading, but it can also be true for a broken image. Pair it with naturalWidth > 0 to identify successful image data. Then decode() resolves when the image data is ready to use; it can reject on a failed request, corrupt data, or a source change during decoding. The example records those cases so a missing image is visible in logs.
3. Choose the right scope
| Capture type | What to trigger and wait for | Practical choice |
|---|---|---|
| Viewport screenshot | Images in the visible capture area, including any just outside it that the page loads for rendering | Scope the image query to the target element or viewport region; do not wait for unrelated images far down a long document. |
| Full-page screenshot | Deferred images throughout the page, including images revealed while scrolling | Scroll through the document, then repeat the image query and readiness checks before fullPage: true. |
| Element screenshot | Images inside the target element and any ancestors or page regions needed to trigger their loading | Scroll the element or its real scroll container into view, then check images scoped to that element. |
Puppeteer’s fullPage option captures beyond the current viewport, but it does not itself simulate visiting each viewport region first. If page code uses a nested scroll container, scrolling window may not trigger its observers; move the actual container instead. Intersection observers can watch an element relative to the viewport or another root, so the correct scroll target depends on how the site is built.
Scope readiness to a target element
For an element capture, replace the document-wide checks with a selector-scoped check. This waits for images contained by the selected element and avoids holding the capture for unrelated page images.
const selector = 'main article';
await page.waitForSelector(selector);
await page.waitForFunction((sel) => {
const root = document.querySelector(sel);
if (!root) return false;
return [...root.querySelectorAll('img')].every(img => img.complete);
}, { timeout: 30000 }, selector);
const result = await page.evaluate(async (sel) => {
const root = document.querySelector(sel);
const images = [...root.querySelectorAll('img')];
const outcomes = await Promise.all(images.map(async img => {
if (img.naturalWidth === 0) return { ok: false, src: img.currentSrc || img.src };
try {
await img.decode();
return { ok: true, src: img.currentSrc || img.src };
} catch (error) {
return { ok: false, src: img.currentSrc || img.src, reason: String(error) };
}
}));
return outcomes;
}, selector);
const element = await page.$(selector);
await element.screenshot({ path: 'element.png' });
If images are lazy-loaded before the element becomes visible, bring it into view first with element.evaluate(el => el.scrollIntoView()), or scroll its containing scroller. If the image list changes while scrolling, take a fresh snapshot after the trigger pass; do not rely on a list captured before dynamic content was inserted.
4. Handle dynamic pages and edge cases
- Images are inserted during scrolling: query
document.imagesafter the scroll pass, as in the example. For pages that keep appending content, repeat the scroll and readiness cycle until the document height and image set stop changing, with an overall deadline. - Image sources change: a responsive image may choose a different
currentSrc, or site code may replacesrc. A decode can reject if the source changes mid-decode. Recheck the image set and decode again after the page settles. - Broken optional images:
completecan be true withnaturalWidth === 0. Decide whether to log and continue, replace the image, or fail the capture. Do not silently interpret completion as success. - Background images:
document.imagescovers image elements, not CSS background images. If backgrounds matter, inspect the relevant elements’ computed styles and wait for those resource URLs through an explicit resource-aware strategy; an image-element predicate cannot certify them. - Nested scrollers: scrolling the window does not necessarily intersect images inside a separately scrolling panel. Identify the container used as the observer root and move that element.
- Infinite scroll: a page can continue growing indefinitely. Set a maximum scroll depth or item count, plus a total timeout, and define which content belongs in the capture.
- Hidden tabs or accordions: images may not load until their panel is opened. Activate the relevant control before waiting, if doing so is appropriate for the capture.
- Cross-origin images: ordinary image display and
decode()do not require reading pixel data through canvas. If your workflow later reads pixels, cross-origin restrictions may apply separately.
5. Performance and reliability
Scrolling every viewport can trigger many requests and page scripts, so it costs time and bandwidth proportional to page length and image count. For a viewport or element capture, trigger and check only the relevant area. For a full-page image, progressive scrolling is generally more reliable than assuming a large jump will activate every visibility-based behavior.
Keep waits bounded. Puppeteer’s wait functions have timeout and polling options, and its documented default wait timeout is 30 seconds. Choose a deadline suitable for the page, and on timeout capture diagnostics such as the current URL, document height, pending image sources, and failed image list. Avoid an unbounded promise around decode(): a single problematic resource should produce a recorded failure or a clear capture error.
For repeated captures, consider caching output when the page and capture settings have not changed. If you operate the browser yourself, account for browser execution, network transfer, storage, and retries in your own cost model; the research sources provide no benchmark or universal cost figure. Retries should be limited and reserved for transient navigation or load failures, since retrying a permanently broken image will not repair it.
6. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Images are missing from a full-page capture | fullPage: true enlarged the capture but did not trigger lazy loading below the viewport. |
Scroll through the page first, then repeat readiness checks and capture. |
networkidle2 returns but images are absent |
The page became network-quiet before those images were requested, or image decoding had not finished. | Trigger the relevant regions, check successful image completion, then await decode. |
| The wait passes but a broken image remains | complete is true for failed resources too. |
Require naturalWidth > 0 for success and log or handle failures explicitly. |
| The wait times out on a long page | The script waits for images unrelated to the capture, or a request never settles. | Scope to the target, use a bounded timeout, and report pending sources before choosing to skip or fail. |
| Images inside a panel remain unloaded | The panel has its own scroll container or is hidden until opened. | Scroll the observer’s actual root or reveal the relevant panel before waiting. |
| Decode rejects intermittently | The source changed during decode, the request failed, or the file data is corrupt. | Record the source, re-query after the page settles, retry once only if the source changed or the failure appears transient, and otherwise report the image as failed. |
| New images appear after the wait | Scrolling or page scripts inserted content after the initial image snapshot. | Take a fresh image snapshot after triggering loads; for progressive pages, repeat until the image set stabilizes or a defined limit is reached. |
7. Other ways to trigger lazy loading
Scrolling is usually the most direct trigger because it follows the page’s actual visibility behavior. If you control the page, you can instead make images eager or expose a page-specific readiness signal for automation. For third-party pages, avoid changing every image’s loading attribute as a default: that can cause a burst of downloads, alter page behavior, and still does not ensure successful decoding. Intersection-observer-driven components may require movement of their configured root rather than changes to native image attributes.
Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server. Its one-call API returns an image or PDF without requiring you to launch and manage Puppeteer for this capture. The service removes cookie banners, popups, and chat widgets before the shot; bot checks, blank pages, and failed loads are never billed. An MCP server lets AI agents use screenshot tools. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000. See the API documentation for options and response details.
curl -G "https://api.screenshotneo.com/v1/shot" \
-d access_key=YOUR_API_KEY \
--data-urlencode url=https://example.com \
-o shot.webp
Sign up for 1,000 free screenshots a month, with no card.
FAQ
Does Puppeteer’s networkidle2 wait for every lazy image?
No. It describes network activity, not whether every intended image has been requested, loaded successfully, and decoded.
Is img.complete enough?
No. Pair it with naturalWidth > 0 to distinguish a successfully loaded image from a completed failure.
Should I always wait for every image on the page?
No. Wait for the images that affect the capture. A viewport or element screenshot usually needs a narrower scope than a full-page screenshot.
Why use decode() after checking load success?
It waits for image data to be decoded for use and can surface failures that should be handled before capture.


