ScreenshotNeo

BlogHow-to

How to Wait for Navigation in Puppeteer Before a Screenshot

Wait for Puppeteer navigation and page-specific readiness before capturing a screenshot. See runnable patterns for direct URLs, clicks, timeouts, and common failures.

By the ScreenshotNeo team4 October 20269 min read

For a direct URL, await page.goto() before calling page.screenshot(). For a click that triggers navigation, start page.waitForNavigation() before the click and await both together with Promise.all(). If the screenshot depends on content rendered after navigation, wait for a page-specific selector too; a lifecycle event alone does not guarantee that an application is ready.

await page.goto('https://example.com', {waitUntil: 'networkidle2'});
await page.screenshot({path: 'screenshot.png'});

This guide covers the wait conditions, complete runnable examples, timeouts, common failures, and when to wait for application content. See the official Puppeteer screenshot guide and Page.goto() reference.

1. Install Puppeteer and run a direct-navigation screenshot

The following Node.js script launches a browser, navigates to a URL, waits for the selected lifecycle condition, saves a screenshot, and closes the browser even if capture fails.

import puppeteer from 'puppeteer';

const url = process.argv[2] ?? 'https://example.com';
const browser = await puppeteer.launch({headless: true});

try {
  const page = await browser.newPage();
  await page.setViewport({width: 1365, height: 900});
  await page.goto(url, {
    waitUntil: 'networkidle2',
    timeout: 30_000,
  });
  await page.screenshot({path: 'screenshot.png', fullPage: true});
} finally {
  await browser.close();
}

Save it as screenshot.mjs, install Puppeteer with npm install puppeteer, then run node screenshot.mjs https://example.com. Puppeteer’s package installation and launch setup can vary by environment; consult the official installation guide if the browser executable is unavailable.

The navigation call resolves when its chosen condition is met, or rejects on navigation errors and timeout. The screenshot operation is also asynchronous, so await it before closing the browser. Page.screenshot() returns image bytes if no path is supplied; a path writes the image to disk.

2. Choose the right navigation wait condition

The waitUntil option accepts one lifecycle event or an array of events; with an array, all listed events must fire. The documented default is load. Puppeteer lifecycle events have different thresholds:

Condition What it waits for Use it when
domcontentloaded The document’s DOMContentLoaded event. The DOM is enough to proceed and later resources are not needed for the capture.
load The browser’s load event. This is the default. You need the page’s load lifecycle event before capture.
networkidle0 No more than zero network connections for at least 500 ms. The site becomes genuinely quiet and that stricter network condition fits the page.
networkidle2 No more than two network connections for at least 500 ms. A page has a small amount of continuing network activity but reaches an acceptable idle period.

Network-idle events are heuristics. They do not guarantee that a single-page application has fetched all screenshot-critical data, finished an animation, or painted a particular component. Persistent requests can also prevent an idle condition. Choose a condition that fits the target and then wait for the actual content if needed.

You can require more than one lifecycle event by passing an array, for example waitUntil: ['domcontentloaded', 'networkidle2']. This requires both conditions and may take longer; it still does not replace an application-specific readiness check.

3. Wait for navigation caused by a click

Register the navigation wait before performing the action. Starting the wait after the click creates a race: the page can navigate before Puppeteer begins waiting. The official Page class reference documents the Promise.all() pattern.

const [response] = await Promise.all([
  page.waitForNavigation({waitUntil: 'networkidle2', timeout: 30_000}),
  page.click('a.my-link'),
]);

await page.screenshot({path: 'after-navigation.png'});

The navigation response is optional for screenshot capture. Keep it if you need to inspect the main-resource response. It can be null in documented cases such as navigating to about:blank or a same-URL hash change, so do not assume it is always a response object. See Frame.waitForNavigation().

Here is a complete example that accepts a URL, navigates to it, clicks a link, and captures the resulting page. Replace the selector with one present on your starting page.

import puppeteer from 'puppeteer';

const url = process.argv[2] ?? 'https://example.com';
const browser = await puppeteer.launch({headless: true});

try {
  const page = await browser.newPage();
  await page.goto(url, {waitUntil: 'domcontentloaded', timeout: 30_000});

  const [response] = await Promise.all([
    page.waitForNavigation({waitUntil: 'networkidle2', timeout: 30_000}),
    page.click('a.my-link'),
  ]);

  console.log('Main resource status:', response?.status() ?? 'no response');
  await page.screenshot({path: 'after-navigation.png'});
} finally {
  await browser.close();
}

Some clicks update content without a document navigation. Puppeteer treats History API URL changes as navigation, but an in-page action that only changes application state may not trigger waitForNavigation(). In that case, wait for the new content or state that matters to the screenshot.

4. Wait for the content the screenshot needs

When a page renders a report, chart, or other component after navigation, wait for that component explicitly. waitForSelector() waits for a selector to appear in the DOM; visible: true also requires it to be visible. Choose a selector that represents the content being ready, not merely a generic page shell. See Page.waitForSelector().

await page.goto('https://example.com/report', {
  waitUntil: 'domcontentloaded',
  timeout: 30_000,
});

await page.waitForSelector('[data-testid="report-ready"]', {
  visible: true,
  timeout: 20_000,
});

await page.screenshot({path: 'report.png'});

For an action that navigates and then displays a specific component, combine both waits:

await Promise.all([
  page.waitForNavigation({waitUntil: 'domcontentloaded'}),
  page.click('a.my-link'),
]);

await page.waitForSelector('[data-testid="report-ready"]', {
  visible: true,
  timeout: 20_000,
});
await page.screenshot({path: 'report.png'});

Puppeteer also recommends locators for interactions that need element-state preconditions. Locators wait for conditions such as visibility, enabled state, viewport presence, and a stable bounding box for actions such as clicking. See the page interactions guide. Avoid substituting an arbitrary fixed sleep for a meaningful lifecycle or content condition: a sleep can be too short on a slow page and waste time on a fast one.

5. Configure timeouts and handle navigation responses

The documented default for WaitForOptions is 30,000 ms. You can set a timeout per wait, configure page-level defaults with Puppeteer’s timeout-setting methods, or use 0 to disable that wait’s timeout. Disabling a timeout can leave an automation job waiting indefinitely if the expected event never occurs, so bounded waits are usually easier to operate.

page.setDefaultNavigationTimeout(45_000);
page.setDefaultTimeout(20_000);

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

Use a per-call timeout when one route is known to need more time, rather than increasing every wait without reason. Handle timeouts at the job boundary so the browser can still close and the caller receives a useful failure:

try {
  await page.goto(url, {waitUntil: 'networkidle2', timeout: 45_000});
  await page.screenshot({path: 'screenshot.png'});
} catch (error) {
  console.error(`Navigation or capture failed for ${url}:`, error);
  throw error;
}

A successful navigation wait means its condition was met; it does not by itself confirm the HTTP status is successful. If status matters, inspect the returned response when present. Special navigations can return null.

6. cURL, Python, and Node.js alternatives

cURL and Python do not control Puppeteer directly. Puppeteer is a Node.js browser-automation library. If your caller uses another language, it can invoke the Node.js script as a process or use a browser service your team operates. These examples make an HTTP screenshot request with ScreenshotNeo instead of running a local Puppeteer browser.

cURL

curl -G "https://api.screenshotneo.com/v1/shot" \
  -d access_key=YOUR_API_KEY \
  --data-urlencode url=https://stripe.com \
  -o shot.webp

Python

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()
with open("shot.webp", "wb") as f:
    f.write(r.content)

Node.js

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(({writeFile}) => writeFile('shot.webp', Buffer.from(await res.arrayBuffer())));

For Puppeteer API options and ScreenshotNeo request options, see the ScreenshotNeo documentation.

7. Or skip the browser setup

With ScreenshotNeo, one GET request takes a screenshot of a URL and returns PNG, JPEG, WebP, or PDF. Its API can handle screenshot work without installing or running a Puppeteer browser in your application.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

ScreenshotNeo accepts cookie and consent banners like a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets before capture; each step can be turned off. Bot checks and CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing, and each response reports the page verdict and billing status in headers. Its MCP server provides take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients. The free plan includes 1,000 shots per month with no card; paid plans start at $5 for 3,000 shots.

Create a free ScreenshotNeo account to get 1,000 screenshots a month with no card.

8. Common problems and fixes

Symptom Likely cause Fix
The screenshot shows the old page after a click. The navigation wait began after the click, or the click was not paired with the wait. Start waitForNavigation() before the click and await both with Promise.all().
Navigation timeout with networkidle0 or networkidle2. The page keeps network connections open or makes background requests. Use a lifecycle event such as domcontentloaded or load, then wait for a page-specific selector.
The screenshot is blank or missing data even though navigation completed. The document lifecycle finished before the application rendered the target content. Wait for a visible, meaningful selector or state before capture.
waitForNavigation() times out after clicking a control. The control updates state without causing a navigation. Wait for the resulting content or state instead of a navigation event.
The returned navigation response is null. The navigation was a special case such as about:blank or same-URL hash navigation. Make response handling nullable; only inspect status when a response exists.
waitForSelector() times out. The selector is wrong, the element never appears, or it is not visible when visibility was required. Verify the selector and the page state, and select a condition that corresponds to actual readiness.
Puppeteer cannot launch the browser. The environment may lack a compatible browser installation or required system setup. Follow the Puppeteer installation guide and check the browser executable and environment configuration.

9. Performance, reliability, and cost

Waiting for network idle adds at least the documented 500 ms idle window after the connection threshold is met, and pages with persistent requests may never reach it. A DOM lifecycle event followed by a precise selector can avoid waiting for unrelated background activity, but the right choice depends on what the screenshot must contain. No universal fastest or most reliable condition applies to every site.

For repeatable automation, set explicit timeouts, close the browser in a finally block, and record which wait or selector failed. Use application-specific readiness markers when available. A screenshot workflow can incur compute and browser costs in the environment where it runs; the research sources provide no benchmark or cost figure for running Puppeteer, so measure your own routes and workload. If you prefer a per-shot API, ScreenshotNeo has a free tier and the published paid plans listed above.

10. FAQ

Does page.goto() wait before returning?

Yes. It waits for the configured waitUntil condition, which defaults to load, unless navigation fails or times out.

Can I pass multiple waitUntil events?

Yes. Pass an array; the navigation wait completes after all listed events have fired.

Does networkidle2 mean the page is fully rendered?

No. It describes a network-connection threshold held for a period, not application data readiness or animation completion.

Should I use a fixed delay before every screenshot?

Usually not. Prefer a lifecycle condition and, when needed, a selector or state that represents the content the capture needs.

Where can I check version-sensitive behavior?

Use the current official Puppeteer API references linked in this guide; API documentation is versioned and can change.