ScreenshotNeo

BlogHow-to

How to Fix Puppeteer’s “Navigation Failed Because Browser Has Disconnected” Error

Find why Puppeteer lost its browser connection, collect the right evidence, and fix lifecycle, environment, and navigation-wait failures.

By the ScreenshotNeo team1 October 20268 min read

Short answer: Puppeteer shows Navigation failed because browser has disconnected! when its connection to the browser ends while navigation is being observed. The browser may have closed, crashed, or been deliberately detached with browser.disconnect(); the message does not identify which one happened. Instrument the browser and page, audit every cleanup path, verify browser and runtime compatibility, and then isolate navigation-wait races.

Puppeteer documents the disconnected event as occurring when the browser closes or crashes, or when Browser.disconnect() is called. See the BrowserEvent API. Treat this as a lifecycle and connection failure first, rather than assuming that networkidle0, a particular launch flag, or the target website is the cause.

1. Capture evidence before changing the code

Run a minimal diagnostic that records the Node.js process, page console output, browser disconnection, and browser-process output. Puppeteer’s debugging guide recommends examining all three layers: your Node.js code, the page, and the browser.

const puppeteer = require('puppeteer');

(async () => {
  const target = process.argv[2] || 'https://example.com';
  const started = new Date().toISOString();
  let browser;

  process.on('uncaughtException', (error) => {
    console.error('[node uncaughtException]', error);
  });
  process.on('unhandledRejection', (reason) => {
    console.error('[node unhandledRejection]', reason);
  });

  try {
    browser = await puppeteer.launch({
      headless: true,
      dumpio: true
    });

    browser.on('disconnected', () => {
      console.error('[browser disconnected]', {
        at: new Date().toISOString(),
        target,
        started
      });
    });

    const page = await browser.newPage();
    page.on('console', message => {
      console.log(`[page console:${message.type()}] ${message.text()}`);
    });
    page.on('pageerror', error => {
      console.error('[page error]', error);
    });
    page.on('requestfailed', request => {
      console.error('[request failed]', request.url(), request.failure());
    });

    console.log('[goto start]', target);
    const response = await page.goto(target, {
      waitUntil: 'domcontentloaded',
      timeout: 30000
    });
    console.log('[goto complete]', {
      status: response && response.status(),
      url: page.url()
    });
  } catch (error) {
    console.error('[navigation failure]', error);
    process.exitCode = 1;
  } finally {
    if (browser && browser.connected) {
      await browser.close();
    }
  }
})();

Keep the timestamp, URL, request or job ID, Puppeteer version, Node.js version, browser version, operating system, container or serverless runtime, launch arguments, and custom executable path with the log. Debug output can contain sensitive data, so redact cookies, authorization headers, page contents, and private URLs before sharing it.

2. Understand the browser lifecycle

browser.close() versus browser.disconnect()

browser.close() shuts down the browser process. browser.disconnect() detaches Puppeteer while leaving the browser and its pages running. The distinction is documented in Puppeteer’s browser-management guide. Search the whole application for both calls, including signal handlers, job timeouts, worker shutdown hooks, and finally blocks.

A common race looks like this:

const navigation = page.goto(url);
await cleanup();              // cleanup closes the browser
await navigation;             // rejects after disconnect

Move cleanup after all page work has settled. If several jobs share one browser, do not let one request close the shared instance while another request is navigating. Prefer an ownership rule: the code that launches a browser owns its shutdown, and request handlers only close their own pages.

Signals and timeouts

Check SIGTERM, SIGINT, orchestration timeouts, test teardown, and serverless callbacks. A process manager can terminate Node.js while Chromium is still doing work. Log when a signal handler starts and whether it calls close(). Give in-flight navigation a bounded timeout, then close once, in one place.

3. Verify browser and deployment assumptions

Record these values for both successful and failed runs:

  • Puppeteer package version and lockfile revision.
  • Node.js version and operating system.
  • Browser version and the resolved executable path.
  • Container base image, CI runner, or serverless runtime.
  • CPU, memory, process, and temporary-storage limits.
  • Concurrency, profile-directory settings, and launch arguments.

Puppeteer’s launch-options documentation says support is guaranteed with its bundled browser; using a custom executablePath is at your own risk. A mismatch is therefore a compatibility hypothesis to verify with versions and logs, not a conclusion supplied by the error itself.

In CI or containers, compare a failing run with a successful local run. Check whether the browser process exits, whether the runtime kills it for resource limits, whether the profile or temporary directory is writable, and whether a supervisor sends a termination signal. Do not copy flags such as --single-process or --no-sandbox from an issue thread without evidence that your environment requires them; historical reports do not establish universal fixes.

4. Separate navigation waits from browser disconnects

waitUntil: 'networkidle0' means that there are no more than zero active network connections for at least 500 ms; networkidle2 allows up to two. These are completion conditions, not browser-crash remedies. A page with analytics, polling, or streaming requests may never become idle.

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

// If the application has a reliable readiness marker:
await page.waitForSelector('#app-ready', { timeout: 15000 });

Choose the condition that represents readiness for your page, then investigate a disconnect independently. Avoid a second waitForNavigation() unless an action really triggers another navigation. Puppeteer’s Page.waitForNavigation documentation warns that ordering an action and a separate wait incorrectly can create a race.

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

Use this pattern only when the click is expected to navigate. For a single goto(), do not add an unrelated navigation waiter.

5. Reduce the failure to a minimal reproduction

  1. Launch the same browser and create one page.
  2. Navigate to the same URL, or call setContent() with the smallest HTML that still fails.
  3. Use the same waitUntil and timeout.
  4. Enable dumpio and the event listeners above.
  5. Add authentication, request interception, extra pages, PDF generation, and concurrency one at a time.

If the minimal case works, the failure is in an added lifecycle path, resource limit, or page operation. If it fails only with a custom executable, test Puppeteer’s bundled browser. If it fails only under load, lower concurrency and compare process and memory data.

6. A robust navigation wrapper

const puppeteer = require('puppeteer');

async function capture(url) {
  const browser = await puppeteer.launch({ headless: true });
  let disconnected = false;
  browser.on('disconnected', () => { disconnected = true; });

  try {
    const page = await browser.newPage();
    await page.goto(url, {
      waitUntil: 'domcontentloaded',
      timeout: 30000
    });
    await page.screenshot({ path: 'page.png', fullPage: true });
    return { url: page.url(), disconnected };
  } finally {
    // Only the owner of this browser closes it.
    if (browser.connected) await browser.close();
  }
}

capture(process.argv[2] || 'https://example.com')
  .then(console.log)
  .catch(error => {
    console.error(error);
    process.exitCode = 1;
  });

This wrapper gives each operation a clear browser owner, a bounded navigation, and one cleanup location. For a long-lived service, launch one browser at service startup, create and close pages per job, and close the browser only during service shutdown.

7. Troubleshooting checklist

Symptom Likely area What to check
disconnected fires before navigation rejects Browser lifecycle Browser crash, external termination, browser.close(), or browser.disconnect(); inspect timestamps and browser logs.
Only CI or containers fail Deployment Memory and CPU limits, writable temporary/profile paths, signals, executable path, and browser/OS compatibility.
Only concurrent jobs fail Ownership or resources Whether one job closes a shared browser, and whether reducing concurrency changes process exits.
Only pages with long-lived requests fail or hang Wait condition Replace an unsuitable networkidle0 with a readiness selector or domcontentloaded; still investigate any disconnect event.
Failure follows a click Navigation race Pair the click and waitForNavigation() in Promise.all(), and confirm that the click actually navigates.
Failure follows setContent() Content and waits Reduce external resources, check the chosen wait condition, and remove unrelated navigation waiters.
No useful browser output Observability Run a controlled diagnostic with dumpio: true, page console listeners, and process-level exception logging.

8. Performance, reliability, and cost considerations

  • Performance: Reuse a browser for multiple jobs when startup time matters, but isolate jobs in separate pages and enforce ownership. Too much concurrency can increase memory pressure; measure process exits and limits before changing flags.
  • Reliability: Use explicit timeouts, a readiness selector where possible, and one shutdown owner. Record browser disconnects as structured events with a job ID and URL.
  • Retries: Retry only after recording whether the browser closed, crashed, or was detached. A retry cannot repair a deterministic cleanup race or an incompatible executable.
  • Security: Treat browser logs, console output, cookies, authorization headers, and URLs as sensitive. Redact them before sending diagnostics to a third party.
  • Cost: Self-hosted Puppeteer costs depend on your compute, concurrency, storage, and operational limits. Measure those resources in the environment where the error occurs.

Or skip the browser setup

If your goal is a reliable screenshot rather than maintaining Chromium, ScreenshotNeo provides a GET endpoint that returns PNG, JPEG, WebP, or PDF. Its capture pipeline accepts consent banners like a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets before the shot; each step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and the response identifies the result with X-Page-Verdict and X-Billed headers. See the ScreenshotNeo API docs.

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}`);

ScreenshotNeo also offers an MCP server with take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients. It supports full-page and element captures, device presets or custom viewports, dark mode, retina scale, custom CSS and JavaScript, waits, request blocking, headers, cookies, user agents, authorization, timezone, geolocation, transparent backgrounds, resizing, caching, signed links, asynchronous jobs, bulk capture, usage data, and PDF controls. Every feature is on every plan. 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 and get 1,000 screenshots a month with no card.

FAQ

Does networkidle0 cause this error?

It can expose a wait problem, but it does not explain a browser disconnect. It only defines a network-idle completion condition; inspect lifecycle events and browser logs separately.

Should I always use --no-sandbox?

No. The error text does not justify that flag. Reproduce the failure and verify the container’s browser and sandbox requirements first.

How do I know whether Puppeteer or Chromium failed?

The disconnected event confirms that Puppeteer lost the connection. Browser-process output, process-exit data, versions, and lifecycle logs are needed to distinguish a crash, close, or deliberate detach.

Can I reconnect after browser.disconnect()?

Puppeteer can detach without stopping the browser, but your code must deliberately manage the remaining browser process and reconnect strategy. Do not use it as a substitute for orderly request cleanup.

What information belongs in a bug report?

Include a minimal reproduction, exact Puppeteer and Node.js versions, browser version and executable path, operating system or runtime, launch options, target operation, timestamps, and redacted Node, page, and browser logs.