PRACTICAL GUIDE / Selenium Node test runner setup
Stop Selenium sessions leaking between Node tests
Set up Selenium with Node's test runner so every test owns its browser session, teardown is awaited, and CI failures retain useful evidence.
In this guide6 sections
What you will learn
- Why the driver ends up owned by the wrong test
- Build a lifecycle that cannot outlive its test
- Diagnose ownership before changing timeouts
- Handle concurrency without shared browser state
A profile test passes by itself, then fails with invalid session id when the whole Node suite runs. The screenshot belongs to a checkout test, and the teardown log appears before the profile assertion. Two tests are controlling the same driver, or one test has finished without waiting for its session to close.
That combination is a runner-lifecycle bug, not a reason to increase Selenium timeouts. The useful question is simple: which test created this session, and which test was allowed to quit it?
Why the driver ends up owned by the wrong test
WebDriver commands are asynchronous. Building a driver starts a browser session, each call sends a command to that session, and quit() asks the remote end to delete it. Node sees promises for all of those operations. If a test or hook starts a promise without awaiting or returning it, the runner is free to mark that lifecycle phase complete while the command is still in flight.
The most common setup puts a mutable driver variable at module scope:
import { afterEach, beforeEach, test } from 'node:test';
import { Builder, WebDriver } from 'selenium-webdriver';
let driver: WebDriver;
beforeEach(async () => {
driver = await new Builder().forBrowser('chrome').build();
});
afterEach(() => {
driver.quit(); // The hook returns before quit has settled.
});
test('profile', { concurrency: true }, async () => {
await driver.get('https://www.selenium.dev/selenium/web/web-form.html');
});
test('checkout', { concurrency: true }, async () => {
await driver.get('https://www.selenium.dev/selenium/web/web-form.html');
});There are two independent defects here. The teardown promise is ignored. More seriously, both concurrent tests assign to the same variable. The second beforeEach can replace driver before the first test reads it. Either afterEach can then quit the session currently held by that variable, regardless of which test created it.
Typical output is misleading because the exception is raised by the next command, not by the earlier ownership mistake:
▶ profile
session created: 8f9710a2
▶ checkout
session created: b245cc81
checkout teardown: b245cc81
✖ profile
InvalidSessionIdError: invalid session id
at WebDriver.execute (.../selenium-webdriver/lib/webdriver.js:...)The stack points to getTitle(), findElement(), or another innocent command. It does not point to the hook that quit the session. That is why adding a wait around the failing element changes nothing.
Moving the variable into a factory is not enough if the factory returns a singleton. A helper named getDriver() often hides the same ownership problem behind a cleaner interface. Imports are cached within a process, so a module that creates a driver once and exports it still provides shared mutable state to every test that imports that module.
Runner concurrency also has more than one layer. Test files may execute concurrently, and tests or suites inside a file may opt into concurrency. Separate test-file processes protect JavaScript module memory, but they do not isolate external resources. Two sessions can still use the same account, download path, database record, or fixed Grid slot label. A sound setup separates in-memory driver ownership from application-data ownership instead of assuming one solves the other.
Selenium's guidance to avoid shared state and use a fresh browser per test is practical here. A fresh session resets windows, cookies, storage, active alerts, and browsing context. It also gives failure evidence a clear test owner. The cost is session startup time, which should be measured rather than silently traded for contamination.
Build a lifecycle that cannot outlive its test
The least surprising design is a scoped function. It creates one driver inside the test callback, passes that exact object to the test body, and closes the same object in finally. No hook can look up a later value from a mutable global.
import type { TestContext } from 'node:test';
import {
Browser,
Builder,
type WebDriver,
} from 'selenium-webdriver';
type BrowserWork = (driver: WebDriver) => Promise<void>;
export async function withChrome(
context: TestContext,
work: BrowserWork,
): Promise<void> {
const remoteUrl = process.env.SELENIUM_REMOTE_URL;
let builder = new Builder().forBrowser(Browser.CHROME);
if (remoteUrl) {
builder = builder.usingServer(remoteUrl);
}
const driver = await builder.build();
const session = await driver.getSession();
const sessionId = session.getId();
context.diagnostic(`webdriver created session=${sessionId}`);
let testError: unknown;
try {
await work(driver);
} catch (error) {
testError = error;
}
let quitError: unknown;
try {
context.diagnostic(`webdriver quitting session=${sessionId}`);
await driver.quit();
} catch (error) {
quitError = error;
}
if (testError && quitError) {
throw new AggregateError(
[testError, quitError],
`test and teardown both failed for session ${sessionId}`,
);
}
if (testError) throw testError;
if (quitError) throw quitError;
}This helper does more work than a basic try/finally because a teardown error can otherwise replace the original assertion error. Preserving both errors matters in CI. A failed assertion followed by a Grid connection reset tells a different story from a clean assertion followed by a failed delete-session request.
The test itself stays small and uses the driver supplied to its callback:
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { By, until } from 'selenium-webdriver';
import { withChrome } from './support/browser.js';
test('web form reports the submitted value', async (context) => {
await withChrome(context, async (driver) => {
await driver.get('https://www.selenium.dev/selenium/web/web-form.html');
await driver.findElement(By.name('my-text')).sendKeys('runner-owned');
await driver.findElement(By.css('button')).click();
const message = await driver.wait(
until.elementLocated(By.id('message')),
5_000,
);
assert.equal(await message.getText(), 'Received!');
});
});Notice what the assertion proves. It checks the application response, while the helper checks lifecycle completion. A log assertion such as "session was created" would only prove infrastructure setup and could pass even when the page is wrong.
Hooks can still be appropriate for a serial suite. A beforeEach and afterEach pair is readable when every test in that scope runs sequentially and every hook awaits its work. Keep the variable inside the smallest possible suite module, never export it, and document that enabling in-file concurrency is prohibited. The scoped-function pattern is safer when reviewers cannot easily prove those constraints.
A before hook that creates one browser for an entire file buys speed at a larger price. If test one leaves a modal open, changes locale, or navigates a second window, test two inherits it. Cleanup code then grows into an incomplete reimplementation of new-session isolation. Once reset logic includes cookies, local storage, session storage, windows, frames, alerts, permissions, downloads, and server-side account state, fresh sessions are usually cheaper to reason about.
Diagnose ownership before changing timeouts
Start with one failing test and one test that appears unrelated but runs near it. Run each alone, then run the pair repeatedly with the same concurrency setting. A pass in isolation and a failure only as a pair is evidence of shared state, but it does not yet prove the driver is the shared resource.
Add three fields at session creation and teardown: test name, process ID, and session ID. Node's test context already associates diagnostic lines with the current test. The process ID distinguishes file-level workers. The session ID joins client output to Selenium Server or Grid output.
Use a focused command before running the full suite:
node --test \
--test-reporter=spec \
--test-concurrency=1 \
dist/test/profile.test.js dist/test/checkout.test.js
node --test \
--test-reporter=spec \
--test-concurrency=2 \
dist/test/profile.test.js dist/test/checkout.test.jsIf the first command passes and the second fails, compare identities rather than declaring a generic race. One session ID appearing under two test names proves driver sharing. Different session IDs with the same account and conflicting application state point to a data-isolation problem. Different sessions that both disappear when one Grid node restarts point to infrastructure ownership, not JavaScript module state.
Several exception shapes help narrow the boundary:
InvalidSessionIdErrorimmediately after another test's teardown suggests a deleted or replaced session.NoSuchSessionErrorin a Grid or browser-driver log can describe the same lifecycle at the server side.ECONNREFUSEDfor the Grid URL means the client could not reach the server. It does not prove any session was created.- A runner warning about asynchronous activity after a test ended indicates work escaped the test callback. Find the unreturned promise.
- A process that hangs after all assertions often has a live driver, HTTP connection, timer, or child process. Confirm whether every created session has one completed quit attempt.
Do not count quit() calls without pairing them to creations. Two calls and two sessions can still be wrong if both calls target one ID. Do not use screenshots as the sole correlation signal either. Screenshots show page state but rarely identify the owning test process or session unless the artifact name includes those fields.
Capture failure evidence before teardown. Once quit() succeeds, the browser is gone and later screenshot or page-source commands will fail. A practical failure path takes a screenshot in the test's catch block, names it with the test and session ID, and then lets the outer lifecycle close the driver. Keep secrets out of page source and browser logs before uploading artifacts.
The ordering of diagnostic lines matters. This sequence is healthy:
profile pid=412 created session=8f9710a2
profile pid=412 assertion passed session=8f9710a2
profile pid=412 quitting session=8f9710a2
profile pid=412 quit complete session=8f9710a2A missing quit complete line is not automatically a leak. The process may have been killed, the remote endpoint may have reset the connection after deleting the session, or logging may have failed. Check the Grid session map or browser-driver log before deciding. Conversely, a successful HTTP response from delete-session proves server acceptance, not that an operating-system browser process terminated cleanly. Node capacity trends reveal that second problem better than a client log.
A Grid-side deletion can present almost exactly like the shared-variable race. The next innocent WebDriver command can still report an invalid session, and its stack still points at the command that discovered the loss. The separator is identity and ordering. In the ownership bug, one session ID appears under two test names, or a quit for that ID is issued from the wrong test before the failed command. In a Grid-side loss, every test keeps a distinct session ID and no client quit for the affected ID precedes the error. Several otherwise unrelated IDs may disappear in the same time window, especially when they were hosted by the same Node. That cluster belongs with the Grid or platform owner, not in a rewrite of JavaScript hooks.
Read the session ID as the primary field and the process ID as supporting context. A healthy value is one ID paired with one test from created through quit complete, with the final assertion before quitting. A broken ownership value is the same ID paired with two names, or two created IDs followed by teardown of only the later value while the earlier test is still issuing commands. A misleading value is a unique process ID. It proves the lines came from one runner process, but it does not prove that process held only one driver or that another process did not share the same external account. Likewise, distinct session IDs rule out driver reuse but do not rule out shared application data.
Keep the server and client clocks aligned closely enough to order an incident. The decisive handoff is not merely the text of InvalidSessionIdError. It is the client creation time, session ID, last successful command time, absence or presence of a quit attempt, and the Grid view of that ID at the same point. If the server removes the session before any client teardown line, infrastructure ended it. If a client teardown for the same ID comes first under another test name, the suite ended it. A truncated CI log is inconclusive and should remain classified as unknown rather than being used to justify a timeout increase.
Handle concurrency without shared browser state
Once each test owns its driver, increase concurrency in small steps. Jumping from one session to 40 changes browser CPU, Grid queueing, application traffic, and test-data contention at once. That makes the first red build hard to explain.
Begin with two files that use different accounts and no fixed filesystem paths. Record total duration, session-creation latency, command latency, and failures. Then double file concurrency until either throughput stops improving or one resource reaches its intended limit. The runner's concurrency number is a demand setting, not proof that the Grid can supply that many stable slots.
An example with two genuine failures illustrates why identity matters. Suppose profile and checkout have different WebDriver session IDs, yet checkout deletes an item that profile expects. Serial execution passes, and concurrent execution fails with a correct application assertion. The driver lifecycle is sound. Give each test a separate user or seed record.
Now suppose the tests have separate users but write downloads to artifacts/latest.pdf. One test verifies a file produced by the other. Again, browser sessions are isolated while the filesystem is not. Use a per-test temporary directory and include its path in diagnostics. Deleting cookies will never repair that race.
A third case occurs on a Grid with two slots. Four tests start together, two acquire sessions, and two wait in the new-session queue. If the runner's per-test timeout includes queue time, waiting tests may be cancelled before they ever receive a session. Their error can look like a slow browser setup. Measure time immediately before build() and immediately after it returns. A long gap with no session ID belongs to allocation. A long first navigation after a session ID exists belongs elsewhere.
Avoid a global semaphore inside the same helper unless the runner and Grid cannot be configured coherently. A semaphore can prevent overload, but it creates another queue whose wait time is invisible to Grid telemetry. If you use one, emit acquisition time and make cancellation release permits reliably.
Retries are particularly dangerous during rollout. A retry often starts with a clean session, so a race disappears and the report turns green. Keep the first attempt, its session ID, and its artifacts. Label a retry as a second observation, not a cure. A suite that passes only after replacing its session has discovered isolation evidence.
Wire the same contract into CI
CI should make the remote endpoint and concurrency explicit. A developer should be able to copy the reported command and reproduce the same ownership model. Hidden runner defaults create the familiar situation where local execution is serial but CI is parallel.
This workflow starts a standalone Grid, waits for its status endpoint, compiles the tests, runs two test files at a controlled concurrency, and removes the container even after failure:
name: selenium-node
on:
pull_request:
jobs:
browser-tests:
runs-on: ubuntu-latest
timeout-minutes: 20
env:
SELENIUM_REMOTE_URL: http://127.0.0.1:4444
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- name: Start Selenium
run: >-
docker run --detach --name selenium-grid
--publish 4444:4444 --shm-size 2g
selenium/standalone-chrome:4.35.0
- name: Wait for Grid
run: |
for attempt in $(seq 1 30); do
curl --fail --silent http://127.0.0.1:4444/status && exit 0
sleep 1
done
docker logs selenium-grid
exit 1
- run: npm run build:test
- name: Run browser tests
run: >-
node --test --test-reporter=spec --test-concurrency=2
dist/test/profile.test.js dist/test/checkout.test.js
- name: Print Grid logs
if: failure()
run: docker logs selenium-grid
- name: Stop Selenium
if: always()
run: docker rm --force selenium-gridPin the Selenium image to a version your team has qualified. A floating tag can update the browser, driver, and server together between two commits, which destroys the comparison. A version tag still depends on registry availability; a digest gives stronger immutability at the cost of more deliberate upgrades.
The two-minute convenience of a very large job timeout should not replace boundary timeouts. Keep a job ceiling for stuck processes, a startup budget for Grid readiness, a session-allocation measurement around build(), and explicit waits for application conditions. Those numbers answer different questions. Raising the job timeout cannot make an unawaited teardown correct.
During migration, do not convert the whole suite in one change. Pick a noisy file, wrap each test in the scoped helper, and run it serially until session creation and deletion pair correctly. Enable concurrency for that file alongside one other file. Track Grid peak sessions and application-data conflicts. Expand only after the first group stays clean for several CI cycles.
Land the lifecycle helper and its diagnostics before changing concurrency. That gives the old and new executions the same identity evidence. Next change page objects and fixtures so the driver enters through the test callback, while the canary still runs serially. Then remove file-level driver exports and fail review when new code imports one. Only after the serial canary creates and closes one unique ID per test should the runner fan-out increase. Changing helper, ownership, and concurrency in one commit makes every new failure ambiguous.
The first migration break often exposes a dependency the shared browser had hidden. A later test may assume an earlier login, seeded cart, open window, or changed locale. A screenshot hook may run after the scoped helper has already quit, so it loses the very evidence it was meant to capture. A reporter may render the paired assertion and teardown errors poorly. Repair those contracts before raising concurrency: create test state explicitly, capture artifacts inside the owned session, and verify how the reporter displays both failures. Treating those breaks as reasons to restore the singleton preserves the original coupling.
The rollout costs wall-clock time before it saves any. Serial canaries keep CI slow while the suite is being converted, and fresh sessions add browser startup plus remote allocation to tests that previously reused one process. Diagnostic lines and per-session artifacts also increase log volume and storage. Those are specific migration costs. Put an expiry on high-volume artifacts after the canary period, but keep the test, process, and session correlation because removing it recreates the original investigative gap.
Legacy suites often expose driver through page-object constructors. That is compatible with per-test ownership: create page objects inside the scoped callback and pass the callback's driver to them. A page object that imports a global driver must change, because its dependency remains hidden and can outlive the test.
Make leak detection a reporting rule rather than an after-suite cleanup sweep. Compare created and quit-complete session IDs, but keep quit failures separate from missing quit attempts. A final script that deletes every remaining Grid session may protect capacity, yet it can also erase the evidence and terminate sessions owned by another job on a shared Grid.
Job cancellation deserves its own test. A runner that receives a termination signal may not have enough time to finish an asynchronous quit(), even though the normal finally path is correct. Reproduce that condition against a non-production Grid: start one long test, cancel the job, and watch whether the slot returns before the Grid's inactive-session timeout. If it returns only after that timeout, normal test ownership is working but abrupt-process cleanup is not guaranteed.
Do not try to make the Node exit event perform network cleanup. That event cannot keep the process alive for an awaited WebDriver request. A termination handler can begin an orderly shutdown when the platform provides a grace period, but it needs idempotence, a hard deadline, and tests for a second signal. In a shared framework this code is easy to get wrong. Grid-side session timeouts and job-scoped capacity limits are the final safety net, while the regular per-test finally remains the primary cleanup path.
This distinction changes incident classification. A session that leaks after every passing test implicates the test lifecycle. A session that leaks only when CI cancels the process implicates shutdown policy. A session that received a successful delete request but still occupies a slot implicates the Grid or node. Record which case occurred before changing hooks that already behave correctly.
The test-framework owner should deliver the scoped helper, the one-test-to-one-session contract, and a minimal pair of tests that reproduces the former race. Suite owners must remove hidden driver imports and identify tests that depend on shared accounts or order. The Grid or platform owner investigates sessions that disappear without a matching client quit and validates slot release after cancellation. A useful handoff contains the CI run, test names, process IDs, session IDs, UTC timestamps, last successful command, teardown outcome, concurrency setting, and the Grid Node involved when known. Without that packet, each team can demonstrate its own component in isolation while the cross-boundary failure remains unresolved.
Know the costs and the cases where this setup is wrong
Fresh sessions add latency. Local Chrome startup may take a second or two; a busy remote Grid may add queue time. Across hundreds of short tests, setup can dominate execution. Parallelism can recover wall-clock time, but it consumes CPU and memory and can overload the application under test. Measure the curve instead of assuming more workers are faster.
Per-test sessions also make login cost visible. Prefer an API or fixture that creates the required application state, then log in through the UI only where the login flow is the behavior under test. Do not transfer cookies from a shared browser unless the test contract explicitly covers that mechanism. Shared authentication state can recreate the same coupling outside the driver.
The enhanced error handling in the helper adds complexity. AggregateError preserves both failures, but some reporters render nested errors poorly. Verify your reporter output and, if necessary, attach teardown details as diagnostics while retaining the original assertion as the primary failure. Never silently suppress quit errors just to keep reports tidy.
Do not use this setup for a single linear browser journey that is intentionally one test. Splitting a checkout scenario into multiple runner tests while trying to keep one session creates ordered test dependencies. Keep the journey in one test with meaningful internal steps, or make each test independently establish its preconditions.
Avoid a browser per test for load or performance testing. WebDriver functional sessions are expensive, and the application traffic they create is not a controlled load model. Use a performance-testing tool for concurrency at scale, then retain a small Selenium set for user-visible correctness.
A shared session can also be appropriate in an interactive investigation. When an engineer is reproducing one production issue and wants the browser left open, automatic teardown works against the task. Put that behavior in an explicitly manual script, not behind an environment switch that CI might inherit.
Finally, do not blame lifecycle ownership when the evidence shows different sessions and independent resources. A test that fails alone with the same session history has another cause. A Grid node that kills every session at once needs node or browser-process investigation. A correct product assertion that detects shared server-side data needs fixture isolation. The point of session IDs and awaited teardown is not to force every failure into one category. It is to eliminate ambiguity about which test controlled which browser.
This technique does not catch a command that hangs inside a session that remains valid. Creation and teardown ownership can be perfect while a navigation, script, or element command never returns and prevents the test from reaching quit(). Diagnose that path with command timing and Grid or browser evidence. One-to-one session IDs answer who owns the browser, not whether every command inside that browser will complete.
// 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 selenium.dev reference
selenium.dev
Primary documentation selected and verified for the claims in this guide.
- 04Official selenium.dev reference
selenium.dev
Primary documentation selected and verified for the claims in this guide.
FAQ / QUICK ANSWERS
Questions testers ask
Should every Node test create a new Selenium driver?
Create a fresh session for tests that can change browser or application state. A deliberately shared session can suit a read-only smoke journey, but it must run serially and accept that one failure may contaminate everything that follows.
Why does driver.quit finish after the Node test has passed?
A missing await lets the test function settle before WebDriver finishes deleting the session. Return or await the teardown promise, and make a quit failure visible instead of starting an unobserved cleanup task.
Does Node test concurrency share one WebDriver instance?
Parallel files normally have separate module instances, while concurrent tests inside one scope can still race on module-level variables and external accounts. Treat the driver, test data, download directory, and user identity as resources with an explicit owner.
What evidence proves that two tests used the same browser session?
Keep the test name beside the WebDriver session ID at creation, before the assertion, and during teardown. Interleaved lines with one session ID, or a command logged after that ID was quit, establish an ownership error.
When is a shared browser acceptable in a Selenium Node suite?
Reuse can be reasonable for a short, serial diagnostic script where speed matters more than isolation. It is a poor default for independent CI tests because cookies, windows, storage, alerts, and failed cleanup all cross test boundaries.
RELATED GUIDES
Continue the learning route
GUIDE 01
Run Selenium Tests in Docker: Complete QA Guide
Learn how to run Selenium tests in Docker with browsers, Grid, CI pipelines, debugging artifacts, stable setup, and fewer environment issues.
GUIDE 02
Selenium Grid Tutorial: Run Tests Across Browsers
Selenium Grid tutorial explaining architecture, setup, remote WebDriver, browser capabilities, parallel execution, Docker, CI, and debugging tips.
GUIDE 03
Selenium TypeScript ESM Setup for WebDriver
Learn Selenium TypeScript ESM setup with working code, failure cases, debugging steps, and CI evidence for reliable QA automation in practice.
GUIDE 04
Run Selenium Grid on Kubernetes with Disposable Nodes
Master Selenium grid Kubernetes with practical examples, architecture decisions, failure analysis, CI guidance, metrics, and scenario-led interview answers.
GUIDE 05
Selenium Grid Trace Correlation with Test IDs
Master Selenium grid trace correlation with practical examples, architecture decisions, failure analysis, CI guidance, metrics, and scenario-led interview answers.