ScreenshotNeo

BlogHow-to

Puppeteer Screenshot Blank After Navigation: Common Fixes

A blank Puppeteer screenshot usually means the page is still empty, content is in a frame, or the browser environment failed. Trace navigation and wait for the content you need.

By the ScreenshotNeo team4 October 202610 min read

A blank Puppeteer screenshot after navigation usually means Puppeteer captured the page state it had: navigation may have reached an error or unexpected URL, the application may not have rendered its content yet, or the content may live in a child frame. First inspect the navigation result, final URL, and expected page content. Then wait for the specific selector or state your task needs before calling page.screenshot().

A completed navigation wait is not proof that a client-rendered component, image, or embedded document is ready. Puppeteer documents Page.screenshot() for capture and demonstrates both navigation waits and selector waits. Pick a readiness condition that matches the target page instead of relying on a fixed delay or treating network idle as a universal signal.

1. Check what navigation actually reached

Before changing screenshot options, record the final URL, navigation response status when available, title, and a small piece of the content you expect. A non-error HTTP status does not prove the intended page rendered. Puppeteer’s Page API documents navigation and page inspection methods; its navigation guidance also describes checking status, URL, and page content and handling timeouts and network errors explicitly.

page.goto() can return null for about:blank or a same-document hash change. Do not treat null by itself as a failed request; examine page.url() and the resulting DOM.

2. Use a content-based readiness check

For a page that renders content asynchronously, wait for the element your screenshot needs. The selector must represent real readiness: an element that appears before its text, image, or data is populated may still produce an incomplete capture.

Complete runnable JavaScript example

This Node.js example uses Puppeteer’s bundled browser, checks navigation and expected content, and writes a PNG. Save as screenshot.js, install Puppeteer with npm install puppeteer, then run node screenshot.js https://example.com. Replace main with a selector that is expected on your target page.

const puppeteer = require('puppeteer');

async function main() {
  const url = process.argv[2] || 'https://example.com';
  const selector = 'h1';
  const browser = await puppeteer.launch({ headless: true });

  try {
    const page = await browser.newPage();
    page.setDefaultNavigationTimeout(30_000);
    page.setDefaultTimeout(15_000);
    page.on('pageerror', error => console.error('Page JavaScript error:', error.message));
    page.on('requestfailed', request => {
      console.error('Request failed:', request.url(), request.failure()?.errorText);
    });

    const response = await page.goto(url, { waitUntil: 'domcontentloaded' });
    console.log({
      requestedUrl: url,
      finalUrl: page.url(),
      status: response ? response.status() : null,
      title: await page.title(),
    });

    if (response && response.status() >= 400) {
      throw new Error(`Navigation returned HTTP ${response.status()}`);
    }

    await page.waitForSelector(selector, { visible: true });
    const text = await page.$eval(selector, element => element.textContent?.trim() || '');
    if (!text) throw new Error(`Expected content in ${selector}, but it was empty`);

    console.log('Expected content:', text.slice(0, 200));
    await page.screenshot({ path: 'page.png', fullPage: true });
    console.log('Saved page.png');
  } catch (error) {
    console.error('Capture failed:', error);
    process.exitCode = 1;
  } finally {
    await browser.close();
  }
}

main();

domcontentloaded plus a selector is one workable sequence, not a universal recipe. Choose the event and selector based on the page. If the page displays a loading placeholder first, wait for the placeholder to disappear or for the populated state you need. If the element exists but its text arrives later, use waitForFunction() to check that text or another application-specific condition.

3. Choose the wait condition that matches the page

Wait condition Use it when Limitation
domcontentloaded You need the initial HTML parsed, then will check the app’s content yourself. Client-side rendering and later requests may still be underway.
load The page depends on load-event resources, such as ordinary images and stylesheets. It does not mean every app widget or lazy-loaded item is ready.
networkidle2 in goto A short period with limited in-flight network activity is a useful initial signal. Network quiet is not semantic readiness; analytics or long-lived requests can complicate it.
waitForSelector() A specific element must appear or become visible. Check its content too if it can appear empty before population.
waitForFunction() Readiness is a state, such as non-empty text or a completed loading flag. Write the predicate to match the page and give it an intentional timeout.
waitForResponse() A known API response must arrive before the page is useful. A successful response alone does not guarantee the UI consumed and rendered it.
waitForNetworkIdle() Network quiet itself matters after an action or navigation. It waits for network idleness, at least for the configured idle time; it cannot prove a component is populated.

Puppeteer’s waitForNetworkIdle() specifically describes network activity, not application meaning. Follow it with a content check when content correctness matters. An arbitrary setTimeout can be useful as a one-off diagnostic to see whether timing is involved, but it can mask a race and behave differently across sites or environments. Prefer a selector, function, or specific response condition.

Wait for populated text

await page.waitForFunction(
  selector => {
    const element = document.querySelector(selector);
    return Boolean(element && element.textContent?.trim());
  },
  { timeout: 15_000 },
  '#article-body'
);

Wait for a specific response, then inspect the page

const dataResponsePromise = page.waitForResponse(
  response => response.url().includes('/api/article') && response.status() === 200,
  { timeout: 15_000 }
);
await page.goto(url, { waitUntil: 'domcontentloaded' });
await dataResponsePromise;
await page.waitForSelector('#article-body', { visible: true });

4. Check whether the content is inside an iframe

A selector lookup on the main page does not search inside every child frame. Enumerate frames and inspect their URLs, then wait for the expected selector in the frame that owns the content. Puppeteer exposes frame enumeration and frame waiting through its Page API.

for (const frame of page.frames()) {
  console.log('Frame URL:', frame.url());
}

const targetFrame = page.frames().find(frame => frame.url().includes('embedded-content'));
if (!targetFrame) throw new Error('Expected content frame was not found');

await targetFrame.waitForSelector('.report-ready', { visible: true, timeout: 15_000 });
const frameText = await targetFrame.$eval('.report-ready', el => el.textContent?.trim() || '');
if (!frameText) throw new Error('Frame content is empty');

Use a reliable frame identifier from the page, such as a stable URL fragment or the iframe’s identifying attributes. If the iframe is created after the main navigation, wait for the frame to appear before searching it. A visible empty rectangle can be an embedded document that has not loaded or has failed independently of the top-level page.

5. Check images, lazy content, and element screenshots

For a full-page image, content may be present in the DOM while image resources are still loading or lazy images have not been requested because their region has not entered view. Check the image’s complete and naturalWidth properties or scroll relevant content into view before capture. Avoid assuming that the page’s initial load event includes every below-the-fold resource.

await page.waitForFunction(() => {
  const images = [...document.images];
  return images.length > 0 && images.every(image => image.complete);
}, { timeout: 15_000 });

const imageState = await page.evaluate(() =>
  [...document.images].map(image => ({
    src: image.currentSrc || image.src,
    complete: image.complete,
    naturalWidth: image.naturalWidth,
  }))
);
console.log(imageState);

A broken image can be complete with a zero natural width, so examine both fields. For lazy-loaded content, scroll the relevant element into view or use the page’s own readiness signal before waiting on images. The Puppeteer screenshot guide notes that ElementHandle.screenshot() attempts to scroll a hidden element into view by default. That helps with scroll position, but it does not mean the element’s data or assets have loaded.

6. If the DOM has content but the screenshot is blank

If your selector and text checks pass but the saved image looks empty, the issue is more likely in capture configuration, rendering, or output handling than in navigation. Narrow it down with these checks:

  • Capture a simple visible element as well as the full page and compare the output.
  • Log the viewport and any device emulation settings; verify the target is not outside the captured viewport or clipped by options.
  • Inspect the screenshot options, especially clip, fullPage, and any transparent background handling.
  • Check whether the visible content is rendered into a canvas, WebGL surface, or another mechanism that behaves differently in your runtime.
  • Confirm the output path, file size, and that the file being viewed is from the current run.

These are follow-up branches, not one universal cause. Puppeteer’s ScreenshotOptions documents capture settings; compare the produced image with what the page DOM and viewport show.

7. Diagnose browser installation and runtime issues

If the problem happens only in CI, a container, or a cloud runtime, inspect browser launch output and installation state before changing page waits. Puppeteer’s troubleshooting guide says package managers that block install scripts can skip the browser download; its manual installation command is:

npx puppeteer browsers install

The same guide notes that the default Node.js runtime on Google Cloud Run lacks system packages needed for Headless Chrome, so the deployment needs a Dockerfile that supplies those dependencies. Compare local and deployed Node.js, Puppeteer, browser, fonts, and system packages. Capture the browser launch error itself. Do not reflexively add --no-sandbox or disable browser protections without diagnosing the launch error and deployment constraints.

8. Troubleshooting by symptom

Symptom Likely explanation Next action
goto() throws a timeout Navigation did not meet the selected lifecycle condition in time, or the network is stalled. Log the URL and exception; try an earlier lifecycle event and wait separately for the required selector. Check failed requests and the target’s availability.
Response is 4xx or 5xx The server returned an error page or rejected the request. Log status and final URL, then inspect page text and requests. Fix the URL, access, or server response before capturing.
Status looks successful but screenshot is an error page HTTP status and rendered content are separate evidence; an intermediate or browser error state may be visible. Check page.url(), title, and expected text. A chrome-error:// URL can be a Chrome-specific clue, not a universal detector.
Selector timeout Wrong selector, content rendered later, element hidden, or content lives in a frame. Inspect the DOM, validate the selector, wait for visibility or populated state, and enumerate frames.
Selector exists but text is empty The component shell appeared before its data. Wait for non-empty text or the application’s ready state rather than only element existence.
Some images are missing Lazy loading, failed requests, or image loading is still underway. Scroll the relevant region into view, inspect request failures and image dimensions, then capture.
Works locally, blank or fails in CI/container Browser download, runtime dependencies, fonts, or launch configuration differ. Read launch errors, install Puppeteer’s expected browser, and provide system dependencies for the runtime.
DOM checks pass but image file looks blank Capture bounds/options, viewport, canvas behavior, or stale/wrong output file may be involved. Compare element and page captures, inspect options and viewport, and verify the output file and run timestamp.

9. Performance, reliability, and cost

Browser startup and page loading can dominate the work of a screenshot. In a service that captures multiple pages, reusing a browser process can avoid repeated launches, but create and close pages carefully and isolate state such as cookies and storage between jobs. Always close the browser in a finally block so failures do not leave processes behind.

Use the earliest navigation lifecycle event that is safe for your use case, then wait for a precise content signal. Waiting for network idle on pages with analytics, polling, or persistent connections can add delay or never provide the useful readiness guarantee you need. Set explicit navigation and selector timeouts, log the final URL and page errors, and preserve a failure screenshot or diagnostic HTML when appropriate for your workflow.

For reliability, treat navigation status, page content, and screenshot output as separate checks. Retry only errors that may be transient, and bound retries and total capture time. Puppeteer and browser versions should be deployed together according to Puppeteer’s supported browser setup; a version or system-library mismatch can make local and hosted behavior differ. The dossier provides no universal runtime cost or timing benchmark, so measure launch and capture time in your own environment.

Or skip the browser setup

If your goal is a screenshot rather than managing a headless browser, ScreenshotNeo is a website screenshot API and MCP server. Its one-request API can return an image or PDF; see the ScreenshotNeo API documentation.

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}`);
const image = new Uint8Array(await res.arrayBuffer());
await import('node:fs/promises').then(fs => fs.writeFile('shot.webp', image));

ScreenshotNeo accepts cookie and consent banners and removes more than 60 known consent platforms, newsletter popups, and chat widgets before capture; each step can be turned off. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, 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 a 200 response guarantee the page is ready?

No. It tells you about the response status, not whether the required component rendered. Check the final URL and page content.

Should I always use networkidle2?

No. Use it when network quiet is relevant, but follow it with a content check if the screenshot depends on specific page state.

Why does the screenshot work on my machine but not in deployment?

Compare the browser download, system dependencies, runtime, fonts, and launch errors. Puppeteer’s troubleshooting guide covers browser installation and environment requirements.

Can an empty screenshot still be an accurate capture?

Yes. A capture can faithfully show an empty, incomplete, or browser-error page. Confirm the page state before treating the image itself as the cause.