Driver Management Across Tests and Threads

9 min read

The WebDriver specification explicitly states that WebDriver implementations are not thread-safe. Two threads operating on the same WebDriver instance will issue conflicting commands — one thread clicks a button while another is still waiting for a page to load — producing NoSuchSessionException, StaleElementReferenceException, and race conditions that disappear the moment you add a debug log. Driver management is the part of your framework that decides when drivers are created, how they're isolated across threads, and when they're destroyed. Get this right and parallel execution "just works." Get it wrong and you have a suite that's faster in theory but broken in practice.

The three strategies

Strategy 1: One driver per test (sequential)

The simplest approach. A new driver for every test method, created in setup and destroyed in teardown.

public class BaseTest {
    protected WebDriver driver;
 
    @BeforeMethod
    public void setUp() {
        driver = DriverFactory.create(Config.browser());
        driver.manage().window().maximize();
        driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(Config.timeoutSeconds()));
    }
 
    @AfterMethod(alwaysRun = true)
    public void tearDown() {
        if (driver != null) {
            driver.quit();
            driver = null;
        }
    }
}

alwaysRun = true is mandatory. Without it, a test failure before tearDown leaves a zombie browser process running — consuming memory and file handles until the CI agent runs out of resources.

This strategy is correct, safe, and predictable. For a 50-test suite running sequentially, the 2–4 second driver startup cost per test is acceptable. At 500 tests, that's 16–33 minutes of browser startup alone.

Strategy 2: ThreadLocal driver (parallel-safe)

Covered in the Singleton lesson — revisited here as the cornerstone of parallel driver management. ThreadLocal<WebDriver> gives each thread its own independent driver instance. Thread A's getDriver() returns Thread A's driver; Thread B's returns Thread B's. No sharing, no interference.

public class DriverManager {
    private static final ThreadLocal<WebDriver> DRIVER = new ThreadLocal<>();
 
    public static WebDriver getDriver() {
        if (DRIVER.get() == null) {
            DRIVER.set(DriverFactory.create(Config.browser()));
        }
        return DRIVER.get();
    }
 
    public static void quitDriver() {
        WebDriver d = DRIVER.get();
        if (d != null) {
            d.quit();
            DRIVER.remove();   // prevents memory leak in thread pools
        }
    }
}

Enable TestNG parallelism in the suite XML:

<suite name="Regression" parallel="methods" thread-count="4">
    <test name="All Tests">
        <packages>
            <package name="com.mycompany.tests"/>
        </packages>
    </test>
</suite>

Four threads, four independent ChromeDriver instances, four times the throughput. The thread-count ceiling is your machine's available memory and CPU cores — a browser process typically consumes 200–400 MB RAM. On CI, 4–8 threads per agent is a common sweet spot.

Strategy 3: Remote WebDriver / Selenium Grid (distributed)

When parallel threads on one machine aren't enough, distribute tests across a Grid of nodes. Each node runs a browser; the Grid hub routes requests from multiple test threads across multiple machines.

// Remote driver — connects to Grid hub
public static WebDriver createRemote(String browser) {
    DesiredCapabilities caps = new DesiredCapabilities();
    caps.setBrowserName(browser);
    try {
        return new RemoteWebDriver(new URL(Config.gridUrl()), caps);
    } catch (MalformedURLException e) {
        throw new RuntimeException("Invalid Grid URL: " + Config.gridUrl(), e);
    }
}

Cloud providers (BrowserStack, Sauce Labs, LambdaTest) are Selenium Grid equivalents with browser/OS combination matrices and built-in video recording:

// BrowserStack remote driver
ChromeOptions opts = new ChromeOptions();
HashMap<String, Object> bsOptions = new HashMap<>();
bsOptions.put("os", "Windows");
bsOptions.put("osVersion", "11");
bsOptions.put("buildName", System.getenv("BUILD_ID"));
opts.setCapability("bstack:options", bsOptions);
return new RemoteWebDriver(new URL(Config.bstackUrl()), opts);

Driver management strategies — when to use each

Per-test driver

  • New driver created for every test method

  • Perfect isolation — zero shared state

  • Safe for sequential and parallel execution

  • 2–4 sec startup cost per test

  • Best for: suites under 100 tests

ThreadLocal driver

  • One driver per thread, reused across tests

  • Parallel-safe — threads never share a driver

  • driver.remove() required to prevent leaks

  • Saves startup time in parallel runs

  • Best for: 100–2000 tests, multi-thread CI

Selenium Grid / Cloud

  • Tests distributed across many machines

  • Browser/OS matrix coverage

  • Built-in video and screenshot capture

  • Cloud providers: BrowserStack, Sauce Labs

  • Best for: 2000+ tests, cross-browser suites

Driver lifecycle decisions

Per-method vs per-class. Creating the driver in @BeforeMethod gives full isolation at the cost of startup time per test. Creating it in @BeforeClass shares one driver across all tests in the class — faster, but any test that leaves the browser in an unexpected state affects the next test. @BeforeMethod is the safer default; @BeforeClass is acceptable only for read-only test classes where state mutation is impossible.

Headless in CI. Browser UI rendering requires a display server. CI agents typically have none. Run headless in CI (--headless=new for Chrome, --headless for Firefox) and headed locally:

private static ChromeOptions buildChromeOptions() {
    ChromeOptions opts = new ChromeOptions();
    opts.addArguments("--no-sandbox", "--disable-dev-shm-usage");
    if (Boolean.parseBoolean(System.getenv().getOrDefault("CI", "false"))) {
        opts.addArguments("--headless=new", "--window-size=1920,1080");
    }
    return opts;
}

The explicit --window-size in headless mode is non-optional. Without it, Chrome defaults to 800×600 — elements positioned for 1920-wide viewports are either off-screen or in a different layout, causing locators to miss.

Driver warmup. For test classes that share a driver (@BeforeClass), a warmup navigation before the first test ensures the browser session is fully initialised:

@BeforeClass
public void warmUp() {
    DriverManager.getDriver().get(Config.baseUrl());
    new WebDriverWait(DriverManager.getDriver(), Duration.ofSeconds(5))
        .until(d -> !d.getCurrentUrl().equals("about:blank"));
}

⚠️ Common mistakes

  • Calling driver.quit() in @AfterMethod without alwaysRun = true. When a test fails, TestNG's default behaviour skips @AfterMethod if you don't set alwaysRun. The browser is orphaned. In a 200-test parallel suite, this fills the CI agent with zombie processes within minutes.
  • Passing driver as a constructor argument to page objects before it's fully initialised. If new LoginPage(driver) is called in a @BeforeClass that runs before @BeforeMethod creates the driver, driver is null and the page object stores a null reference. Always construct page objects after the driver is guaranteed to exist.
  • Increasing thread count without checking memory. Four threads on a machine with 4 GB RAM, where each Chrome process needs 400 MB, leaves 2.4 GB for the JVM, the OS, and other processes. The suite becomes slower as the OS starts swapping, not faster. Profile memory before scaling thread count.

🎯 Practice task

Implement and validate parallel driver management — 35 minutes.

  1. Baseline timing. Run your test suite sequentially and time it. Note the average per-test time.
  2. Implement DriverManager with ThreadLocal. Wire it into @BeforeMethod and @AfterMethod(alwaysRun = true). Run the suite sequentially first — confirm all tests pass.
  3. Enable parallel execution. Set parallel="methods" thread-count="3" in TestNG XML. Run the suite. Confirm all tests pass. If any fail with NoSuchSessionException, check for static driver fields in test classes — those must move to ThreadLocal.
  4. Time the parallel run. Compare the wall-clock time against the sequential baseline. Expect roughly 2.5–3× speedup for 3 threads on I/O-bound tests. Less than 2× suggests a bottleneck (shared database, slow test data creation).
  5. Verify teardown. After the parallel suite completes, check for orphan browser processes (ps aux | grep chrome on macOS/Linux, Task Manager on Windows). Zero orphans means driver.remove() and alwaysRun = true are working correctly.

Next lesson: screenshot and video capture on failure — how to give every CI failure a visual record without any changes to individual test methods.

// tip to track lessons you complete and pick up where you left off across devices.