A Playwright script ends in more ways than await browser.close(): it throws, someone calls process.exit(), a container runtime sends SIGTERM, the OOM killer sends SIGKILL. I wanted to know what each of these leaves behind on a Linux host: running Chromium processes, temp directories, or a Node process that ignores the signal.
I ran all of it on 2026-11-03: Linux 6.8, Node v24.22.2, Playwright 1.63.0 with the bundled chrome-headless-shell (revision 1243). Everything below is that one machine and one browser build. Containers, systemd, macOS and Windows are not covered.
The test setup
The child script launches a browser, opens a page, prints READY, and then either waits for a GO line on stdin and ends in a chosen way, or hangs so that the harness can send it a signal. The cdp mode does not launch anything: it attaches to a Chromium that the harness started separately, which is what many browser pools do.
// child.cjs <launch|cdp> <close|exit|throw|hang> [port] const { chromium } = require('playwright'); const [how, action, port] = process.argv.slice(2); (async () => { const browser = how === 'launch' ? await chromium.launch() : await chromium.connectOverCDP(`http://127.0.0.1:${port}`); await (await browser.newContext()).newPage(); console.log('READY'); if (action === 'hang') { setInterval(() => {}, 1000); return; } await new Promise(r => process.stdin.once('data', r)); // wait for GO from the harness process.stdin.destroy(); if (action === 'close') await browser.close(); else if (action === 'exit') process.exit(0); else if (action === 'throw') throw new Error('boom'); })(); The harness reads the process tree from /proc while the child is running, ends the child, waits three seconds, and then checks which of those PIDs are still alive (zombies do not count) and whether the browser's profile directory is still on disk. It kills and deletes only what it started.
const { spawn } = require('child_process'); const fs = require('fs'); const { chromium } = require('playwright'); const sleep = ms => new Promise(r => setTimeout(r, ms)); function descendants(root) { // every process below root, from /proc const kids = {}; for (const d of fs.readdirSync('/proc').filter(x => /^\d+$/.test(x))) { try { const st = fs.readFileSync(`/proc/${d}/stat`, 'utf8'); (kids[st.slice(st.lastIndexOf(')') + 2).split(' ')[1]] ||= []).push(+d); } catch {} } const out = [], stack = [root]; while (stack.length) { const p = stack.pop(); for (const k of kids[p] || []) { out.push(k); stack.push(k); } } return out; } const alive = pid => { // exists and is not a zombie try { const s = fs.readFileSync(`/proc/${pid}/stat`, 'utf8'); return s.slice(s.lastIndexOf(')') + 2)[0] !== 'Z'; } catch { return false; } }; const profileOf = pids => { // --user-data-dir of the pids we started for (const pid of pids) { try { const m = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').match(/--user-data-dir=([^\0\s]+)/); if (m) return m[1]; } catch {} } }; const cases = [ ['launch', 'close'], ['launch', 'exit'], ['launch', 'throw'], ['launch', 'SIGTERM'], ['launch', 'SIGINT'], ['launch', 'SIGKILL'], ['cdp', 'close'], ['cdp', 'SIGKILL'], ]; (async () => { let port = 9400; for (const [how, action] of cases) { port++; let chrome = null; if (how === 'cdp') { // Chromium started by us, Playwright only attaches chrome = spawn(chromium.executablePath(), [ '--headless=new', '--no-sandbox', `--remote-debugging-port=${port}`, `--user-data-dir=/tmp/probe-profile-${port}`, 'about:blank', ], { stdio: 'ignore', detached: true }); await sleep(2000); } const isSignal = action.startsWith('SIG'); const child = spawn('node', ['child.cjs', how, isSignal ? 'hang' : action, String(port)], { stdio: ['pipe', 'pipe', 'ignore'] }); let exited = null, ready = false; child.on('exit', (c, s) => { exited = `code=${c} sig=${s}`; }); child.stdout.on('data', d => { if (String(d).includes('READY')) ready = true; }); while (!ready) await sleep(50); await sleep(500); const procs = how === 'launch' ? descendants(child.pid) : [chrome.pid, ...descendants(chrome.pid)]; const profile = profileOf(procs); if (isSignal) child.kill(action); else child.stdin.write('GO\n'); const t0 = Date.now(); while (!exited && Date.now() - t0 < 8000) await sleep(50); await sleep(3000); const left = procs.filter(alive); console.log( `${how.padEnd(6)} ${action.padEnd(8)} node: ${String(exited || 'still running').padEnd(20)}` + ` chromium procs: ${String(procs.length).padStart(2)} -> ${String(left.length).padStart(2)}` + ` profile dir left: ${fs.existsSync(profile)}`); for (const pid of left) { try { process.kill(pid, 'SIGKILL'); } catch {} } // clean up only what we started try { child.kill('SIGKILL'); } catch {} if (profile) fs.rmSync(profile, { recursive: true, force: true }); await sleep(300); } })(); Result
launch close node: code=0 sig=null chromium procs: 6 -> 0 profile dir left: false launch exit node: code=0 sig=null chromium procs: 6 -> 0 profile dir left: false launch throw node: code=1 sig=null chromium procs: 6 -> 0 profile dir left: false launch SIGTERM node: still running chromium procs: 6 -> 0 profile dir left: false launch SIGINT node: code=130 sig=null chromium procs: 6 -> 0 profile dir left: false launch SIGKILL node: code=null sig=SIGKILL chromium procs: 6 -> 0 profile dir left: true cdp close node: code=0 sig=null chromium procs: 14 -> 10 profile dir left: true cdp SIGKILL node: code=null sig=SIGKILL chromium procs: 14 -> 10 profile dir left: true In the cdp rows the first count was 14 or 15 and the second 10 or 11 across my runs, so read that as "most of the tree stays". The other rows were identical on every run. In the cdp rows the profile directory belongs to the browser the harness started itself, and it is still there because that browser is still running.
Three things in that table surprised me.
Playwright's own launch cleans up after a crash, even after SIGKILL. throw, exit and SIGKILL all end with zero Chromium processes. In a one-off check (script not included) I printed the command line of the launched browser, and it ends in --remote-debugging-pipe. I did not trace the mechanism, but it fits a browser that exits when the other end of its pipe closes. The cdp rows are the contrast: that browser listens on a port, and nothing ties it to the script. In the same one-off check, chromium.launchServer() also left no processes after SIGKILL, so it is not only launch().
Browsers you did not launch are not cleaned up at all. In the cdp rows, about ten Chromium processes stayed alive after browser.close() and after SIGKILL. This is the case where a leak actually happens. The docs for browser.close() say that for a browser you connected to, it "clears all created contexts belonging to this browser and disconnects from the browser server". It does not stop the browser. Whoever started it owns its lifetime. If you run a pool this way, you need a supervisor that restarts or kills the browser itself, and a periodic check that counts processes.
SIGKILL leaves the profile directory. The launch SIGKILL row says profile dir left: true. Each one was about 224 KB in my runs, which is nothing for a single crash and something for a worker that gets OOM-killed a few times a day on a small /tmp. A normal close(), an exception, process.exit(), SIGTERM and SIGINT all removed it. I did not look into which Playwright process does the deleting. Whatever it is, SIGKILL gives it no chance to run.
The SIGTERM row
launch SIGTERM says node: still running. The child had a setInterval standing in for an HTTP server, and after SIGTERM the browser was gone but Node kept going. The reason is the handleSIGTERM launch option, which defaults to true and is documented as "Close the browser process on SIGTERM". Playwright installs a handler that closes the browser. Node's documentation says that once a SIGTERM or SIGINT listener is installed, the default behaviour (exit with code 128 plus the signal number) is removed. So the server stays up with no browser behind it. SIGINT ended with exit code 130 in my run; I did not look into why it differs from SIGTERM.
I did not test what Docker or Kubernetes do from here. The consequence I can state from this measurement is only that a service which does nothing on SIGTERM keeps running with a dead browser, and anything that waits for it to exit has to fall back to SIGKILL.
A small fix for both problems
This is the service skeleton I used to test two remedies: close the browser and exit on SIGTERM and SIGINT, and sweep profile directories that nothing is using when the service starts.
// service.cjs: closes the browser and exits on SIGTERM, sweeps leaked profile dirs at startup const { chromium } = require('playwright'); const fs = require('fs'); const PREFIX = 'playwright_chromiumdev_profile-'; function sweepStaleProfiles(minAgeMs) { // a profile dir is stale when no running process names it and it is not brand new const inUse = new Set(); for (const pid of fs.readdirSync('/proc').filter(n => /^\d+$/.test(n))) { try { const m = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').match(/--user-data-dir=([^\0\s]+)/); if (m) inUse.add(m[1]); } catch {} // process vanished or is not readable } let removed = 0; for (const name of fs.readdirSync('/tmp').filter(n => n.startsWith(PREFIX))) { const full = '/tmp/' + name; if (inUse.has(full)) continue; if (Date.now() - fs.statSync(full).mtimeMs < minAgeMs) continue; fs.rmSync(full, { recursive: true, force: true }); removed++; } return removed; } (async () => { console.log('swept stale profiles:', sweepStaleProfiles(Number(process.env.SWEEP_MIN_AGE_MS ?? 60000))); const browser = await chromium.launch(); await browser.newPage(); let closing = false; const shutdown = async signal => { if (closing) return; closing = true; await browser.close(); // Playwright's own handler closes Chromium but does not end this process console.log('closed on', signal); process.exit(0); }; process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); console.log('READY'); setInterval(() => {}, 1000); // stands in for the HTTP server })(); The sweep treats a directory as stale only if no process on the machine has it in a --user-data-dir argument and it is older than a minute. Chromium rewrites its own argument list with spaces on Linux, which is why the pattern stops at whitespace and not only at the NUL byte. I found that when my first version of the harness reported "profile left: false" for a directory that was plainly still there.
To test it I used a second script that is not included: it starts the service, SIGKILLs it, starts it twice more, sends SIGTERM to both, and counts new profile directories under /tmp each time. The sweep deletes everything under that prefix that no process references, so the test script lowered the age limit to five seconds and waited six. Results:
service> swept stale profiles: 0 service> READY new profile dirs on disk after SIGKILL: 1 service> swept stale profiles: 1 service> READY service> swept stale profiles: 0 service> READY new profile dirs with two live services: 2 service> closed on SIGTERM second service: exit code 0 after 167 ms service> closed on SIGTERM first service: exit code 0 | new profile dirs left: 0 After SIGKILL one profile directory stayed. The next start swept it (swept stale profiles: 1). Two live services each kept their own directory, so the sweep did not touch a running browser. Both services exited with code 0 on SIGTERM, in 106 to 281 ms across my runs, and no new directories were left.
Limits
- The sweep matches on the
playwright_chromiumdev_profile-prefix in/tmp. If other Playwright programs share the host, the "no process names it" test is what protects them, together with the age limit. A browser that is just starting could in principle be missed by both checks. Do not shorten the age limit in production. - If you pass your own
userDataDir,launch()is not involved and none of this applies; you own that directory. - I did not measure memory growth, only whether processes and directories survive. A browser that stays alive and slowly grows is a different problem.
- For the
cdpcase there is no fix in Playwright. A process manager that starts the browser and owns its lifetime is the fix, and I did not test one.
Sources: Playwright BrowserType launch options for handleSIGTERM and handleSIGINT, the browser.close() text from the Playwright 1.63.0 type definitions, and Node.js process signal events.
Written with AI assistance. Every code sample was run on 2026-11-03 with Playwright 1.63.0 (chrome-headless-shell revision 1243) and Node v24.22.2; the output above is from that run.
