ScreenshotNeo

BlogHow-to

How to Make Headless Chrome Exit After a Timeout

Learn why headless Chrome keeps running after a timeout and how to cancel work, close Puppeteer, and clean up processes reliably.

By the ScreenshotNeo team1 October 20265 min read

Use a whole-task deadline, cancel the browser operation, and always await browser cleanup. A timeout on navigation or a selector wait usually rejects one promise; it does not automatically terminate the Chrome process. In Puppeteer, combine an AbortSignal for the job deadline with browser.close() in a finally block.

What timed out?

Chrome automation has several independent timeout layers. Identify the layer before changing code.

Timeout What it limits What happens at expiry
Launch timeout How long Puppeteer waits for Chrome to start Launch rejects; it is not a session-wide deadline
Navigation or wait timeout page.goto(), selector waits, and similar operations That operation rejects; Chrome can remain open
Application deadline The complete job, including all pages and processing Your code must abort or close the browser
Process cleanup The browser and its child processes Graceful close or forced termination, depending on the driver and host

Puppeteer’s launch timeout defaults to 30,000 ms, and 0 disables that launch timeout. Wait APIs also default to 30,000 ms, with 0 disabling the individual wait timeout. These settings are separate. See the LaunchOptions documentation and WaitForOptions documentation.

Reliable Puppeteer pattern

This example gives the entire job a 30-second deadline, passes the signal to launch, and closes the browser on every exit path.

const puppeteer = require('puppeteer');

const url = 'https://example.com';
const controller = new AbortController();
const deadline = setTimeout(() => controller.abort(), 30_000);
let browser;

try {
  browser = await puppeteer.launch({
    signal: controller.signal,
    timeout: 30_000
  });

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

  // Perform the rest of the task here.
  console.log(await page.title());
} catch (error) {
  if (controller.signal.aborted) {
    console.error('The browser job exceeded its deadline.');
  } else {
    console.error('Browser task failed:', error);
  }
} finally {
  clearTimeout(deadline);
  if (browser) {
    await browser.close();
  }
}

The launch timeout only bounds startup. The timer and abort signal provide the whole-job deadline. Puppeteer’s launcher supports an abort signal and aborts the browser process; the exact process behavior is implementation and platform dependent. Keep the final browser.close() because it handles normal completion and failures after launch. The browser management guide documents graceful closure.

ES modules version

import puppeteer from 'puppeteer';

const controller = new AbortController();
const deadline = setTimeout(() => controller.abort(), 30_000);
let browser;

try {
  browser = await puppeteer.launch({ signal: controller.signal });
  const page = await browser.newPage();
  await page.goto('https://example.com', { timeout: 20_000 });
  // Continue the task.
} finally {
  clearTimeout(deadline);
  if (browser) await browser.close();
}

Do not rely on Promise.race alone

Promise.race() stops your code from waiting, but it does not stop the underlying Chrome operation. If you use a race, abort and close the browser in the timeout branch.

const timeout = new Promise((_, reject) => {
  setTimeout(() => reject(new Error('job deadline exceeded')), 30_000);
});

try {
  await Promise.race([runBrowserTask(), timeout]);
} catch (error) {
  // This catch only handles your JavaScript promise.
  // The browser task still needs an AbortController and close path.
}

A controller-based deadline is usually easier to audit because cancellation is explicit and can be passed to APIs that support signals.

Set operation-specific timeouts

Use shorter limits for individual operations so a failed page does not consume the whole job budget.

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

await page.waitForSelector('#report', {
  timeout: 5_000
});

Choose waitUntil according to the page: domcontentloaded returns earlier, while networkidle2 waits for a quieter network. A page with analytics, long polling, or WebSockets may never become truly idle, so an explicit selector wait plus a bounded timeout is often more predictable.

Cleanup checklist

  1. Create one deadline for the complete job.
  2. Pass its signal to launch or other APIs that support cancellation.
  3. Give navigation and selector waits their own limits.
  4. Keep the browser reference outside the try block.
  5. Call and await browser.close() in finally.
  6. Clear the deadline timer after the job settles.
  7. Log which timeout fired so operations can distinguish startup, navigation, and job failures.

If Chrome still remains

1. Confirm the timeout source

Check whether the error came from launch, navigation, a selector wait, the Chrome DevTools Protocol, or your outer job timer. Each has a different fix.

2. Verify that cleanup is reached

Ensure every return and error path passes through finally, and await the close promise. Calling browser.close() without awaiting it can leave cleanup unfinished when the process exits.

3. Inspect containers and PID 1

In Docker, process reaping affects orphaned and zombie Chrome processes. Puppeteer’s troubleshooting guide specifically recommends checking an init process such as dumb-init when zombie processes appear.

4. Check serverless CPU behavior

On Google Cloud Run, CPU can be disabled after an HTTP response. If browser work continues in the background, it may appear frozen or extremely slow. Complete the browser task before responding, or configure CPU allocation for background work as described in Puppeteer’s troubleshooting guidance.

5. Avoid copying Puppeteer APIs to another driver

Playwright, Selenium, direct Chrome CLI usage, and external process managers have different cancellation and process-tree rules. Use the driver’s own shutdown API and process-management documentation; there is no stack-independent Chrome flag that guarantees exit after every timeout.

Performance, reliability, and cost considerations

  • Performance: A shorter navigation timeout returns failures sooner, but overly aggressive limits reject slow yet valid pages. Measure page behavior and leave enough budget for retries or cleanup.
  • Reliability: Bound every wait, keep one outer deadline, and make cleanup idempotent. Treat an abort as a failed job and record the URL and timeout layer.
  • Process scope: Closing a Puppeteer browser normally shuts down its managed process tree. Forced termination may be needed only when the driver or host cannot complete graceful shutdown.
  • Cost: In hosted workers, a stuck browser consumes memory, CPU, and execution time. Enforcing a hard deadline prevents one page from occupying a worker indefinitely.

Or skip the browser setup

If your goal is a clean screenshot or PDF rather than browser-process control, ScreenshotNeo handles the browser session behind one request. Cookie and consent banners are accepted and removed before capture, along with 60+ known consent platforms, newsletter popups, and chat widgets. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and response headers identify the page verdict and billing status. Its MCP server lets Claude, Cursor, and other MCP clients call take_screenshot, get_page_info, and capture_pdf.

See the ScreenshotNeo API documentation for all 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}`);

There are 1,000 screenshots per month free with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.

FAQ

Does Puppeteer’s launch timeout kill Chrome?

It limits how long Puppeteer waits for startup. For a whole-job deadline, add cancellation and cleanup.

Why does a navigation timeout leave Chrome running?

The navigation promise rejected, but the browser session is still alive. Close it explicitly in finally.

Should I set every timeout to zero?

No. Zero disables the relevant timeout and can allow a stuck operation to run indefinitely. Use finite limits unless you have another enforced deadline.

Can I force-kill Chrome from JavaScript?

Use the automation library’s supported abort and close mechanisms first. Direct process killing is platform and process-tree specific.

What should I log?

Record the timeout layer, URL, elapsed time, browser PID when available, and whether cleanup completed. This separates slow pages from broken shutdown paths.