skip to content

How would you design a ThreadLocal WebDriver holder so a forgotten remove() cannot leak a Selenium session?

level: principalimportance: should knowfreq 36%

answer

  1. Do not rely on remembering the call
  2. Shrink what test code is allowed to call
  3. One pair owns creation and destruction
  4. Quit in try, clear in finally
  5. withInitial hides the mistake it should surface

basics

~20 s

Make the holder own both ends. Hide set and remove behind one start and stop pair whose stop quits inside a try and clears inside a finally, and make the getter throw rather than return null.

solid answer

~50 s

Treat the forgotten `remove()` as an API defect rather than a discipline problem. Keep the `ThreadLocal` field private and expose three operations — `start()`, `current()` and `stop()` — so no test can call `set()` or `remove()` itself. `stop()` calls `quit()` inside a `try` and `remove()` inside the `finally`, so a driver that fails to quit still clears its entry. Prefer an explicit `set()` in `start()` over `ThreadLocal.withInitial(ChromeDriver::new)`: with a supplier, a stray `get()` after teardown silently opens a browser nothing will quit, whereas a plain holder can throw an error naming the thread. Wire `stop()` into one shared teardown that every case inherits rather than copies in each class. Then make the invariant checkable: assert the holder is empty at the start of each setup, and compare sessions started against sessions stopped at the end of the run.

code

java · 30 lines
java
import java.util.function.Supplier;
import org.openqa.selenium.WebDriver;

public final class TrackerSession {

  private static final ThreadLocal<WebDriver> HOLDER = new ThreadLocal<>();

  private TrackerSession() {}

  public static WebDriver current() {
    WebDriver driver = HOLDER.get();
    if (driver == null) {
      throw new IllegalStateException("No driver on " + Thread.currentThread().getName());
    }
    return driver;
  }

  public static void run(Supplier<WebDriver> factory, Runnable body) {
    HOLDER.set(factory.get());
    try {
      body.run();
    } finally {
      try {
        HOLDER.get().quit();
      } finally {
        HOLDER.remove();
      }
    }
  }
}

go deeper

for a junior

You are not expected to design the holder. Know that the driver comes from a shared helper and that you should never call set or remove yourself in a test class.

for a middle

Explain why the clear belongs in a finally block and what a getter should do when the holder is empty. Being able to read and follow the holder's code is enough here.

for a senior

Show that you would close the hole in the harness rather than in each case: one shared teardown, a getter that fails loudly, and a check that the entry is empty when a case starts.

for a principal

Own the tradeoff between a holder that recovers silently and one that fails loudly, and define the end-of-run evidence that proves no session was left open by the suite.

## Why "remember to call remove()" is not a design The forgotten `remove()` has every property of a defect that discipline cannot fix. It is invisible in a serial run, invisible on a runner that creates a thread per case, and its blast radius lands on an unrelated case running later on the same pooled thread. Nobody reviewing the case that caused it sees anything wrong, and whoever debugs the case that suffers it is looking at the wrong file. A rule on a wiki page will not survive that. So the design goal is narrow and testable: **it must not be possible for test code to leave a driver in the holder.** That is achieved by shrinking what test code is allowed to call, by ordering the two teardown operations so an exception cannot skip the clear, and by making the invariant something the suite checks rather than something a reviewer remembers. ## The holder's public surface Keep the `ThreadLocal` field `private static final` and expose the smallest possible API: - **`start(...)`** — the only path that creates a session and the only caller of `set()`. - **`current()`** — returns the driver for the calling thread, and **throws** rather than returning `null`, with a message naming the thread. - **`stop()`** — quits and clears, in that order, with the clear in a `finally`. Everything else stays out of reach. No test class calls `set()` or `remove()`, so no test class can get the pair wrong. Where the runner allows it, the strongest form collapses the three into one scoped call that sets, runs the body and cleans up in its own `finally`; where the runner offers only before-and-after hooks, the `start()`/`stop()` pair wired into one shared fixture every case inherits is the equivalent. What it must never be is a snippet copied into each test class, because copies drift. Note that this design says nothing about **how long** a session should live — per case, per class or per worker. Whatever that decision is, the holder's contract is the same: one owner starts, one owner stops. ## withInitial versus an explicit set This is the real judgment call, and both answers are defensible. | | `ThreadLocal.withInitial(ChromeDriver::new)` | plain holder plus an explicit `set()` | |---|---|---| | `current()` when nothing started | creates a driver and returns it | throws, naming the thread | | Ordering bug in fixtures | absorbed silently | surfaces immediately | | A stray `get()` after teardown | starts a browser nothing will quit | fails loudly, leaks nothing | | Distinguishes "not started" from "finished" | no | yes | | Cost | leaks are silent and cumulative | a fixture ordering mistake fails the case | The supplier form is attractive because `get()` can never return `null` and no fixture needs to run before another. The price is that the holder loses the ability to tell *not started yet* from *already finished*: a listener, a late helper or a retry hook that calls `get()` after `stop()` re-runs the supplier and opens a browser that no teardown will ever close. For a suite whose problem is leaked sessions, **failing loudly is the safer default** — the ordering bug it exposes is cheap to fix, and the leak it prevents is not. ## Ordering: quit inside try, remove inside finally The two operations act on different things — `quit()` ends the session on the remote end, `remove()` drops this thread's reference — and only one of them can fail. `quit()` reaches over the wire and can throw on an unreachable browser or a session the remote end already reclaimed. Written as two plain statements, that throw skips the clear and hands a dead driver to the next case on the pooled thread, which then fails with `NoSuchSessionException` on its first command. Wrapping the quit in `try` and the remove in `finally` makes the clear unconditional. It is worth noting that a second `quit()` on the same instance is harmless in Selenium 4 — a `RemoteWebDriver` whose session id is already null returns immediately — so a defensively written `stop()` costs nothing when it runs twice. ## Making the invariant checkable A design you cannot verify is a hope. Three cheap checks turn this one into evidence: 1. **Assert the holder is empty at the start of every setup.** A non-null value there proves the previous case on that thread leaked, and it fails the case that would otherwise have inherited the problem. 2. **Count `start()` against `stop()` for the whole run** and fail the run when they differ. The difference is the number of leaked sessions, which is a far better report line than a flaky case. 3. **Log the thread name beside `RemoteWebDriver.getSessionId()`** at both ends, so when the counter does disagree you can name the case that failed to stop. ## What the holder still cannot buy you Be honest about the residual risk. A holder cannot clean up after a process killed mid-run, so a cancelled job still leaves browsers behind. It cannot help a helper that spawns its own thread, and reaching for `InheritableThreadLocal` to fix that is a mistake: the child receives the same `WebDriver` reference rather than a copy, putting two threads on one session. Nor does it make a suite parallel-safe on its own; it removes exactly one failure mode, a driver outliving the case that opened it.

  • What is the argument for ThreadLocal.withInitial(ChromeDriver::new) despite that risk?
    It removes a whole failure mode: the getter can never return null, so a helper that runs before setup still works and no fixture ordering contract is needed. The price is that the holder can no longer tell not-started from already-finished, so a stray read after teardown opens a session nobody will close.
  • How would you prove at the end of a run that no thread still holds a driver?
    Count it rather than inspect it. Have the holder increment one counter on `start()` and another on `stop()`, and fail the run when they differ. Pair that with an assertion in setup that the current thread's entry is empty, which catches the leak at the case that would otherwise have inherited it.
  • Would InheritableThreadLocal make the holder easier to use?
    It would make it more dangerous. A child thread receives the same `WebDriver` reference rather than a copy, so a helper that spawns a thread puts two threads on one session, which is the failure the holder exists to prevent. Keep the holder plain and keep driver calls on the case's own thread.

saying these in an interview costs you the question

  • Treats a forgotten remove as a code-review problem rather than an API one
  • Leaves set and remove public so any test class can call them
  • Puts quit and remove in plain sequence with no finally between them
  • Reaches for InheritableThreadLocal so helper threads can see the driver
  • Adds withInitial so the getter never returns null, hiding a missing setup