lecture 2 of 160 completed

Driver lifecycle at scale

Sessions are resources you allocate and must release. At scale, leaks take down build machines and reuse causes cross-test bleed.

A build server at a company I worked with had to be restarted every few days. The cause was a suite leaking a handful of Chrome processes per run. After a week there were hundreds, the machine had no memory left, and every job on it was slow.

Nobody had written obviously wrong code. They had written driver.quit() at the end of each test, and tests that failed never reached it.

At senior level, driver lifecycle is a resource management problem, and it has three parts: never leak, isolate correctly, and pay the startup cost as few times as you can.

Part one: never leak.

The rule is that quitting must happen on every path, including failure, crash and timeout.

let driver;

beforeEach(async () => {
  driver = await createDriver();
});

afterEach(async () => {
  if (driver) {
    await driver.quit();
    driver = null;
  }
});

afterEach runs even when the test throws. The null assignment matters too: a stale reference to a quit driver produces a confusing error rather than an obvious one.

Add a safety net at the process level, because afterEach does not run if the whole process is killed:

process.on('SIGINT', async () => { await driver?.quit(); process.exit(); });
process.on('uncaughtException', async () => { await driver?.quit(); process.exit(1); });

And a scheduled sweep on CI machines. Even with careful code, crashed drivers leave orphans. A job that kills stray chromedriver and browser processes older than an hour is not a failure to write correct code, it is operational hygiene. Every mature Selenium setup has one.

Part two: isolation, and the real decision.

A fresh driver per test gives complete isolation: no cookies, no storage, no leftovers. It costs one to three seconds per test, which on a thousand tests is up to fifty minutes of pure browser startup.

One driver per file or per worker is far faster and leaks state between tests, which is the failure that produces "passes alone, fails in the suite".

The compromise most mature suites use: reuse the driver, reset the state explicitly.

before(async () => { driver = await createDriver(); });

beforeEach(async () => {
  await driver.manage().deleteAllCookies();
  await driver.executeScript('window.localStorage.clear(); window.sessionStorage.clear();');
  await driver.get(config.baseUrl);
});

after(async () => { await driver.quit(); });

You pay startup once and still begin each test from a known state. Note that clearing storage needs executeScript, because the protocol has no storage command. The protocol lecture predicts that.

Part three: pooling, for large suites.

Above a few hundred tests, teams pool drivers: create a fixed number, hand them out, return them after use. This caps how many browsers exist at once, which caps memory, which is usually the binding constraint on a CI machine.

A pool needs three things to be safe: a hard maximum, a health check before handing a driver out (a quit or crashed driver must not be reused), and a reset when it is returned. Without the health check a pool will hand out dead sessions and produce failures that look random.

What good looks like. One place in the codebase builds drivers. One place destroys them. No test calls new Builder() directly. When that is true, changing browser options, adding a Grid URL, or fixing a leak is a single edit. When it is not, it is a search across two hundred files, and I have done that search.

Remember this: quit on every path, sweep on CI, reset instead of restarting, and centralise creation in one factory.

Reference: Driver sessions.