Fix Puppeteer Screenshots That Miss Content Loaded After Scrolling
Full-page capture does not trigger lazy loading. Scroll the right page area, wait for the content you need, verify it, then capture.
If a Puppeteer screenshot misses content that appears after scrolling, trigger the page’s scroll behavior before capturing and wait for a signal that the specific content is ready. page.screenshot({ fullPage: true }) controls how much of the document is captured; it is not documented as a command to load content that has not appeared yet. For a single element, Puppeteer’s ElementHandle.screenshot() scrolls that element into view before capturing it, but that does not guarantee other page sections have loaded.
This distinction matters for lazy-loaded images, infinite feeds, scroll-triggered animations, and pages that fetch content as a section becomes visible. The dependable sequence is: identify what is missing, scroll the page or its actual scroll container, wait for a meaningful readiness condition, verify the target, and then take the screenshot.
1. Diagnose what the screenshot is missing
First distinguish among three cases:
- Absent from the DOM: The site has not inserted the content yet. Scrolling may trigger a request or render.
- Present but hidden: The node exists, but CSS, application state, or an animation keeps it invisible.
- Present and visible, but missing from the image: The capture extent or screenshot target may be wrong, or the screenshot happened before rendering finished.
Inspect the page in its own context. page.evaluate() runs a function in the browser page and waits if that function returns a Promise. Use it to check whether a target exists, whether it has text, and whether it has a non-zero box. These checks narrow the problem; they do not prove that every pixel has finished painting.
const state = await page.evaluate((selector) => {
const element = document.querySelector(selector);
if (!element) return { exists: false };
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
exists: true,
text: element.textContent?.trim().slice(0, 200) ?? '',
width: rect.width,
height: rect.height,
display: style.display,
visibility: style.visibility,
opacity: style.opacity,
};
}, '[data-content-ready="true"]');
console.log(state);
A zero-sized box or display: none points toward visibility or application state, while a missing node points toward loading or the wrong selector. For elements inside an iframe, inspect the corresponding frame rather than assuming the main page’s document contains them.
2. Use a readiness signal, not a guess
Prefer a condition tied to the content you need: a selector, expected text, a known “loaded” attribute, or an application-specific state. A network-idle wait can help when the relevant content depends on requests, but it only indicates that network activity has been quiet for the configured interval. It does not certify that the application rendered a particular block. Puppeteer documents a default idle interval of 500 ms; the wait always lasts at least the selected idle time.
Use the most specific condition available:
| Situation | Wait for | What it establishes |
|---|---|---|
| The page marks the target ready | waitForSelector() or waitForFunction() |
The specified DOM condition became true. |
| Content appears after a known request | waitForResponse(), followed by a DOM check |
The response arrived; verify separately that the UI rendered it. |
| No application signal is available | waitForNetworkIdle(), followed by a DOM check |
Network activity became idle for the chosen interval; app readiness is still unproven. |
| A known animation must finish | An app-specific state or a deliberately chosen delay | Only the condition you selected; a delay alone is timing-dependent. |
A fixed delay can be useful as a last resort when the page offers no observable signal, but it is inherently less reliable: slow runs can exceed it, and fast runs waste time. Set timeouts based on the page and capture environment, and let failures surface instead of silently taking an incomplete image.
3. Scroll the page to trigger lazy loading
For a document that loads content as the window scrolls, move through it in viewport-sized steps. A small pause gives scroll handlers and observers an opportunity to run, but the pause is only pacing; the selector check after scrolling is the meaningful readiness test.
Here is a complete Node.js example using Puppeteer. Save it as capture.mjs, install Puppeteer with npm install puppeteer, and run node capture.mjs https://example.com. Replace the example URL and READY_SELECTOR with a page and selector you control.
import puppeteer from 'puppeteer';
const url = process.argv[2];
if (!url) {
throw new Error('Usage: node capture.mjs https://example.com');
}
const READY_SELECTOR = '[data-content-ready="true"]';
const browser = await puppeteer.launch({ headless: true });
try {
const page = await browser.newPage({
viewport: { width: 1365, height: 900 },
deviceScaleFactor: 1,
});
page.setDefaultNavigationTimeout(30_000);
page.setDefaultTimeout(15_000);
const response = await page.goto(url, { waitUntil: 'domcontentloaded' });
if (!response || !response.ok()) {
throw new Error(`Navigation failed: ${response?.status() ?? 'no response'}`);
}
// Trigger viewport-based lazy loading. This is a pacing loop, not a
// readiness guarantee; the target selector below is the actual check.
await page.evaluate(async () => {
const pause = (ms) => new Promise(resolve => setTimeout(resolve, ms));
const step = Math.max(300, Math.floor(window.innerHeight * 0.8));
let previousHeight = 0;
let stablePasses = 0;
const maxPasses = 40;
for (let pass = 0; pass < maxPasses; pass += 1) {
const height = document.documentElement.scrollHeight;
const limit = Math.max(0, height - window.innerHeight);
for (let y = 0; y <= limit; y += step) {
window.scrollTo(0, y);
await pause(150);
}
window.scrollTo(0, document.documentElement.scrollHeight);
await pause(250);
const nextHeight = document.documentElement.scrollHeight;
if (nextHeight === previousHeight) stablePasses += 1;
else stablePasses = 0;
if (stablePasses >= 2) break;
previousHeight = nextHeight;
}
});
// Wait for a page-specific signal. This example selector is a placeholder.
await page.waitForSelector(READY_SELECTOR, { visible: true, timeout: 15_000 });
// Optionally wait for network quiet, then still verify the target.
// Remove this wait if the site maintains long-lived network connections.
await page.waitForNetworkIdle({ idleTime: 500, timeout: 10_000 }).catch(() => {});
const target = await page.$(READY_SELECTOR);
if (!target) throw new Error(`Expected content not found: ${READY_SELECTOR}`);
const details = await target.evaluate(element => {
const rect = element.getBoundingClientRect();
return {
text: element.textContent?.trim().slice(0, 200) ?? '',
width: rect.width,
height: rect.height,
};
});
if (details.width === 0 || details.height === 0) {
throw new Error(`Target exists but has no visible area: ${JSON.stringify(details)}`);
}
await page.screenshot({ path: 'page.png', fullPage: true });
console.log(`Saved page.png; target: ${JSON.stringify(details)}`);
} finally {
await browser.close();
}
The loop stops after the document height is stable for consecutive passes or reaches its pass limit. This is a practical safeguard for pages that append content, not proof that an infinite feed has been exhausted. For an infinite feed, define an application-specific stopping rule, such as reaching a known item count or seeing an end marker, and fail clearly if that rule is not met.
4. Scroll the correct container
Some pages scroll an inner panel while the window stays put. A document-level loop will not trigger that panel’s scroll handlers. Find the actual container and scroll it instead:
const containerSelector = '.results-panel';
const itemSelector = '.results-panel .result-card:last-child';
await page.waitForSelector(containerSelector);
await page.evaluate(async (containerSelector) => {
const container = document.querySelector(containerSelector);
if (!container) throw new Error(`Container not found: ${containerSelector}`);
const pause = (ms) => new Promise(resolve => setTimeout(resolve, ms));
const step = Math.max(200, Math.floor(container.clientHeight * 0.8));
let previousHeight = 0;
let stablePasses = 0;
for (let pass = 0; pass < 40; pass += 1) {
const limit = Math.max(0, container.scrollHeight - container.clientHeight);
for (let top = 0; top <= limit; top += step) {
container.scrollTop = top;
await pause(150);
}
container.scrollTop = container.scrollHeight;
await pause(250);
if (container.scrollHeight === previousHeight) stablePasses += 1;
else stablePasses = 0;
if (stablePasses >= 2) break;
previousHeight = container.scrollHeight;
}
}, containerSelector);
await page.waitForSelector(itemSelector, { visible: true, timeout: 15_000 });
await page.screenshot({ path: 'results.png', fullPage: true });
If the desired content sits inside a nested container or iframe, scroll and inspect that context. Puppeteer’s locator API also provides scroll behavior; use the locator for the actual element or container rather than assuming the top-level window owns the scroll.
5. Capture the whole page or one element
Once the target is ready, choose the screenshot scope that matches the output you need:
- Whole document:
await page.screenshot({ path: 'page.png', fullPage: true }). This captures the full page after your loading and readiness steps. - Current viewport:
await page.screenshot({ path: 'viewport.png' }). Use this when you want only what is currently visible. - One element: wait for and locate it, then call
await element.screenshot({ path: 'element.png' }). Puppeteer scrolls that element into view if needed and captures it. If the handle becomes detached because the page rerendered, locate the element again.
For a single element, you do not need a full-page capture to bring that element into view. But the element screenshot’s automatic scroll does not trigger or verify unrelated sections elsewhere on the page.
6. Troubleshooting common failures
| Symptom | Likely cause | Fix |
|---|---|---|
| Lower sections are blank in a full-page image | Capture ran before scroll-triggered loading completed. | Scroll the relevant page area, wait for the target selector or app state, verify it, then capture. |
| Scrolling the window changes nothing | The page uses an inner scroll container. | Find the element whose scrollHeight exceeds clientHeight; scroll that container and wait for its content. |
waitForSelector times out |
The selector is wrong, content never loaded, it is in another frame, or the site requires another interaction. | Inspect the DOM and frames, confirm the selector in the correct context, and reproduce required clicks or state changes before waiting. |
| Selector resolves but image is still empty | The node exists before its content, image, or styling is ready. | Wait for an application-ready attribute, expected text, image complete/naturalWidth, or another page-specific signal; then verify visibility. |
waitForNetworkIdle never resolves |
Polling, analytics, streaming, or other continuing requests keep the page active. | Use a selector or app state as the primary wait. If network idle is still useful, apply a finite timeout and handle it explicitly. |
| Network idle resolves but content is missing | Quiet network does not mean the app rendered the needed content. | Wait for and check the actual content condition; use network idle only as an additional signal. |
| Only the last batch of an infinite list appears | Scrolling triggered repeated loads or the app virtualizes and replaces off-screen nodes. | Define a stopping condition and consider capturing sections or items as they appear. A single full-page image may not preserve virtualized content that is no longer in the DOM. |
| Element screenshot says the handle is detached | The app replaced the node during a rerender. | Wait for the replacement state, query the selector again, and capture the fresh handle. |
| Screenshot cuts off despite content being present | The selected capture scope is viewport-only, or page dimensions changed during capture. | Use fullPage: true for the document and wait for layout-changing content to settle before capturing. |
When diagnosis remains unclear, log navigation status, console errors, failed requests, selector state, scroll-container dimensions, and the final document height. This separates a failed load from a wrong scroll target or an early screenshot.
7. Reliability, speed, and resource tradeoffs
- Use the earliest useful navigation milestone.
domcontentloadedcan let your own selector and scroll logic drive readiness.loadwaits for more page resources, but it still does not guarantee scroll-triggered content is loaded. - Wait narrowly. A target selector or app state usually avoids waiting for unrelated requests. Network-idle adds time and can be incompatible with persistent connections.
- Limit scroll work. Use a bounded number of passes and stop on an explicit content condition. Large pages, image-heavy feeds, and long pauses increase capture time and browser memory use.
- Account for layout changes. Images, fonts, and expanding sections can shift content. If the target matters, wait for its final state rather than relying only on a previously measured document height.
- Make failure visible. A missing required selector should fail the job with a useful message. Saving an image anyway can produce a valid PNG of the wrong state.
- Control concurrency. Each open page and full-page image consumes browser resources. Reuse browser processes where appropriate, close pages after capture, and avoid unbounded parallel captures.
Or skip the browser setup
If you need a screenshot API rather than maintaining a browser script, ScreenshotNeo returns a screenshot or PDF from one GET request. See the API documentation for the full options and response details.
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,
)
r.raise_for_status()
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}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await Bun.write('shot.webp', res);
ScreenshotNeo removes cookie banners, newsletter popups, and chat widgets before capture. Bot checks, blank pages, and failed loads are never billed. Its 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 screenshots.
Create a free account and get 1,000 screenshots a month with no card.
FAQ
Does fullPage: true scroll the page to load lazy content?
Do not rely on it to trigger site behavior. Scroll and verify the target before taking the full-page screenshot.
Should I always wait for network idle?
No. It can be useful for network-backed content, but it is not a render-ready guarantee and may time out on pages with ongoing requests.
Can Puppeteer capture a lazy-loaded element without capturing the whole page?
Yes. After the element exists and is ready, ElementHandle.screenshot() scrolls it into view if needed and captures that element.
What if a virtualized list never contains every item at once?
A full-page screenshot may only include items currently represented in the DOM. Define what “complete” means for your job, then capture sections or items as they are loaded if necessary.


