How to Check Whether a Puppeteer Browser Process Has Closed
Use the browser child process exit event to confirm Chrome has exited. Learn how it differs from Puppeteer disconnects, with runnable patterns and fixes.
For a browser started with puppeteer.launch(), call browser.process() and listen for the returned Node.js child process’s exit event. That event tells you the operating system process ended. Puppeteer’s disconnected event and browser.connected property report connection state; they do not prove the browser process exited.
browser.process() returns null for a browser attached with puppeteer.connect(), because the attaching client does not own its launcher process handle. In that case, observe connection loss from the client or monitor the process where it was originally launched. See the [Puppeteer Browser API](https://pptr.dev/api/puppeteer.browser.process) and [browser management guide](https://pptr.dev/guides/browser-management).
1. Check for actual process exit
Install the listener immediately after launch, before doing page work. This catches an early crash and reports the exit code and signal. A normal exit commonly has a numeric code and no signal; termination by signal commonly has a signal and a null code. Treat both as process termination.
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch();
const child = browser.process();
if (!child) {
throw new Error('No child process: this browser was not launched by this Puppeteer instance');
}
child.once('exit', (code, signal) => {
console.log('Browser process exited', { code, signal });
});
// Continue normal work.
const page = await browser.newPage();
await page.goto('https://example.com');
await browser.close();
Save the example as check-browser.mjs, install Puppeteer with npm install puppeteer, then run node check-browser.mjs. If your project uses CommonJS, replace the import with const puppeteer = require('puppeteer'); and run it from a CommonJS file.
Reusable helper that handles an already-exited child
Node emits an exit event only when listeners are present at the time the event occurs. If your code receives a child process later, check its exitCode and signalCode before waiting. Registering first and then checking state also avoids the gap where an exit happens around setup.
function waitForProcessExit(child) {
if (!child) {
return Promise.reject(new Error('Browser has no local child process handle'));
}
if (child.exitCode !== null || child.signalCode !== null) {
return Promise.resolve({ code: child.exitCode, signal: child.signalCode });
}
return new Promise(resolve => {
const onExit = (code, signal) => {
cleanup();
resolve({ code, signal });
};
const onError = error => {
cleanup();
// An error such as a spawn failure is not itself proof of a later exit.
// Reject so the caller can handle the launch/process error distinctly.
reject(error);
};
const cleanup = () => {
child.off('exit', onExit);
child.off('error', onError);
};
child.once('exit', onExit);
child.once('error', onError);
// Close the narrow race if exit occurred before listeners were attached.
if (child.exitCode !== null || child.signalCode !== null) {
cleanup();
resolve({ code: child.exitCode, signal: child.signalCode });
}
});
}
const browser = await puppeteer.launch();
const exited = waitForProcessExit(browser.process());
// ...perform work, or request a graceful shutdown:
await browser.close();
console.log('Exit result:', await exited);
The helper intentionally distinguishes process errors from the exit event. If you simplify it for code that always attaches immediately after a successful launch, the basic one-time listener is usually enough.
2. Know which lifecycle signal answers your question
| Signal or method | What it tells you | What it does not establish |
|---|---|---|
child.on('exit') from browser.process() |
The launched browser’s operating-system child process exited. | Why it exited; inspect exit code, signal, and logs. |
browser.on('disconnected') |
Puppeteer lost its connection to that browser. | That the browser process exited; it can remain alive after a disconnect. |
browser.connected |
Whether this Puppeteer Browser object is currently connected. | Whether an external browser process still exists. |
page.isClosed() |
Whether one page is closed. | Whether the browser or its other pages are closed. |
browser.close() |
Requests Puppeteer to close the browser and its associated pages. | By itself, a separately observed OS-level exit event. |
browser.disconnect() |
Detaches Puppeteer from the browser. | Browser shutdown. The browser stays running. |
Puppeteer’s documentation explicitly distinguishes disconnect from close: disconnect leaves the browser process running, while close() closes the browser and associated pages. If you need proof the process has terminated after a close request, await the process exit signal separately. Sources: [disconnect API](https://pptr.dev/api/puppeteer.browser.disconnect), [close API](https://pptr.dev/api/puppeteer.browser.close).
3. Handle close requests and browser events
To request shutdown and also observe when the process is gone, begin listening before calling close(). browser.close() resolves when Puppeteer completes its browser close operation; the child process’s exit event is the process-level observation.
const browser = await puppeteer.launch();
const child = browser.process();
if (!child) throw new Error('Expected a locally launched browser');
const processExited = new Promise(resolve => {
if (child.exitCode !== null || child.signalCode !== null) {
resolve({ code: child.exitCode, signal: child.signalCode });
} else {
child.once('exit', (code, signal) => resolve({ code, signal }));
}
});
browser.once('disconnected', () => {
console.log('Puppeteer connection ended; process status is checked separately.');
});
await browser.close();
const result = await processExited;
console.log('OS process exit observed:', result);
Use the disconnected event for cleanup tied to Puppeteer’s connection, such as discarding a stale Browser object. Use the process event for resource accounting or supervision that depends on the child process actually ending. Do not call disconnect() when your intention is to shut Chrome down.
4. Launched browser versus connected browser
Launched by this Puppeteer instance
browser.process() returns the associated Node.js ChildProcess. Monitor its exit event as shown above. If the result is unexpectedly null, first verify that the Browser came from puppeteer.launch() in this process rather than from puppeteer.connect().
Attached using puppeteer.connect()
The connected Browser object’s process() method returns null. The client can detect loss of its protocol connection, but it cannot infer from that alone that the external browser process exited. To answer the OS process question, arrange monitoring in the service, container, or script that launched the browser. The API documents the null return for connected browsers in the [Browser.process reference](https://pptr.dev/api/puppeteer.browser.process).
const browser = await puppeteer.connect({
browserWSEndpoint: process.env.BROWSER_WS_ENDPOINT,
});
browser.once('disconnected', () => {
console.log('This Puppeteer client disconnected from the browser');
});
console.log('Local process handle:', browser.process()); // null for connect()
// Detach only. The remote browser is not shut down.
await browser.disconnect();
5. Process wrapper and version considerations
Puppeteer also documents a browser Process wrapper with a hasClosed() method and a nodeProcess property. That wrapper is distinct from the public Browser.process() return value, which is a Node.js ChildProcess. Use the public Browser API when you already have a Browser object, and check the API reference for your installed Puppeteer version before depending on wrapper internals or version-specific launch hooks. See the [Puppeteer Process API](https://pptr.dev/browsers-api/browsers.process).
Launch options also describe signal handling for SIGHUP, SIGINT, and SIGTERM, and an AbortSignal option that closes the browser when aborted. Some preview documentation includes an onExit option; confirm it exists in the version pinned by your project before using it. A child-process exit listener remains a clear way to observe the actual launched process.
6. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
browser.process() is null |
The Browser was attached using puppeteer.connect(). |
Monitor the external launcher process, or use disconnected only if connection loss is the condition you need. |
disconnected fired, but Chrome is still present |
The Puppeteer connection ended while the browser kept running, for example after browser.disconnect(). |
Check the launcher’s process handle. Call browser.close() when shutdown is intended. |
| No exit callback appears | The browser has not exited, or listener registration happened after the event. | Register immediately after launch. If attaching later, inspect exitCode/signalCode as well as listening. |
page.isClosed() is false after another page closes |
Page state is being confused with browser state. | Check the specific page with isClosed(); check the browser process using its child process. |
| The process exits with a nonzero code or signal | The browser crashed, was killed, or exited abnormally. | Record both callback arguments and examine stderr/browser logs and the surrounding shutdown path. |
| Closing one Browser object does not stop a remote browser | The object represents a connection, or the client detached rather than owning the launcher. | Have the owning launcher perform shutdown; remote connection state and process ownership are separate. |
7. Reliability, performance, and operational cost
- Listener timing: Attach the child listener directly after launch. A process may terminate before later application work starts.
- Use one owner: In worker pools or services, keep the ChildProcess handle with the component responsible for launching and shutting down that browser. Other clients can report connection status but may not own the process.
- Make cleanup idempotent: A crash, a requested close, and application shutdown can race. Guard shutdown logic so multiple paths do not each attempt conflicting cleanup.
- Log the distinction: Record separate events for Puppeteer disconnect and OS process exit, including exit code and signal. This makes orphaned browsers easier to identify.
- Avoid polling: An event listener has low overhead and avoids repeated status checks. Polling the connection state cannot reliably replace observing the process exit.
- Plan for forced termination: A close request may not be the final evidence your supervisor needs. Use a bounded shutdown wait in production and let the process supervisor handle a stuck child according to your service policy.
8. Or skip the browser setup
If your job is to capture a website, ScreenshotNeo provides a single HTTP request instead of having your service launch and supervise a Puppeteer browser. It is a website screenshot API and MCP server by [ScreenshotNeo](https://screenshotneo.com). See the [API documentation](https://screenshotneo.com/docs/) for options.
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,
)
open("shot.webp", "wb").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(fs => fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer())));
- Cookie banners, newsletter popups, and chat widgets are removed before the shot; each cleanup step can be turned off.
- Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed. Response headers identify the page verdict and billing status.
- An MCP server gives AI agents tools to take screenshots, get page information, and capture PDFs.
- 1,000 screenshots a month are free with no card. Paid plans start at $5 for 3,000 screenshots; every feature is on every plan.
Create a free ScreenshotNeo account to get 1,000 screenshots a month with no card.
FAQ
Does Puppeteer’s disconnected event mean Chrome exited?
No. It means Puppeteer lost its connection. The browser may still be running, especially if it was intentionally detached.
Can I tell whether a page closed with the browser process API?
Use page.isClosed() for a page. Use the child process exit event for the launched browser process; they represent different lifecycle scopes.
What should I monitor when my application owns Chrome?
Keep the ChildProcess returned by browser.process() and observe its exit event. Also handle the Puppeteer disconnect event if your application needs connection cleanup.
Can an attached Puppeteer client confirm remote process exit?
Not from its Browser object alone. It gets no local process handle through browser.process(); the process owner must report or monitor exit.


