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.
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
- Create one deadline for the complete job.
- Pass its signal to launch or other APIs that support cancellation.
- Give navigation and selector waits their own limits.
- Keep the browser reference outside the
tryblock. - Call and await
browser.close()infinally. - Clear the deadline timer after the job settles.
- 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.


