Why Does Puppeteer Screenshot Return a Blank Page?
A blank Puppeteer screenshot is a symptom, not a diagnosis. Check the final URL, response, rendered content, readiness signal, and capture bounds in that order.
A blank Puppeteer screenshot is a symptom, not a diagnosis. First confirm that navigation reached the intended URL and returned the expected response. Then verify that the content you expect exists in the page, wait for the application’s actual ready condition, and check the viewport and screenshot bounds. A fulfilled page.goto() does not prove that the page rendered correctly: navigation to about:blank can return null, and headless shell can resolve navigation even for HTTP error responses such as 404 or 500. See Puppeteer’s Page.goto() reference.
1. Confirm what Puppeteer loaded
Log the requested URL, the final URL, and the main document response. A redirect can lead somewhere unexpected; a request can also succeed at the HTTP level while serving an error page. Treat a null response as a special case to inspect, not proof of success.
import puppeteer from 'puppeteer';
const targetUrl = process.argv[2] ?? 'https://example.com';
const browser = await puppeteer.launch({ headless: true });
try {
const page = await browser.newPage();
page.on('console', message => console.log('[console]', message.type(), message.text()));
page.on('pageerror', error => console.error('[pageerror]', error.message));
page.on('requestfailed', request => {
console.error('[requestfailed]', request.url(), request.failure()?.errorText);
});
const response = await page.goto(targetUrl, {
waitUntil: 'domcontentloaded',
timeout: 45000,
});
console.log({
requestedUrl: targetUrl,
finalUrl: page.url(),
status: response?.status() ?? null,
responseUrl: response?.url() ?? null,
});
if (!response) {
throw new Error(`No main document response; current URL is ${page.url()}`);
}
if (!response.ok()) {
throw new Error(`Navigation returned HTTP ${response.status()} at ${response.url()}`);
}
await page.screenshot({ path: 'page.png' });
} finally {
await browser.close();
}
Run it with node diagnose.mjs https://your-site.example. Use a full URL including https://. If the final URL is a login, consent, error, or challenge page, investigate why the request was redirected or served that content before changing screenshot settings.
2. Verify that the expected page content exists
Check a selector that identifies the actual page you want, rather than relying on a generic lifecycle event. For a server-rendered page, this may be the article heading. For a single-page app, it may be a root element that appears only after data has loaded.
const heading = await page.waitForSelector('main h1', {
visible: true,
timeout: 20000,
});
const text = await heading?.evaluate(element => element.textContent?.trim());
if (!text) {
throw new Error(`Expected heading did not render at ${page.url()}`);
}
console.log('Ready heading:', text);
await page.screenshot({ path: 'page.png' });
For an app without a stable selector, wait for a condition tied to its data or rendering state:
await page.waitForFunction(() => {
const app = document.querySelector('#app');
return app?.getAttribute('data-state') === 'ready';
}, { timeout: 20000 });
Adapt the selector or condition to the site. An element can exist while still being empty, hidden, covered, or populated with placeholder content, so validate the content that matters.
3. Wait for the application’s real readiness signal
Puppeteer’s screenshot guide demonstrates navigation with waitUntil: 'networkidle2' before capture. That is a useful starting point, not a universal guarantee: an app may render after an API response, hydration, a delayed script, or an iframe load. Choose the wait that corresponds to the content you need.
| Signal | Use it when | Watch out for |
|---|---|---|
domcontentloaded |
You will wait separately for a known element or app state. | Images, API-driven content, and later scripts may not be ready. |
load |
The page’s load event is a useful milestone. | Some apps continue rendering after the event. |
networkidle0 / networkidle2 |
Network quiet is a meaningful signal for this page. | Analytics, polling, streaming, or persistent connections can delay or prevent idleness; idleness still does not prove the desired content rendered. |
waitForSelector() |
A visible, page-specific element marks readiness. | Check that the element has meaningful content, not just that it exists. |
waitForFunction() |
The application exposes a reliable state or data condition. | A condition that never becomes true ends in a timeout; make failures explicit. |
waitForResponse() |
A particular API response signals the data needed for the shot. | Match the intended request and verify its result; a response alone may not mean rendering is complete. |
For example, wait for a specific API response and then for its rendered result:
const dataResponsePromise = page.waitForResponse(response =>
response.url().includes('/api/products') && response.status() === 200,
{ timeout: 20000 },
);
await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 45000 });
await dataResponsePromise;
await page.waitForSelector('[data-testid="product-list"] li', {
visible: true,
timeout: 20000,
});
await page.screenshot({ path: 'products.png' });
Register the response wait before the action that triggers it so the event cannot be missed. If the API call starts during navigation, the example does this by creating the promise before goto().
4. Check viewport, clip, and screenshot options
A valid page can still produce an image that looks blank if the capture is outside the visible content, clipped to the wrong coordinates, or captured before responsive layout settles. Log the viewport and temporarily remove clipping and custom bounds. Puppeteer documents these controls in its ScreenshotOptions reference.
await page.setViewport({ width: 1365, height: 900, deviceScaleFactor: 1 });
await page.screenshot({
path: 'debug.png',
fullPage: true,
type: 'png',
});
fullPagecaptures the full page instead of just the viewport.cliprestricts the capture to a rectangle; check itsx,y,width, andheightagainst the page coordinates.captureBeyondViewportcontrols capture outside the viewport, particularly when using a clip.omitBackground: truemakes the default background transparent; it does not add missing page content.typeselects PNG, JPEG, or WebP where supported by the installed Puppeteer version; JPEG quality applies to JPEG, not PNG.pathdetermines where the file is saved; without it, screenshot bytes are returned rather than written to a file.
Start with a normal viewport screenshot. Add fullPage or clip only after confirming the content is present. For an element capture, wait for the element and inspect its dimensions before calling element.screenshot(); Puppeteer’s screenshot guide also covers element screenshots.
5. Check frames and content loaded by scrolling
Content inside an iframe belongs to a separate frame. Wait for the frame to attach and for its own content to become ready. Inspect page.frames() and each frame’s URL when the visible area is empty.
const frames = page.frames();
for (const frame of frames) {
console.log('Frame:', frame.url());
}
const targetFrame = page.frames().find(frame => frame.url().includes('/embedded-content'));
if (!targetFrame) throw new Error('Expected iframe did not load');
await targetFrame.waitForSelector('.embedded-result', { visible: true, timeout: 20000 });
Some sites defer images or sections until they approach the viewport. Scroll the page or the relevant container to trigger loading, then verify that the image has loaded or the section has populated before capture. There is no single lazy-load sequence that works for every site.
6. Compare headless and headful runs as a diagnostic
Run the same URL, viewport, readiness condition, and screenshot options in both modes. If only one mode is blank, compare the browser and Puppeteer versions, console output, page errors, failed requests, and any mode-dependent site behavior. Change one variable at a time so the difference is informative.
const browser = await puppeteer.launch({ headless: false });
A 2018 report described a blank screenshot in headless mode with headful mode working, using Puppeteer 0.1.13 on Ubuntu 17.04 against one website: Puppeteer issue #1755. It is historical, site- and version-specific evidence, not proof of a current general headless defect.
7. A repeatable debugging sequence
- Print the requested URL,
page.url(), response URL, and response status. - Reject unexpected redirects,
nullmain responses, and HTTP errors. - Log console messages, page errors, and failed requests while reproducing the issue.
- Wait for a page-specific element, app state, or relevant response, and assert that useful content exists.
- Capture a plain viewport screenshot with no clip or transparency settings.
- Test
fullPage, viewport dimensions, and clip bounds independently. - Check frames and trigger lazy-loaded content if the missing area depends on them.
- Compare headless and headful with all other inputs held constant; then compare browser/Puppeteer versions.
Common errors and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
goto() resolves, but screenshot is blank |
Navigation completed, but the wrong destination or an unrendered app state was captured. | Check final URL and response; wait for and assert the page-specific content. |
Response is null |
The page may still be at about:blank, or navigation was only a same-page hash change. |
Log page.url(), verify that the intended navigation happened, and do not treat null as an HTTP success response. |
| HTTP 404 or 500 but no navigation exception | Navigation resolution is not equivalent to an HTTP success status; headless shell behavior is documented by Puppeteer. | Check response.status() and explicitly reject unexpected status codes. |
| Selector wait times out | Wrong selector, redirect, failed app script/API call, content in another frame, or selector never appears. | Inspect URL, console, failed requests, and frames; confirm the selector in the correct document. |
| Network-idle wait times out | Continuous polling, analytics, streaming, or persistent requests keep the network active. | Wait for a content-specific selector or app condition instead. |
| Header appears but main content is empty | Client-side hydration or a data request has not completed, or JavaScript failed. | Inspect page errors and relevant API requests; wait for the rendered result rather than a generic load event. |
| Only part of the page is captured | Viewport-only capture, incorrect clip bounds, or content below the fold. | Remove clip while debugging, check the viewport, then use fullPage if required. |
| Iframe area is empty | The iframe has not loaded, or the content is inside a child frame. | Find the frame, wait for its URL/content, and inspect frame-level errors. |
| Screenshot is transparent or appears blank in a viewer | omitBackground removed the default white background and the page has transparent regions. |
Capture with omitBackground: false while diagnosing, or set a page background explicitly. |
| Headful works, headless does not | Environment, browser build/version, or site behavior differs between runs. | Hold URL, viewport, readiness, and options constant; compare versions and captured diagnostics before attributing the cause. |
Performance, reliability, and cost
- Wait only for what matters. Waiting for a stable selector or app condition can avoid spending time on irrelevant third-party requests. Avoid arbitrary long delays as the only readiness check; they can still be too short on slow runs and waste time on fast ones.
- Use explicit timeouts and cleanup. Navigation and readiness waits should have bounded timeouts. Put browser closure in a
finallyblock so failed captures do not leave browser processes running. - Keep diagnostic output. Record final URL, status, viewport, browser/Puppeteer version, console errors, and failed requests alongside a failed artifact. This makes intermittent failures comparable.
- Control inputs for reproducibility. Fix the URL, viewport, device scale, cookies/session, and readiness condition when comparing runs. A changing page state can make two screenshots incomparable.
- Account for browser resources. Full-page images and large viewports can use more memory and take longer to encode than viewport captures. Capture only the area and format you need once the issue is understood.
- Cost is mostly operational. A self-hosted Puppeteer capture consumes your browser and compute resources; repeated retries and unbounded waits add runtime. There is no universal cost or speed figure because pages and execution environments differ.
Or skip the browser setup
ScreenshotNeo provides a website screenshot API: one GET request returns an image or PDF. For example, use cURL:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
Equivalent Python and Node.js examples are in the ScreenshotNeo API documentation. ScreenshotNeo removes cookie banners, newsletter popups, and chat widgets before the shot. Bot checks, blank pages, and failed loads are never billed, and the response identifies page verdict and billing status. Its MCP server lets AI agents take screenshots. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000. See ScreenshotNeo or the docs for the available options.
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 import('node:fs/promises').then(fs => fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer())));
Create a free ScreenshotNeo account for 1,000 screenshots a month with no card.
FAQ
Does a successful page.goto() mean the site is ready?
No. It means the navigation promise resolved. Verify the final URL, response status, and expected rendered content separately.
Should I always use networkidle2 before a screenshot?
No. It is a documented example, but some sites never become idle and others render useful content before or after that point. Match the wait to the content you need.
Can fullPage: true fix a blank page?
It can include content outside the viewport, but it cannot make content that never rendered appear. Confirm the DOM and page state first.
Is Puppeteer’s headless mode generally broken?
The cited historical issue is one old report, not evidence of a current general defect. Compare modes on your own reproducible case and inspect the environment and page diagnostics.


