ScreenshotNeo

BlogHow-to

Puppeteer Screenshot Is Blank After Page.goto: Fixes

A resolved page.goto() does not guarantee your app rendered. Check the response, inspect the DOM, and wait for the content your screenshot needs.

By the ScreenshotNeo team4 October 20268 min read

A fulfilled page.goto() promise means Puppeteer completed its navigation wait condition. It does not prove the intended application content rendered. Check the main response and final URL, inspect the page title and DOM, wait for a meaningful content-ready condition, and only then take the screenshot.

Puppeteer documents Page.screenshot() as its screenshot method. Its guide shows a navigation followed by a screenshot, with networkidle2 as an example wait condition. That can be a useful starting point, but network quiet is not a universal signal that a single-page application has finished rendering.

1. Start with a diagnostic baseline

Log what was requested and what actually loaded. A navigation response can be absent in documented special cases, such as about:blank or same-URL hash navigation, so handle null explicitly. Also check the status: headless shell may resolve navigation for valid HTTP error statuses such as 404 or 500.

const url = 'https://example.com';
const response = await page.goto(url, { waitUntil: 'networkidle2' });

console.log({
  requestedUrl: url,
  responseStatus: response?.status() ?? null,
  finalUrl: page.url(),
  title: await page.title(),
});

const html = await page.content();
console.log('HTML excerpt:', html.slice(0, 1000));

Replace the example URL with the page you are capturing. A status of 200 alone does not establish that the app displayed the intended content: check the final URL, title, and a relevant element or text too.

2. Check whether the expected content exists

If the screenshot is blank, inspect the live page before changing screenshot settings. Query a known element and record its text and visibility. This separates a page that never rendered the application from a capture, target, viewport, or output-file problem.

const state = await page.evaluate(() => {
  const el = document.querySelector('main');
  if (!el) return { found: false, text: null, visible: false };
  const style = getComputedStyle(el);
  const rect = el.getBoundingClientRect();
  return {
    found: true,
    text: el.textContent?.trim().slice(0, 300) ?? '',
    visible: style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0,
  };
});
console.log(state);

Use a selector that belongs to the actual page, such as a product heading or app shell. If the expected content is missing in the DOM, debug navigation, redirects, app initialization, or site behavior before focusing on screenshot encoding.

3. Wait for application readiness, not just navigation

Choose a condition tied to the content you need. A page-specific readiness marker is often the clearest option. If the site has no marker, wait for a visible content element and verify its state or text.

await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('[data-page-ready="true"]', { timeout: 15000 });
await page.screenshot({ path: 'page.png' });

The selector is illustrative; replace it with a marker the target application actually exposes. For a visible heading, for example:

await page.waitForFunction(() => {
  const heading = document.querySelector('main h1');
  return heading && heading.textContent.trim().length > 0
    && heading.getBoundingClientRect().height > 0;
}, { timeout: 15000 });
await page.screenshot({ path: 'page.png' });

You can also start with Puppeteer’s documented example condition:

await page.goto(url, { waitUntil: 'networkidle2' });
await page.screenshot({ path: 'page.png' });

Use networkidle2 as a first attempt, not a guarantee. Some pages keep requests active, while a client-rendered app may become network-quiet before its useful content appears. Prefer the condition that reflects the page’s actual readiness.

4. Follow the evidence through the capture

  1. Response is missing or unexpected: verify the requested URL, redirects, response status, and final page.url().
  2. Response looks fine but content is absent: check the page title and DOM. Wait on the app’s content or readiness marker; inspect whether scripts or app initialization completed.
  3. DOM has content but the image is blank: confirm you screenshot the same Page you navigated, check the viewport and emulation settings, and make sure the target is not inside a frame. Confirm the output path or buffer is the one being opened.
  4. Only one environment or mode fails: compare headless settings and mobile emulation while holding the URL, Puppeteer version, browser revision, and other settings constant.
  5. Only a selected-element capture fails: verify the selector matches a visible element. Puppeteer supports capturing a selected element; a missing or zero-size target is a different issue from a blank full-page capture.

These are diagnostic branches, not a claim that any one is the universal cause. Puppeteer’s API captures the current page state, so the DOM and page configuration at capture time matter.

5. Check headless, mobile, and version differences

A historical Puppeteer issue from 2017–2018 includes reports of blank screenshots with headless or mobile settings and a maintainer suggestion that a site might detect headless browsing. Treat this as an investigation lead, not evidence of a current general Puppeteer defect. Compare the same URL under controlled settings and avoid treating slowMo or an arbitrary sleep as a durable fix just because it appeared to help once.

Record the installed Puppeteer package version and the browser revision used in the failing environment. Documentation version numbers do not tell you what your project has installed. When comparing machines or CI with a local run, make versions and launch settings explicit.

6. Runnable diagnostic script

This CommonJS example logs the navigation response and page state, waits for a meaningful element, and writes a screenshot. Install Puppeteer in your project, replace the URL and selector with ones for your page, and run it with Node.js.

const puppeteer = require('puppeteer');

(async () => {
  const url = 'https://example.com';
  const readySelector = 'main h1';
  const browser = await puppeteer.launch({ headless: true });

  try {
    const page = await browser.newPage();
    await page.setViewport({ width: 1365, height: 900 });

    const response = await page.goto(url, {
      waitUntil: 'domcontentloaded',
      timeout: 30000,
    });

    console.log({
      requestedUrl: url,
      status: response?.status() ?? null,
      finalUrl: page.url(),
      title: await page.title(),
    });

    await page.waitForSelector(readySelector, {
      visible: true,
      timeout: 15000,
    });

    const details = await page.$eval(readySelector, el => ({
      text: el.textContent.trim(),
      width: el.getBoundingClientRect().width,
      height: el.getBoundingClientRect().height,
    }));
    console.log('Ready element:', details);

    await page.screenshot({ path: 'page.png', fullPage: true });
    console.log('Wrote page.png');
  } catch (error) {
    console.error('Capture diagnosis failed:', error);
    process.exitCode = 1;
  } finally {
    await browser.close();
  }
})();

If your app uses a different readiness signal, replace main h1 with it. If the navigation response is null, use the final URL and page state to understand the special navigation case rather than assuming an HTTP response exists.

7. Common errors and fixes

Symptom Likely explanation What to do
page.goto() resolves, but the screenshot is an error page A valid HTTP error response such as 404 or 500 can still produce a fulfilled navigation. Log response.status(), final URL, and title; handle non-success status explicitly in your script.
Response status is null Some navigation cases, including about:blank or a same-URL hash change, have no main-resource response. Check page.url() and the DOM state; do not dereference the response unconditionally.
Wait for networkidle2 times out or content is still blank Network activity may not match application readiness; some pages keep requests active or render after network quiet. Use a selector or application-specific predicate and verify the expected content.
Selector wait times out The selector may not exist on that route, may be in a frame, or the app may not have rendered. Check the final URL and DOM, verify the selector on the target route, and inspect the correct frame if applicable.
DOM shows content but saved image appears empty The capture may use another page, viewport, selector, or output file. Log the page object context, inspect viewport and target visibility, and verify the output file or buffer.
Only mobile emulation or headless mode differs Could be a site-specific behavior or environment difference; historical reports are not proof of a current general bug. Change one setting at a time and record package and browser versions.

8. Performance, reliability, and cost

Waiting only for DOM content can reduce unnecessary waiting, but capturing before the app is ready produces an unusable image. A selector tied to the desired content makes the wait meaningful; choose a timeout that fits the page and report a clear diagnostic on timeout. Broad network-idle waits can add latency or fail on sites with continuing requests. Reusing a readiness condition and logging status, final URL, and selector state makes repeated jobs easier to diagnose.

For local Puppeteer, cost and runtime depend on the browser process and your own execution environment; this guide has no benchmark or universal cost figure. In a capture pipeline, consider the operational cost of retries and saved failures: distinguish navigation errors, unexpected statuses, readiness timeouts, and successful captures in logs rather than retrying every blank image identically.

Or skip the browser setup

ScreenshotNeo is a website screenshot API and MCP server. One GET request returns an image or PDF, and its options include full-page capture, selector capture, waits, custom viewport and device settings, and more. See the 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}`);
  • Cookie and consent banners, newsletter popups, and chat widgets are removed before the shot; each cleanup step can be turned off.
  • Bot checks, blank pages, failed loads, timeouts, and cache hits are never billed; response headers identify the page verdict and billing status.
  • An MCP server gives AI agents tools to take screenshots, get page information, and capture PDFs.
  • The free plan includes 1,000 screenshots per month with no card. Paid plans start at $5 for 3,000 screenshots.

Sign up for 1,000 free screenshots a month, no card required.

FAQ

Does a resolved page.goto() mean the page loaded successfully?

It means the selected navigation wait condition completed. Check the response status, final URL, and page content to confirm the intended app loaded.

Is networkidle2 the best wait condition?

It is an official guide example and a useful starting point. A page-specific selector or readiness predicate is more informative when the app exposes one.

Should I add a fixed delay?

Use a delay only when the page has a known timing requirement. A content-based readiness condition is generally more reliable than guessing a sleep duration.

Could a site detect headless browsing?

It is one possible investigation branch, mentioned in a historical issue report. Compare controlled configurations before attributing a blank capture to it.