ScreenshotNeo

BlogHow-to

Puppeteer screenshot hangs on networkidle: causes and fixes

Learn why Puppeteer’s networkidle wait can time out and how to capture a page when background requests never stop.

By the ScreenshotNeo team4 October 20268 min read

If a Puppeteer screenshot appears to hang on networkidle, the wait is usually happening before the screenshot call. networkidle0 and networkidle2 wait for network activity to stay below a connection threshold; they do not guarantee that the page looks complete, and they are not required for page.screenshot(). A page that polls, streams data, or keeps requests open may never reach the threshold before the wait times out.

For a screenshot, wait for the visual state you need: often domcontentloaded followed by a meaningful selector or application-ready condition. Use a network-idle wait only when quiet network activity is actually relevant to the capture.

1. Find which Puppeteer call is waiting

Navigation, a separate network-idle wait, a selector wait, and screenshot encoding are separate operations. Add logs around each awaited call to locate the pending promise. The symptom alone does not establish which operation is stuck.

console.log('starting navigation');
const response = await page.goto(url, {
  waitUntil: 'networkidle0',
  timeout: 30_000,
});
console.log('navigation finished', response?.status());

console.log('starting screenshot');
await page.screenshot({ path: 'page.png' });
console.log('screenshot finished');

If “starting navigation” appears but “navigation finished” does not, inspect the navigation condition and requests. If navigation finishes but the screenshot-start message does not appear, inspect any intervening waits. If screenshot-start appears without completion, investigate the screenshot operation and page state separately.

2. What networkidle means

In the Puppeteer 25.12.0 API reference, the lifecycle events mean:

Wait condition What it establishes Common limitation
domcontentloaded The document has been parsed and the DOMContentLoaded event fired. Images, fonts, scripts, and application rendering may still be in progress.
load The page load event fired after its load-dependent resources completed. It does not establish that client-side content or later visual updates are ready.
networkidle0 No more than zero active network connections for at least 500 ms. Persistent requests can prevent the condition from occurring.
networkidle2 No more than two active network connections for at least 500 ms. It can still wait indefinitely in practice if activity remains above its threshold.
Selector or app-ready wait The specific selector or condition your code checks is ready. A wrong selector or an app state that never arrives causes a timeout.

These are different signals. Network idle says something about observed request activity during an interval, not whether the page is visually complete. A selector can be a better match for a screenshot, provided it represents the content you need. See Puppeteer’s lifecycle event reference, Page.goto(), and Page.screenshot().

3. Common causes of a networkidle wait that never completes

  • Polling or periodic refresh: an application may repeatedly request updates, leaving activity too frequent for a sustained idle interval.
  • Long-lived requests: streaming responses or connections intentionally kept open may count as ongoing activity.
  • Late or repeated resource fetching: client-side rendering, lazy-loaded content, or background work may continue after the initial document loads.
  • A slow or unresponsive endpoint: a request that does not finish can keep the active count elevated until a timeout or another failure occurs.
  • Request interception left unresolved: when interception is enabled, each request must be continued, answered, aborted, or completed from cache. A handler path that does not resolve a request can stall page activity.
  • The wrong wait is being diagnosed: a selector wait or another promise after navigation may be the one that never resolves.

These are explanations consistent with the documented request-activity threshold and event model; they are not diagnoses of a particular site without inspecting its requests and code.

Use a finite navigation timeout, then wait for a selector that corresponds to visible content on the target site. The selector below is illustrative: it is not built into Puppeteer and must be replaced with a real, meaningful selector.

import puppeteer from 'puppeteer';

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

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

  if (!response) {
    throw new Error('Navigation did not return a main-resource response');
  }
  if (!response.ok()) {
    throw new Error(`Navigation returned HTTP ${response.status()}`);
  }

  // Replace this with a selector that signals the content you need.
  await page.waitForSelector('[data-page-ready]', { timeout: 10_000 });
  await page.screenshot({ path: 'page.png', fullPage: true });
} finally {
  await browser.close();
}

If the DOM is sufficient, remove the selector wait. If the page’s required content appears only after a particular app state, wait for that state instead. If the screenshot needs lazy-loaded images or completed animations, handle those elements explicitly for the target page; there is no universal selector or visual-readiness condition.

Choosing a wait condition

  1. Start with domcontentloaded when the document can become usable before all resources and background work stop.
  2. Use load if the image needs resources covered by the page load event.
  3. Use networkidle2 when a short network-quiet period is helpful and up to two active connections are acceptable.
  4. Use networkidle0 only when having no active connections is important and the page can reach that state.
  5. After navigation, wait for the target selector or app condition if that best represents the required visual state.

Navigation accepts lifecycle conditions through Page.goto(). The screenshot API is a separate call: Page.screenshot().

5. Diagnose active requests

Register listeners before navigation to include early activity. This logs request starts and terminal events:

page.on('request', request => {
  console.log('request', request.method(), request.resourceType(), request.url());
});
page.on('requestfinished', request => {
  console.log('finished', request.url());
});
page.on('requestfailed', request => {
  console.log('failed', request.url(), request.failure());
});

await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });

Look for repeating endpoints, requests that remain open, and unexpected application refreshes. A failed request event is not a complete list of unsuccessful HTTP responses: a 404 or 503 response still completes as requestfinished. Check response status separately if HTTP errors matter. Puppeteer documents this request lifecycle in its PageEvent reference.

6. Audit request interception

If your code calls page.setRequestInterception(true), inspect every request handler branch. Each intercepted request needs exactly one resolution action, such as continue(), abort(), or respond(), unless it has already been handled or completed from cache.

await page.setRequestInterception(true);
page.on('request', request => {
  if (request.isInterceptResolutionHandled()) return;

  if (request.url().includes('/unneeded-analytics')) {
    void request.abort();
    return;
  }

  void request.continue();
});

Adapt resolution handling to your Puppeteer version and any other handlers that may resolve requests. Do not enable interception just to make network idle pass: filtering changes page behavior and can remove scripts, fonts, images, or API responses required by the screenshot. See Page.setRequestInterception().

7. Configure timeouts without hiding the cause

Puppeteer’s current WaitForOptions reference documents a 30,000 ms default timeout; timeout: 0 disables the timeout. The default can also be changed with page timeout methods such as page.setDefaultNavigationTimeout() and page.setDefaultTimeout(). See WaitForOptions.

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

await page.goto(url, {
  waitUntil: 'domcontentloaded',
  timeout: 30_000,
});

Use explicit, finite limits appropriate to your environment. Increasing a timeout can help a genuinely slow page, but cannot make persistent network activity become idle. Setting the timeout to zero can turn a useful error into an indefinite wait.

8. Troubleshooting checklist

Symptom Likely cause Fix
goto() times out with networkidle0 More than zero requests remain active or recur before the idle interval. Try domcontentloaded or load, then wait for required visual content. Use networkidle2 only if its tolerance matches the page.
waitForNetworkIdle() times out Its threshold is not reached; the API defaults to concurrency 0 and idle time 500 ms. Set a suitable concurrency and idle time, or replace this wait with a meaningful selector/app-state wait.
Navigation completes, but capture still stalls A later selector, function, or other promise may be pending. Log before and after every awaited step and identify the exact pending promise.
A selector wait times out The selector is absent, wrong for this page, or appears later than the limit. Inspect the rendered DOM, choose a real readiness selector, and set a finite timeout suited to the page.
Requests remain pending after interception is enabled A branch did not resolve an intercepted request, or multiple handlers conflict. Ensure each request is resolved once; simplify handlers and check for other listeners.
Request logs show 404 or 503 responses but no requestfailed HTTP error responses can still finish normally at the request-event level. Inspect each response’s status; do not use requestfailed as the only error check.
Screenshot is missing images or final visual changes The chosen wait proved document or network state, not the visual state required. Wait for relevant images, selectors, or app state; handle lazy loading and animation as the site requires.
Disabling the timeout makes the script appear stuck The wait condition may be unreachable. Restore a finite timeout and change the readiness condition based on request or selector diagnostics.

9. Performance, reliability, and cost

Waiting for network idle can add latency because it requires a quiet interval, and a page with persistent background traffic may spend the whole timeout waiting. A condition tied to the content needed in the image can avoid waiting on unrelated requests, while still requiring careful selection of the readiness signal. Network activity, page rendering, and screenshot capture are separate concerns, so log and bound each step.

For reliability, keep timeouts finite, report which stage failed, inspect response statuses where relevant, and close the browser in a finally block. If requests are intercepted, resolve each one deliberately. Puppeteer’s documented defaults and event behavior can vary with installed version; check your project’s Puppeteer version and its compatible browser rather than assuming every environment matches the current 25.12.0 documentation.

Self-hosted Puppeteer costs depend on your browser runtime and infrastructure; the research sources provide no benchmark or fixed cost. Account for the time and resources consumed by slow pages and retries. If operating a browser is unnecessary for your use case, ScreenshotNeo offers an API with a free tier and fixed plan allowances (details below).

10. Or skip the browser setup

ScreenshotNeo takes a screenshot with one GET request. The ScreenshotNeo API documentation describes the options and API.

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 banners are accepted and removed before capture; more than 60 known consent platforms, newsletter popups, and chat widgets can be removed, and each step can be turned off.
  • Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed. Responses include X-Page-Verdict and X-Billed headers.
  • An MCP server lets AI agents use take_screenshot, get_page_info, and capture_pdf.
  • The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 screenshots. Every feature is on every plan.

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

Learn more about ScreenshotNeo.

11. FAQ

Does a screenshot require networkidle?

No. page.screenshot() is separate from the navigation wait. Capture after the readiness condition that matches the visual content you need.

Should I always replace networkidle0 with networkidle2?

No. networkidle2 allows up to two active connections during the idle interval, which may suit some pages. If the image depends on particular content, a selector or app-specific condition may be more relevant.

What does waitForNetworkIdle() default to?

The documented defaults are concurrency 0 and an idle time of 500 ms. It waits at least for the configured idle period. See Puppeteer’s Page.waitForNetworkIdle() and WaitForNetworkIdleOptions.

Can I just increase the timeout?

Only if the page is genuinely slow and the condition can eventually become true. A longer timeout does not fix continuous activity; use request logs to distinguish slowness from an unreachable idle condition.