PRACTICAL GUIDE / debug Selenium JavaScript open handles
Your Selenium tests passed, so why is Node still running?
Find the timer, socket, server, or unfinished WebDriver teardown keeping Node alive, then fix ownership without hiding leaks behind forced exits.
In this guide6 sections
What you will learn
- What keeps the process alive after assertions finish
- Capture evidence without killing the process
- Work through three owners that commonly leak
- Make teardown survive failures and partial setup
The last assertion prints as passed, but the CI job keeps running until its outer timeout kills it. Locally, someone presses Ctrl+C and calls the suite flaky. The browser test is finished, yet the Node process still owns something that can keep the event loop active.
What keeps the process alive after assertions finish
Test completion and process completion are separate events. A runner can report every assertion as green while Node still has a referenced timer, a listening server, an open socket, a child process, or another active resource. The runner has no safe reason to tear down resources it did not create, so it waits for the process to become idle.
A pending Promise is not, by itself, an open handle. JavaScript can hold an unresolved Promise in memory without keeping Node alive. The work behind that Promise matters. A network operation may own a socket. A repeating callback owns a timer. A locally started driver service may involve a child process and pipes. Diagnosing only at the Promise level misses the resource that the event loop can see.
Selenium adds a clear lifecycle boundary. WebDriver.quit() sends the command that terminates the browser session and returns a Promise that resolves when the command completes. The JavaScript API says the driver is invalid after quit(). Calling the method without awaiting its Promise starts cleanup but lets the test continue before cleanup has settled.
WebDriver.close() has a narrower purpose. It closes the current window. It is useful when a test intentionally opens and closes tabs, but it is not the suite's session teardown contract. Depending on window state and remote-end behavior, closing a window may leave other browsing contexts or client resources alive. Use quit() for the session created by the test fixture.
An abandoned remote session and a local open handle are related but not identical. A Grid session can remain alive on the server after the Node client has exited. Conversely, a test-owned HTTP server can keep Node alive after the Selenium session has been deleted correctly. Check both sides, but do not infer one from the other.
The timing gives the first useful classification. If the test runner has not printed its final report, a test, hook, or reporter may still be executing. If the report is complete and no shell prompt returns, look for process-level resources. If the outer CI platform says the command completed but a Selenium node still shows a session, investigate server-side session cleanup instead of a Node hang.
The most damaging shortcut is an unconditional process.exit(0) or runner force-exit flag. It changes a lifecycle bug into a green job. Required writes can be cut short, browser sessions can be orphaned, and the next run inherits exhausted Grid capacity. Keep an outer timeout so CI cannot wait forever, but make timeout expiration a failure with diagnostics attached.
Capture evidence without killing the process
Modern Node exposes process.getActiveResourcesInfo(). It returns an array of resource type names that are currently keeping the event loop alive. It is a supported public API on recent Node releases and has existed since Node 16.14. The method does not return object identities, allocation stacks, test names, or Selenium session IDs. Treat it as a direction finder, not a verdict.
Take a baseline before the test creates its browser and another snapshot after teardown has completed. Comparing counts helps remove runner-owned noise such as standard streams. Resource names vary by Node version, operating system, transport, and runner. A strict assertion that every machine must expose the same list will create a new class of CI failure.
The helper below counts resource types and prints only positive deltas. It is intentionally observational. A Timeout delta tells you to inspect timers, but it does not declare which interval leaked. A network-related type tells you to inspect sockets and servers, but the exact type name is not a portable test contract.
import { getActiveResourcesInfo } from 'node:process';
export type ResourceSnapshot = ReadonlyMap<string, number>;
export function snapshotResources(): ResourceSnapshot {
const counts = new Map<string, number>();
for (const type of getActiveResourcesInfo()) {
counts.set(type, (counts.get(type) ?? 0) + 1);
}
return counts;
}
export function reportPositiveDeltas(
before: ResourceSnapshot,
after: ResourceSnapshot,
): void {
const types = new Set([...before.keys(), ...after.keys()]);
for (const type of [...types].sort()) {
const delta = (after.get(type) ?? 0) - (before.get(type) ?? 0);
if (delta > 0) {
console.error(`active resource delta: ${type} +${delta}`);
}
}
}Capture the snapshot inside the same worker that owns the browser. A parent process cannot reliably assign a worker's resources to a test, and merged console output destroys chronology. Include the worker ID, test name, and Selenium session ID in adjacent lifecycle logs. Do not put credentials or complete remote URLs in those records.
Add four timestamps around session ownership: build requested, build resolved, quit requested, and quit settled. A missing quit-requested record points to control flow. A quit request with no settled record points to a command or transport that has not completed. Both records present while Node remains alive points away from ordinary driver teardown and toward another resource.
Log rejection paths too. If quit() rejects and the hook swallows the error, the suite may look green while resources remain. Print the exception category and fail the teardown. Do not automatically call quit() in an endless retry loop. A deleted session cannot be restored, and repeated commands can obscure the first transport failure.
Node's resource list is only one layer. A process listing can reveal a test-owned application server or browser driver child process. Grid status can show sessions still registered remotely. Runner diagnostics can show a hook that never returned. Put those records on one timeline. A single screenshot from the browser says nothing about why the Node event loop is active.
If a leak appears only after several files, run the smallest subset that still hangs and change the execution order. A leak that follows one file suggests missing ownership in that file. A leak that appears only when two files overlap suggests shared global state, parallel teardown, or a singleton service whose reference count is wrong. Preserve concurrency while reducing the set, because serial execution can hide the race.
Avoid undocumented internals as the permanent solution. Some tools inspect private active-handle structures to provide allocation details, which can be useful during an incident. Their output and completeness can change across Node releases. Keep production CI based on explicit resource ownership and public APIs; use deeper instrumentation as a temporary diagnostic with its version recorded.
Work through three owners that commonly leak
The first owner is the WebDriver fixture. A familiar test builds the driver, performs assertions, then calls quit() at the bottom. When navigation or an assertion throws, JavaScript jumps past that final line. The test fails, but the browser session and its client connections remain. On a remote Grid, the server may eventually expire the session, but the local process should not rely on that policy.
Register teardown immediately after successful construction. Node's test runner executes a test context's after hook even when the test body fails. The hook awaits quit() and reports resource deltas after cleanup. One zero-delay turn reduces noise from callbacks already queued by completed cleanup, and its timer has fired before the snapshot. It does not prove that every unrelated resource has settled.
import assert from 'node:assert/strict';
import { setTimeout as delay } from 'node:timers/promises';
import test, { type TestContext } from 'node:test';
import { Builder, Browser, By, type WebDriver } from 'selenium-webdriver';
import { reportPositiveDeltas, snapshotResources } from './resource-diagnostics.js';
async function withChrome(
t: TestContext,
run: (driver: WebDriver) => Promise<void>,
): Promise<void> {
const baseline = snapshotResources();
const driver = await new Builder().forBrowser(Browser.CHROME).build();
t.after(async () => {
let quitFailure: unknown;
try {
await driver.quit();
} catch (error: unknown) {
quitFailure = error;
console.error('WebDriver quit failed', error);
}
await delay(0);
reportPositiveDeltas(baseline, snapshotResources());
if (quitFailure) throw quitFailure;
});
await run(driver);
}
test('checkout confirms the submitted order', async (t) => {
await withChrome(t, async (driver) => {
await driver.get(process.env.APP_URL ?? 'http://127.0.0.1:3000/checkout');
await driver.findElement(By.css('[data-testid="place-order"]')).click();
const confirmation = await driver.findElement(By.css('[role="status"]')).getText();
assert.match(confirmation, /order confirmed/i);
});
});That fixture owns one driver per test. It is unsuitable for a suite that deliberately shares a session, because the first test would terminate the browser for every later test. Shared sessions need suite-level ownership, explicit reset behavior, and a guarantee that tests are not concurrent. The lower startup cost comes with stronger coupling and a larger failure blast radius.
The second owner is a polling helper. Teams often add a setInterval that checks Grid health, refreshes a token, or samples browser memory. Node timers are referenced by default, so a live interval can keep the event loop active. An event listener attached to a plain in-memory emitter does not independently keep Node alive, but a listener attached to a still-open socket accompanies a resource that can.
Give the helper a stop method and make stopping idempotent. The following class tracks the actual timer returned by setInterval; cleanup clears that timer, removes the reference, and waits for samples already in flight. A lifecycle test can detect the defect if stop() stops clearing the timer or stops awaiting active work, so the behavior is not a fixed-fixture oracle.
export class GridHealthProbe {
#timer: ReturnType<typeof setInterval> | undefined;
#inFlight = new Set<Promise<void>>();
constructor(
private readonly sample: () => Promise<void>,
private readonly intervalMs: number,
) {}
start(): void {
if (this.#timer) {
throw new Error('GridHealthProbe is already running');
}
this.#timer = setInterval(() => {
const task = this.sample()
.catch((error: unknown) => {
console.error('Grid health sample failed', error);
})
.finally(() => this.#inFlight.delete(task));
this.#inFlight.add(task);
}, this.intervalMs);
}
async stop(): Promise<void> {
if (this.#timer) {
clearInterval(this.#timer);
this.#timer = undefined;
}
await Promise.all(this.#inFlight);
}
isRunning(): boolean {
return this.#timer !== undefined;
}
}Calling unref() on the interval is a different policy. It allows Node to exit if that timer is the only remaining activity, but it does not stop callbacks while the suite is running. That can be appropriate for optional telemetry whose final sample may be skipped. It is wrong for token renewal, cleanup, or any task the test requires. Required work needs an awaited shutdown path, not permission to disappear at process exit.
The third owner is a server created by the tests. OAuth callbacks, mock payment endpoints, and local application fixtures commonly call listen() in setup. Closing Selenium does not close those servers. A suite-level server should have suite-level teardown, while a per-test server should be bound to that test's context.
This example asks the operating system for an available port, registers closure as soon as listening succeeds, and awaits the close callback. The assertion goes through the real server, so removing route behavior breaks the test. Omitting the close hook leaves a listening resource that can keep the process alive.
import assert from 'node:assert/strict';
import { createServer, type Server } from 'node:http';
import test from 'node:test';
function closeServer(server: Server): Promise<void> {
return new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
test('callback server receives the authorization code', async (t) => {
const server = createServer((request, response) => {
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
response.writeHead(url.searchParams.has('code') ? 204 : 400).end();
});
await new Promise<void>((resolve, reject) => {
const onError = (error: Error) => reject(error);
server.once('error', onError);
server.listen(0, '127.0.0.1', () => {
server.off('error', onError);
resolve();
});
});
t.after(() => closeServer(server));
const address = server.address();
assert(address && typeof address !== 'string');
const response = await fetch(`http://127.0.0.1:${address.port}/callback?code=abc`);
assert.equal(response.status, 204);
});Server shutdown has its own trade-off. Existing connections may delay closure, and forcibly destroying sockets can interrupt assertions or hide an application bug. Track accepted sockets only if graceful close is demonstrably blocked, then distinguish normal idle connections from requests that never finish. Do not add socket destruction to every fixture as a reflex.
Make teardown survive failures and partial setup
Cleanup code must handle every state setup can reach. A browser build can fail before returning a driver. Navigation can fail after a driver exists. A helper can start its interval before the browser is ready. If teardown assumes all resources exist or none do, the most interesting setup failures produce secondary exceptions that hide the original cause.
Keep a resource variable undefined until construction succeeds. Register its cleanup immediately afterward. When several resources form a fixture, either register each cleanup as it becomes available or use a small owner that records completed acquisitions. Release in reverse order: stop the sampler that uses the driver, quit the driver, then stop the local server it contacted. Reverse order prevents later cleanup from calling an already terminated dependency.
Do not catch and discard teardown errors. If the test body fails and quit() also fails, preserve both. JavaScript supports an AggregateError, and many runners already attach hook failures beside test failures. The browser assertion explains the product outcome; the cleanup failure explains why the process or Grid may be dirty. Selecting only one loses operational evidence.
Set a timeout on teardown at the runner level, but treat expiration as a failure. An unbounded quit() can hang the job. A one-second timeout imposed everywhere can kill healthy remote cleanup under load. Choose a bound based on the suite's existing command timeout and Grid path, record it in configuration, and emit the session ID when it expires. Do not present an arbitrary duration as a measured optimum.
Shared singleton fixtures need reference ownership. If two files acquire one application server, the first file to finish must not close it while the second is active. Count acquisitions through one owner and reject double release. Better still, let the test runner's global setup and teardown own the singleton. Global ownership reduces startup cost but makes isolation failures harder to localize.
Test-owned child processes deserve a separate record even when they launch beside Selenium. A suite may start the application under test with child_process.spawn, wait for a readiness line, and then build WebDriver. If browser construction fails, code that registered application cleanup only after the driver was ready never runs. The child and its stdout pipe can keep the worker alive, making a Selenium setup error look like a Selenium handle leak.
Record the child PID as soon as spawn succeeds, and register its disposer before waiting for readiness. The disposer should request the application's documented graceful shutdown, wait for the child's exit event, and escalate only after the suite's declared shutdown limit. Signal behavior differs across operating systems, so a Unix-only SIGTERM recipe is not a portable framework abstraction. Put the platform-specific stop operation behind one owner and test it on every CI operating system the suite supports.
Process-tree evidence tells this case apart from an abandoned driver. Capture the command basename, PID, parent PID, and exit status for processes the fixture created. Do not dump complete command lines when they contain credentials. If the remaining child is the application server and the WebDriver quit record settled, changing Selenium timeouts cannot repair the owner that leaked.
Never clean this up with a broad command such as killing every node, chrome, or chromedriver process on the runner. Parallel jobs may share the machine, and developer workstations certainly do. Terminate only a PID returned to the current fixture, validate that it still belongs to the expected child, and let the CI platform contain anything that outlives the job. Broad cleanup can make the hanging test appear fixed while corrupting unrelated runs.
Local and remote WebDriver modes also change the process picture. A remote session talks to an existing Grid, so the test worker does not own that Grid's browser process. A local builder may arrange a driver service on the same machine. Keep the configured mode in lifecycle logs. Seeing Chrome on a Grid host is not evidence that the Node worker has a local child handle, and seeing a local driver process does not prove the remote session failed to delete.
Watch for callbacks created outside the fixture. A helper module can start a timer at import time, before any test hook has a chance to register cleanup. Import side effects are difficult to assign to a test and can be duplicated in worker processes. Replace them with an explicit start() that returns or registers a disposer. The extra call is a small API cost for visible ownership.
WebDriver event subscriptions need the same discipline. Removing a JavaScript event listener does not necessarily close the transport underneath it, and closing a transport does not excuse forgetting the subscription API's own cleanup. Use the documented unsubscribe or session teardown behavior for the Selenium feature in use. Avoid naming a cleanup method from memory; BiDi and CDP wrappers can expose different interfaces across Selenium versions.
If a framework wraps these transports, expose one asynchronous disposer rather than making tests know the order of listener removal, subscription cancellation, and driver termination. The wrapper can log each completed phase and throw an aggregate failure when more than one phase breaks. That extra abstraction costs code and version-specific maintenance, but it prevents hundreds of tests from copying teardown sequences that become stale at different times.
Finally, make cleanup idempotent where retries are plausible. A hook may run after a helper already quit the driver in a failure path. Store state, clear the stored reference before awaiting destructive cleanup when re-entry is possible, and classify a second call. Idempotence should not mean swallowing every NoSuchSessionError; it should mean the owner knows whether the session was already released.
Put leak detection into CI without creating flakes
CI needs two clocks. The test runner owns normal test and hook timeouts. The job owns a longer outer timeout that catches a process which never exits. The outer timeout should leave enough room for normal artifact upload and container shutdown, then fail loudly rather than convert the run to success.
Enable active-resource snapshots only on the relevant lane at first. They add logging and can expose runner-specific resources that confuse teams unfamiliar with the output. Keep lifecycle records always on for session construction and teardown because those are cheap and directly actionable. Redact once in the logging helper instead of trusting every test author.
The workflow below runs the ordinary command with diagnostics enabled. A GNU timeout watchdog returns a failure before the job's final ceiling, which gives the artifact step a chance to run. The if: always() condition preserves logs after assertion failures and the inner watchdog. A hard job cancellation can still prevent upload, so external logs remain useful as a last fallback.
name: Selenium JavaScript lifecycle
on:
pull_request:
jobs:
browser-tests:
runs-on: ubuntu-latest
timeout-minutes: 15
env:
OPEN_HANDLE_DIAGNOSTICS: "1"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "24"
cache: npm
- run: npm ci
- name: Run browser tests and allow natural process exit
run: timeout --signal=TERM --kill-after=15s 12m npm test
- name: Upload lifecycle diagnostics
if: always()
uses: actions/upload-artifact@v4
with:
name: selenium-lifecycle-${{ github.run_attempt }}
path: artifacts/lifecycle/
if-no-files-found: warnDo not add a successful process.exit(0) after npm test to make this lane fit the timeout. If the command hangs, the job must remain red until ownership is repaired. A temporary shell watchdog can send a diagnostic signal before termination, but signal handling varies by runner and test framework. Document that mechanism as incident instrumentation, not as proof that cleanup succeeded.
Roll the gate out in stages. First collect logs on a nightly lane and identify stable runner-owned resource types. Next fail on resources registered through your own fixture registry, because ownership there is deterministic. Last, decide whether unexplained public-API deltas should fail every pull request or only a focused lifecycle suite. Platform-level lists are valuable but too coarse for a universal zero-resource assertion.
Measure exit latency from the runner's final report to process termination, but do not invent a universal threshold. Establish the distribution from your own CI history, then alert on a sustained change. A fixed low threshold can punish legitimate coverage report writes or remote artifact flushing. A very high threshold wastes executor time and delays feedback.
Keep retries out of the lifecycle gate. Retrying an entire hanging suite multiplies leaked sessions and consumes the evidence from the first attempt. If the product test policy requires retries, record each attempt's driver and cleanup independently, and make any teardown failure fail the overall job even when a later product assertion passes.
When not to treat a slow exit as an open handle
A reporter may still be writing coverage, compressing screenshots, or uploading results after tests finish. That is active required work, not a leak. The process should exit when the work settles. Evidence includes changing artifact sizes, completed upload logs, and eventual natural exit. Fix its performance or timeout separately from resource ownership.
A test runner that has not produced its final summary may be waiting on an unresolved test callback. Resource snapshots can still help, but calling the condition an open-handle leak is premature. Find the test or hook that never resolved. Callback and Promise APIs mixed in one test are common suspects because a runner may wait for a callback after the Promise path has completed.
An active Grid session visible in the server console does not prove the Node client is still alive. Server-side session timeout and client process lifetime are different systems. Check the client PID and event-loop evidence. If Node exited cleanly while the Grid retained a session, focus on whether the quit command reached the remote end and how the Grid handled it.
Do not eliminate every referenced timer with unref(). A retry backoff, lease renewal, or required timeout may be part of correct application behavior. Letting the process exit can skip that work and make a false green more likely. Stop timers owned by completed tests; leave application timers referenced when the application contract requires them.
Do not force-close a shared server merely because one worker reports a network resource. Identify the owning process and reference model first. Another concurrent test may still be using it. The attempted fix can turn a clean hang into intermittent connection resets that are much harder to diagnose.
Some resource types belong to the terminal, debugger, inspector, or test runner. A baseline captured in a different command, Node version, or reporter mode is not comparable. Reproduce with the same invocation and subtract within the same worker. If the delta disappears but the process still hangs, capture process state and runner diagnostics rather than insisting the public list must name the culprit.
Forced exit remains acceptable at the infrastructure boundary when a job has exceeded its declared limit. Its purpose there is containment, not remediation. Preserve the timeout status, active-resource snapshot, lifecycle log, and remote session view. Those records tell the next engineer whether to fix quit(), a timer, a server, or a test that never actually finished.
// FIELD DISPATCH
Get the QA Field Notes
Weekly QA battles, AI testing guides, and interview drills. Free on Substack.
// LIVE COURSE / THE TESTING ACADEMY
Playwright Automation Mastery
Go beyond Selenium. Master Playwright with JS/TS in 90 days.
From the instructor behind this guide.
Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.
PRIMARY REFERENCES
Verify the details at the source
QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.
- 01Official selenium.dev reference
selenium.dev
Primary documentation selected and verified for the claims in this guide.
- 02Official selenium.dev reference
selenium.dev
Primary documentation selected and verified for the claims in this guide.
- 03Official w3.org reference
w3.org
Primary documentation selected and verified for the claims in this guide.
- 04Official nodejs.org reference
nodejs.org
Primary documentation selected and verified for the claims in this guide.
FAQ / QUICK ANSWERS
Questions testers ask
Why does Node stay alive after my Selenium tests pass?
A green assertion does not close resources created by the test process. A referenced timer, listening server, child process, socket, or unfinished WebDriver teardown can still give Node work and prevent natural exit.
Is driver.close the same as driver.quit in Selenium JavaScript?
`close()` closes the current browser window, while `quit()` terminates the WebDriver session and invalidates that driver instance. Suite cleanup should normally await `quit()` because window cleanup is not the same contract as session cleanup.
Should I use forceExit when Jest hangs after Selenium tests?
Forced termination is useful only as a temporary outer safety limit. It can hide leaked sessions, skip asynchronous cleanup, and truncate diagnostic output, so keep the run failing until the owner of the active resource is fixed.
What does process.getActiveResourcesInfo tell me?
The method returns resource type names that are currently keeping Node's event loop alive. It narrows the search to categories such as timers or network resources, but it does not identify the allocation stack or prove which test owns a resource.
Why does the open-handle problem happen only in CI?
Parallel workers, slower teardown, remote Grid connections, reporters, and test-owned servers can overlap differently on CI. Compare resource snapshots from the same runner configuration and preserve worker identity instead of assuming the browser is the only difference.
RELATED GUIDES
Continue the learning route
GUIDE 01
Debug Selenium BiDi Subscription Leaks
Master debug Selenium BiDi subscription leaks with practical examples, architecture decisions, failure analysis, CI guidance, metrics, and scenario-led interview answers.
GUIDE 02
Debug Java Classpath Conflicts in Selenium Frameworks
A practical guide to debug Java Selenium classpath conflicts, covering design, implementation, debugging, scale, measurable release gates, and senior interview scenarios.
GUIDE 03
Debug Selenium Grid Event Bus Connectivity
Master debug Selenium grid event bus with practical examples, architecture decisions, failure analysis, CI guidance, metrics, and scenario-led interview answers.
GUIDE 04
Debug Selenium Manager Proxy and Cache Failures
Master debug Selenium manager proxy cache with practical examples, architecture decisions, failure analysis, CI guidance, metrics, and scenario-led interview answers.
GUIDE 05
Debug ElementClickInterceptedException with Overlays and Hit Testing
Debug Selenium click interception by inspecting the center-point hit test, overlay lifecycle, scrolling geometry, and application readiness before retrying.