How to Capture Images That Load After Scrolling in Headless Chrome
Headless Chrome’s screenshot flag does not scroll to trigger lazy images. Use Puppeteer to scroll, wait for the images you need, and capture the page reliably.
Chrome Headless’s --screenshot option captures a page after load or a configured timeout; it does not scroll the page to trigger images that load near the viewport. Use Puppeteer: navigate to the page, scroll through the document or the relevant nested container, allow time for newly exposed content to load, check the target images, then take a screenshot.
A page’s load event is not proof that lazy images are ready. For a full-page capture, trigger the page’s scroll-dependent loading before calling page.screenshot({ fullPage: true }). For one element, Puppeteer’s element screenshot method can scroll a hidden element into view automatically. See the Puppeteer screenshots guide.
Use Puppeteer to scroll, wait, and capture
The following Node.js example is a runnable starting point for a page that uses ordinary document scrolling and <img> elements. It scrolls in viewport-sized steps, makes repeated passes if the page grows as content loads, waits for a quiet network period when possible, and checks image completion before capturing.
import puppeteer from 'puppeteer';
const url = process.argv[2];
if (!url) {
throw new Error('Usage: node capture.mjs https://example.com');
}
const browser = await puppeteer.launch({ headless: true });
try {
const page = await browser.newPage({
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 1,
});
await page.goto(url, {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
// Revisit the bottom if scrolling causes the page to append more content.
await page.evaluate(async () => {
const step = Math.max(200, Math.floor(window.innerHeight * 0.75));
const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
let previousHeight = 0;
let stablePasses = 0;
const maxPasses = 8;
for (let pass = 0; pass < maxPasses && stablePasses < 2; pass++) {
const heightBefore = document.documentElement.scrollHeight;
for (let y = 0; y < heightBefore; y += step) {
window.scrollTo(0, y);
await pause(250);
}
window.scrollTo(0, document.documentElement.scrollHeight);
await pause(500);
const heightAfter = document.documentElement.scrollHeight;
stablePasses = heightAfter === previousHeight ? stablePasses + 1 : 0;
previousHeight = heightAfter;
}
window.scrollTo(0, 0);
});
// Network idleness is a helpful signal, but the image check below is the
// more direct check for ordinary img elements. Keep both waits bounded.
await page.waitForNetworkIdle({ idleTime: 500, timeout: 5_000 }).catch(() => {});
await page.waitForFunction(() =>
[...document.images].every(img => img.complete),
{ timeout: 15_000 },
).catch(() => {});
await page.screenshot({ path: 'page.png', fullPage: true });
} finally {
await browser.close();
}
Save this as capture.mjs, install Puppeteer with npm install puppeteer, then run node capture.mjs https://example.com. Puppeteer downloads a compatible browser during installation unless configured otherwise. If your environment supplies Chrome separately, follow Puppeteer’s configuration guidance for that setup.
The scroll step and pauses are example values, not universal settings. Adapt them to the page. A site may need a longer pause, several visits to newly appended content, a different scroll target, or selectors that identify only the images relevant to your capture.
Wait for successful images when failures matter
HTMLImageElement.complete becomes true after loading finishes, including when an image failed. A successful image should also have naturalWidth > 0. To wait for all ordinary images to either load successfully or fail, while allowing failures to be reported rather than waiting forever, replace the prior image wait with:
const imageResults = await page.evaluate(async () => {
const images = [...document.images];
await Promise.all(images.map(img => {
if (img.complete) return Promise.resolve();
return new Promise(resolve => {
img.addEventListener('load', resolve, { once: true });
img.addEventListener('error', resolve, { once: true });
});
}));
return images.map(img => ({
src: img.currentSrc || img.src,
complete: img.complete,
loaded: img.naturalWidth > 0,
}));
});
const failedImages = imageResults.filter(image => !image.loaded);
console.log(`Images that did not load successfully: ${failedImages.length}`);
This waits for the currently present <img> elements to settle. If the page can add more images later, first scroll through it and repeat the check after the document height and image count stop changing. Put an overall timeout around production waits so one broken resource cannot hold a capture indefinitely.
Choose what to scroll and what to capture
Main document scrolling
Use window.scrollTo() when the page itself scrolls. Advancing by part of the viewport tends to expose content progressively while retaining some overlap. Return to the top before a full-page capture if the page should be captured from its normal starting position. Full-page capture expands the screenshot to the document dimensions; it does not itself trigger every scroll-based loader.
Nested scroll containers
If a gallery, feed, or panel has its own scrollbar, scrolling the window may never expose its contents. Find the scrollable element and advance its scrollTop instead:
const selector = '.scrollable-gallery';
await page.evaluate(async selector => {
const box = document.querySelector(selector);
if (!box) throw new Error(`No element matches ${selector}`);
const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
const step = Math.max(150, Math.floor(box.clientHeight * 0.75));
let previousHeight = 0;
let stablePasses = 0;
for (let pass = 0; pass < 8 && stablePasses < 2; pass++) {
const heightBefore = box.scrollHeight;
for (let top = 0; top < heightBefore; top += step) {
box.scrollTop = top;
await pause(250);
}
box.scrollTop = box.scrollHeight;
await pause(500);
stablePasses = box.scrollHeight === previousHeight ? stablePasses + 1 : 0;
previousHeight = box.scrollHeight;
}
}, selector);
After triggering the images, capture the whole page or the specific element, depending on the required output. ElementHandle.screenshot() is useful for one element; Puppeteer scrolls a hidden element into view by default. If the element is inside a nested container, scroll that container first so the page has a chance to run its own loading logic.
One tall image or a screenshot per viewport?
| Output | Use it when | Watch for |
|---|---|---|
| Full-page screenshot | You need one tall artifact of the page after scroll-triggered content is present. | Very tall pages can produce large images and use substantial memory. Fixed elements may appear differently than during ordinary scrolling. |
| Viewport screenshots | You need a sequence showing what a visitor sees at each scroll position. | Capture each viewport only after its images settle. Decide whether sticky headers or overlays should appear in every frame. |
| Element screenshot | You need a particular card, image, or section. | Ensure the page-specific content has loaded; scrolling the element into view does not guarantee an app’s data request has completed. |
Understand lazy loading and readiness checks
Native lazy loading can defer an image until it is near the viewport. Sites also implement their own loaders with JavaScript and Intersection Observer, which reacts when a target intersects the viewport or a specified scrolling root. Scrolling is therefore often part of the page’s content-loading process.
MDN explains that the load event fires when eagerly loaded content has loaded; lazy resources may still be pending. The complete property is a useful completion signal, but pair it with naturalWidth > 0 when you need to know whether the image loaded successfully. The MDN <img> reference also notes that providing image dimensions helps avoid zero-size behavior that can interfere with lazy loading.
page.waitForNetworkIdle() waits for a period of network idleness. Treat it as a helper rather than proof that the desired visual content exists: a page may keep analytics connections open, serve assets from cache, or finish network activity before application code has rendered the image.
Use Chrome’s command line for a simple capture
For pages that do not need scroll-triggered loading, Chrome Headless can capture directly:
chrome --headless --screenshot=page.png https://example.com
Chrome documents --screenshot and --timeout. A timeout delays capture; it does not instruct Chrome to scroll the page. The capture otherwise occurs after page load completes. See Chrome Headless mode. Use browser automation when the page needs interaction, repeated scrolling, or readiness checks.
Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server for developers. Its API can return an image or PDF from one GET request. For a screenshot after page loading behavior such as lazy images, use the full-page option as needed and check the API documentation for supported parameters.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com -o shot.webp
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://example.com"},
timeout=90,
)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({
access_key: 'YOUR_API_KEY',
url: 'https://example.com',
});
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('shot.webp', res);
ScreenshotNeo accepts and removes more than 60 known consent platforms, newsletter popups, and chat widgets before capture; each cleanup step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, with the outcome identified in response headers. Its MCP server provides take_screenshot, get_page_info, and capture_pdf for AI agents. The free plan includes 1,000 screenshots per month with no card, and paid plans start at $5 for 3,000 screenshots.
Create a free ScreenshotNeo account for 1,000 screenshots a month, with no card required.
Troubleshooting
| Symptom | Likely cause | What to change |
|---|---|---|
| Images below the fold are missing | The script captured without scrolling, or its scroll step skipped content that triggers loading. | Scroll in smaller steps, pause after each step, and inspect whether the page uses a nested scroll container. |
| The page is full height but images are blank | Document height does not mean images completed successfully; the load event may precede lazy images. | Check complete and naturalWidth after scrolling. Wait for the images the capture needs. |
| The script waits forever for network idle | Some pages maintain long-lived network activity. | Set a timeout and treat network idle as optional. Use bounded checks on target images instead. |
| Scrolling the window does nothing | Content is inside an independently scrollable panel. | Find the panel and change its scrollTop, then check for new images or appended content. |
| Some images never appear | A request failed, a source was not assigned, or the visual uses CSS backgrounds or canvas instead of <img>. |
Inspect currentSrc, naturalWidth, and console/network errors. For backgrounds or canvas, inspect the page-specific styles or rendering code; document.images will not find them. |
| Content stops loading partway down | The page appends more content after reaching the bottom or requires another scroll pass. | Repeat scrolling while checking document or container height and image count. Keep a pass limit so the script remains bounded. |
| The full-page result is too large or awkward | The document is unusually tall, or fixed and sticky elements behave differently in a tall capture. | Capture viewport-sized images after each region is ready, or capture only the target element. |
Performance, reliability, and cost
- Control the work: A viewport-sized scroll step with a short pause is a starting point. Smaller steps and longer waits can trigger more reliably but increase capture time. Use the least work that exposes the relevant content.
- Bound every wait: Set navigation, network-idle, and image readiness timeouts. Decide whether an image failure should fail the whole job or be reported alongside the screenshot.
- Limit page growth: Infinite feeds can continue appending content indefinitely. Set maximum scroll passes, maximum capture height, or an application-specific stopping condition.
- Mind memory and output size: Full-page images of very long documents can consume significant memory and produce large files. Split the capture into viewport images when that better fits the downstream workflow.
- Keep captures reproducible: Use a consistent viewport and device scale factor, and account for animations, sticky elements, and changing page content. A fixed delay alone cannot make a dynamic page deterministic.
- Account for infrastructure: A self-hosted Puppeteer script uses your browser runtime and compute resources. A hosted screenshot API moves browser setup out of your script; compare its response and billing behavior with your expected capture volume and needs.
FAQ
Does fullPage: true trigger lazy images by itself?
No. Scroll the page or relevant container first, then capture the full document.
Should I wait for load or network idle?
Use navigation completion and network idle as useful signals, then verify the images needed in the output. Neither signal alone guarantees every lazy image has rendered.
Can Puppeteer capture an element that is offscreen?
Yes. ElementHandle.screenshot() tries to scroll a hidden element into view. For a page whose own scripts respond to scrolling, explicitly trigger the needed loading and readiness checks first.
Why does the screenshot differ from what I see while scrolling?
Page content, animations, sticky UI, and lazy-loading behavior can change over time. Fix the viewport and wait for the relevant content to settle before capture.


