Puppeteer Screenshot Misses Content Loaded by IntersectionObserver
Puppeteer can capture before viewport-triggered content loads. Scroll through relevant regions, wait for the page’s real readiness signal, then take the screenshot.
Puppeteer does not guarantee that a screenshot or navigation wait will activate every IntersectionObserver. If a page loads images or content only after a target intersects the viewport, scroll through the relevant regions, wait for the page’s actual readiness condition, and capture afterward.
A full-page screenshot controls how much of the document is captured; it does not promise to visit every scroll position or trigger every observer. Puppeteer’s screenshot API captures the current page state. Puppeteer’s screenshot guide shows navigation followed by a screenshot, while its Page API provides explicit wait and evaluation methods for application-specific readiness.
Why content is missing
Some sites defer loading until an IntersectionObserver reports that an element intersects its root, often the viewport. Initial navigation can finish while elements farther down the page have never become visible, so their loading behavior has not run. Google’s lazy-loading guidance likewise says relevant content should load when it becomes visible in the viewport (Google Search Central: Fix lazy-loaded content).
Before changing waits, identify what is absent:
- Not in the DOM: the app may render the section only after a request or state transition.
- Present but hidden: a CSS state or application condition may keep it invisible.
- Image element exists, but its source is not loaded: the site may set
srconly after intersection. - Content is in an iframe or nested scroll container: scrolling the main document may not activate its observer.
Choose a signal that matches the missing content: a stable selector, expected item count, application-ready flag, or image completion check. A generic delay cannot distinguish a slow page from a page that has not yet triggered its observers.
A reliable Puppeteer workflow
- Navigate to the page with a suitable navigation milestone.
domcontentloadedcan be a useful starting point when you intend to wait for application content separately. - Scroll through the relevant viewport regions in increments so intermediate targets can intersect.
- Wait for the real content condition with
waitForSelector()orwaitForFunction(). - Optionally wait for network quiet if late requests are still settling and that condition suits the page.
- Capture only after the condition is satisfied. Use a timeout and surface a useful error if it is not.
The example below is runnable with Puppeteer installed. Replace the URL, selector, and expected count with the page’s actual condition. Its 100 ms pause is an illustrative interval, not a universal readiness guarantee.
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch({ headless: true });
try {
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
await page.goto('https://example.com', {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
// Give viewport-triggered observers a chance to run at each region.
await page.evaluate(async () => {
const step = Math.max(200, Math.floor(window.innerHeight * 0.8));
const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
const height = document.documentElement.scrollHeight;
for (let y = 0; y < height; y += step) {
window.scrollTo(0, y);
await pause(100);
}
window.scrollTo(0, 0);
});
// Replace these with the page's real completion condition.
await page.waitForFunction(
() => document.querySelectorAll('.lazy-content').length >= 10,
{ timeout: 15_000 },
);
// Optional secondary settling step; network quiet alone is not the readiness check.
await page.waitForNetworkIdle({ idleTime: 500, timeout: 10_000 }).catch(() => {});
await page.screenshot({ path: 'page.png', fullPage: true });
} finally {
await browser.close();
}
})().catch(error => {
console.error('Capture failed:', error);
process.exitCode = 1;
});
The loop uses the main document’s scroll height. Adapt it for pages whose content expands during loading, virtualized lists, or nested scroll containers. If sections append as you scroll, re-read the height during traversal or use an application signal that indicates the last item has loaded.
Choose the wait condition that matches the page
| Situation | Approach | Limit |
|---|---|---|
| A known element must appear | page.waitForSelector(selector, { visible: true }) |
Presence or visibility does not necessarily mean an image finished decoding. |
| A known count or app state marks completion | page.waitForFunction(() => ...) |
The condition must reflect the page’s actual ready state. |
| One particular target is needed | Scroll that element into view, then wait for its content. | This does not trigger unrelated targets elsewhere on the page. |
| Several below-fold sections are needed | Incrementally traverse their scroll regions and synchronize on content. | Traversal time depends on page length and loading behavior. |
| Requests are still in progress | Use waitForNetworkIdle() as a secondary settling condition. |
Network quiet cannot prove an offscreen observer was activated. |
Puppeteer documents waitForSelector(), waitForFunction(), evaluate(), and page interaction primitives in its Page API and page interactions guide. For a single element, Puppeteer says ElementHandle.screenshot() tries to scroll it into view. That convenience applies to that target; it does not load all other page content (Screenshots guide).
Image and layout edge cases
Verify images, not only their elements
A selector can match while the underlying image is still loading or broken. For images that matter to the capture, wait for their source and completion state:
await page.waitForFunction(() => {
const images = [...document.querySelectorAll('.gallery img')];
return images.length > 0 && images.every(img => img.complete && img.naturalWidth > 0);
}, { timeout: 15_000 });
This condition is suitable only if every matching image is expected to load successfully. If some images are optional, check a known required subset or an app-owned completion flag instead.
Nested scrollers and virtualized lists
Some pages scroll an inner panel rather than window. Find the actual scroll container and move that container through its content; the window loop will not make its children intersect. Virtualized lists may remove earlier items from the DOM as new ones appear. In that case, verify the desired final item or capture strategy rather than expecting every item to remain present simultaneously.
Dynamic height and layout shifts
Lazy images can change document height after loading. A loop that reads the height once may stop too early. Re-check the height while traversing, or continue until the application reports completion and the scroll position has reached the end. Restore the page to the intended capture position after traversal.
Frames
For content inside an iframe, identify the frame and evaluate the relevant selector or readiness state there. Scrolling the top-level page may expose the iframe, but its internal lazy content can still need its own scrolling and wait condition.
Common errors and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
waitForSelector times out |
The selector is wrong, the element is never rendered, or the observer was not triggered. | Inspect the DOM, scroll the correct container, and wait on a selector that exists in the loaded state. |
| Network idle completes but content is absent | No request started because the target never intersected, or the app uses non-network state changes. | Trigger the viewport condition first and wait for a DOM or application readiness signal. |
| Only the first few sections load | A single large scroll jump skipped intermediate intersection regions or the page uses an inner scroller. | Scroll in viewport-sized increments through the correct container and synchronize as needed. |
| The selector exists but the screenshot shows an empty image | The image source is unset, loading, or failed. | Check currentSrc, complete, and naturalWidth; wait for required images explicitly. |
| Capture finishes but lower content is absent | Full-page capture expanded the screenshot without activating all observers. | Complete the scroll-and-readiness workflow before calling screenshot(). |
| Wait never finishes on a busy page | Long polling, analytics, or ongoing requests prevent a network-idle condition. | Prefer a content-specific condition; use a bounded network-idle wait only if useful. |
Performance, reliability, and cost
Scrolling the whole page adds work proportional to the number of viewport regions visited, plus the waits needed for content to load. If only one section is needed, bring only that target into view. If a complete page is required, traverse the relevant regions and use an explicit condition rather than increasing a fixed sleep blindly.
Reliability comes from matching the condition to the page: selector or app state for content, image checks for required images, and a bounded timeout for failure handling. Network idle can be useful as a secondary signal, but it describes request activity rather than whether every observer target was exposed. The exact runtime and wait values depend on the site; the cited documentation provides behavioral guidance, not a universal timing guarantee.
For your own Puppeteer run, the operational cost is the browser runtime and infrastructure you choose; no general price can be stated without that setup. Avoid repeated full-page retries when a specific condition can report the missing section directly.
Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server. Send one GET request with a URL to receive a PNG, JPEG, WebP, or PDF. See the ScreenshotNeo API documentation for request options.
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}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
const fs = await import('node:fs/promises');
await fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));
ScreenshotNeo accepts cookie and consent banners like a visitor, then removes more than 60 known consent platforms, newsletter popups, and chat widgets; each step can be turned off. Bot checks and CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing, and response headers report the page verdict and billing status. Its MCP server offers take_screenshot, get_page_info, and capture_pdf for AI agents. The Free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 screenshots.
Sign up free for 1,000 screenshots a month, with no card required.
FAQ
Does fullPage: true trigger IntersectionObserver?
Do not rely on it to do so. It sets the capture extent; explicitly expose targets and wait for the content your page needs.
Is networkidle2 enough?
Not by itself. Network quiet cannot trigger a target that has not intersected the viewport.
Can I use a fixed delay?
As a short settling pause after scrolling, perhaps. A fixed delay alone is fragile because page speed and application state vary. Prefer a condition tied to the expected content.
Will an element screenshot load the whole page?
No. Puppeteer may scroll the requested element into view, but other observer targets still need their own activation and readiness checks.


