Fix Blank Website Screenshots in Puppeteer When the Page Uses Client-Side Rendering
Wait for the content your client-rendered page actually needs before capturing. This guide covers Puppeteer waits, diagnostics, runnable code, and fixes for common failures.
A Puppeteer screenshot of a client-rendered page can be blank because navigation completed before the application rendered the content. Wait for a stable, visible selector that marks the content you need, or use page.waitForFunction() for a specific application condition. waitUntil: 'networkidle2' is a useful navigation baseline, but it does not prove that a particular component or its data is ready.
Once the page reaches the state you need, capture it with page.screenshot(). Puppeteer’s screenshot guide documents this flow, and its selector wait API covers waiting for an element.
Why client-rendered screenshots come out blank
In a client-rendered app, the initial document may contain little more than the HTML shell. JavaScript then runs, fetches data, and updates the page. A navigation event can finish while that work is still pending. If the screenshot happens at that point, it records the shell rather than the rendered application.
The right wait depends on what the screenshot must show. A page-specific content selector is usually easiest to reason about. When readiness depends on state or text rather than the presence of one element, use a browser-side predicate. Network idleness can help with navigation, but it is not an application-level readiness signal.
Recommended fix: wait for a visible content selector
Choose a stable selector that appears only when the content you want is rendered. A dedicated readiness marker such as [data-testid="page-content"] is preferable to a generic element like body.
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch();
try {
const page = await browser.newPage();
// Set the viewport before navigation if responsive rendering matters.
await page.setViewport({ width: 1440, height: 900 });
const response = await page.goto('https://example.com', {
waitUntil: 'networkidle2',
});
if (response && !response.ok()) {
throw new Error(`Navigation returned HTTP ${response.status()}`);
}
// Replace with a stable marker for the content required in the image.
await page.waitForSelector('[data-testid="page-content"]', {
visible: true,
timeout: 30000,
});
await page.screenshot({ path: 'page.png', fullPage: true });
} finally {
await browser.close();
}
Install Puppeteer in a Node.js project with npm install puppeteer, save this as an ES module (for example, capture.mjs), and run node capture.mjs. Replace the example URL and selector with the target page and its real content marker. The selector wait resolves when the selector is in the DOM; with visible: true, Puppeteer also checks that it is not hidden by display: none or visibility: hidden. It does not guarantee that images, animations, descendants, or all application data are ready. See the Puppeteer API reference.
Choose the right readiness condition
| Wait | What it establishes | Use it when | Limit |
|---|---|---|---|
waitUntil: 'networkidle2' |
A navigation lifecycle condition used by Puppeteer’s screenshot example. | You want a broad baseline while the page’s requests settle. | It does not establish that the specific component or data for your screenshot has rendered. |
waitForSelector(selector, { visible: true }) |
The selector exists and meets Puppeteer’s documented visibility check. | The app has a stable content container or readiness marker. | Visibility alone does not establish that the content is complete. |
waitForFunction(predicate) |
A browser-side function returns a truthy value. | Readiness depends on app state, populated content, or a condition not captured by one selector. | The predicate must distinguish real completion from an empty shell and needs a bounded timeout. |
For interactions, Puppeteer recommends locators, which wait for an element to be present and in the required state. The page interactions guide describes locator waits, visibility, and custom function conditions. The Page API documents waitForFunction().
Wait for rendered content with waitForFunction
Use a predicate when the page has no reliable readiness element. For example, wait until a results container contains non-empty text. Tailor the predicate to the page: generic checks such as “the body has text” may succeed on a loading message, navigation, or error page.
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch();
try {
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900 });
const response = await page.goto('https://example.com/results', {
waitUntil: 'networkidle2',
});
if (response && !response.ok()) {
throw new Error(`Navigation returned HTTP ${response.status()}`);
}
await page.waitForFunction(() => {
const results = document.querySelector('[data-testid="results"]');
return results && results.children.length > 0;
}, { timeout: 30000 });
await page.screenshot({ path: 'results.png', fullPage: true });
} finally {
await browser.close();
}
The function runs in the page context, so it can inspect the rendered DOM. Make the condition reflect the actual screenshot requirement: for example, populated result cards rather than merely a container element. If images inside the content matter, add a separate check for their readiness instead of assuming the content predicate waits for them.
Check the response and capture target
A wait cannot fix a request to the wrong URL, an HTTP error page, or a screenshot of the wrong target. Inspect the response from page.goto() and its status. Confirm that the URL is the intended route and that the viewport is set before navigation when responsive behavior affects the page.
For a specific element rather than the whole page, use Puppeteer’s ElementHandle.screenshot(). Puppeteer documents that it attempts to scroll the element into view if it is hidden from the viewport. Check that the selected element is the one containing the rendered content, and that its dimensions are not zero.
Diagnose JavaScript and network failures
If the expected selector never appears, collect browser errors and failed requests before navigation. These signals help distinguish a timing issue from an app or resource failure.
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch();
try {
const page = await browser.newPage();
page.on('pageerror', error => {
console.error('Page error:', error.message);
});
page.on('requestfailed', request => {
console.error('Request failed:', request.url(), request.failure()?.errorText);
});
page.on('console', message => {
if (message.type() === 'error') {
console.error('Browser console:', message.text());
}
});
const response = await page.goto('https://example.com', {
waitUntil: 'networkidle2',
});
console.log('Navigation status:', response?.status());
await page.waitForSelector('[data-testid="page-content"]', {
visible: true,
timeout: 30000,
});
await page.screenshot({ path: 'page.png', fullPage: true });
} finally {
await browser.close();
}
Attach listeners before navigation so startup errors and early failed requests are not missed. A failed request may be optional, and a console error may be unrelated to the blank capture; use them as evidence to investigate, not as automatic proof of cause.
Common errors and fixes
| Symptom | Likely cause | What to check or change |
|---|---|---|
| Screenshot contains a blank shell | Capture ran before client-side rendering finished. | Wait for a page-specific visible selector or a predicate that checks populated content. |
waitForSelector times out |
The selector is wrong, the content did not render, or it lives in a frame or shadow root. | Inspect the page and selector; check for login, consent, or error states; verify whether the target is inside a frame or shadow root. |
| Selector resolves, but the capture is still incomplete | The marker became visible before dependent data, images, or animations finished. | Use a more specific readiness condition and add separate checks for visual assets that matter. |
| Navigation status is not successful | The server returned an HTTP error or the route redirected to an unexpected state. | Log the response status and URL, then check access, routing, and the final page state. |
| Page errors or failed requests appear | Application JavaScript or a required resource may have failed. | Use the diagnostics above to identify the failing code or request, and resolve it before extending the wait. |
| Chrome fails to start in a container | Startup may require writable profile, configuration, or cache paths; the installed browser may not match the Puppeteer version. | Check the Puppeteer troubleshooting guide for writable paths and use a Chromium version supported by the installed Puppeteer version. |
Make captures more reliable and efficient
- Prefer state-based waits. A fixed sleep can be too short on a slow run and unnecessarily long on a fast one. Wait for an observable page condition and keep a timeout so a broken state fails clearly.
- Make the marker meaningful. A selector that appears in the initial shell does not prove client content is ready. Prefer a marker tied to the data or component the screenshot must show.
- Set the viewport first. Responsive layouts and lazy rendering can depend on viewport dimensions. Configure the viewport before navigation when those behaviors matter.
- Close the browser in a finally block. This ensures cleanup runs even if navigation or a readiness wait fails.
- Capture only what you need. Full-page capture is useful for long content; a specific element can avoid capturing unrelated page regions. Choose based on the output you need.
Waits and capture options do not make a failed app render successfully. If the page remains blank after a state-based wait, use the response status, console errors, failed requests, and selector checks to locate the failure. For container-specific startup issues, follow Puppeteer’s current troubleshooting documentation.
Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server from Yorker Media. It can capture a URL as PNG, JPEG, WebP, or PDF with one GET request. See the ScreenshotNeo API documentation for request 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
The API accepts the parameter names used by other screenshot APIs, which can make switching simpler. Its options include waiting for a selector, a delay, or network idle; full-page capture with lazy images loaded; CSS selector element capture; custom JavaScript and CSS; and PNG, JPEG, WebP, or PDF output. Cookie banners, popups, and chat widgets are removed before the shot, with each cleanup step configurable. Bot checks, blank pages, failed loads, timeouts, and cache hits cost nothing, and response headers report the page verdict and billing status. An MCP server provides take_screenshot, get_page_info, and capture_pdf tools for AI agents.
The free plan includes 1,000 screenshots a month with no card. Paid plans start at $5 for 3,000 screenshots; yearly billing gives two months free, and every feature is available on every plan. Sign up for 1,000 free screenshots a month with no card.
FAQ
Why is my Puppeteer screenshot blank even though page.goto completed?
Navigation completion is not the same as client-rendered content being ready. Wait for a selector or predicate tied to the content you need.
Should I always use networkidle2?
No. It is a useful baseline from Puppeteer’s screenshot example, but application-specific readiness is a stronger signal when you have one.
Does visible: true guarantee the whole page is ready?
No. It checks the selected element’s documented visibility conditions. It does not guarantee that its descendants, images, animations, or data are complete.
What should I do if there is no stable content selector?
Use waitForFunction() with a specific browser-side condition, such as a populated result list or an app state exposed in the DOM.


