Your suite is green. Someone sets thread-count="4" to get CI under ten minutes, and now
three tests fail. Re-run: two fail, and not the same two. Run it locally and everything
passes.
That's not flakiness in your application. That's your test framework being unsafe under
concurrency, and it was unsafe the whole time — one thread was just hiding it.
The advice you'll find in ten minutes of searching is "wrap the WebDriver in a
ThreadLocal." That's correct, and it's about a third of the answer. Parallel execution
exposes at least four distinct bugs, and ThreadLocal fixes exactly one of them. This
post covers all four, what the real fix looks like for each, and how to check your own
suite without waiting for CI to tell you.
Why a shared WebDriver breaks
WebDriver is not thread-safe, and the Selenium project has never claimed otherwise. A
driver instance is a stateful handle to one browser session: one current URL, one active
window, one set of cookies. Two threads holding the same reference are two tests driving
one browser.
The classic version looks like this, and almost every home-grown framework has shipped it
at some point:
public class BaseTest {
protected static WebDriver driver; // ← the bug
@BeforeMethod
public void setUp() {
driver = new ChromeDriver();
}
@AfterMethod
public void tearDown() {
driver.quit();
}
}
On one thread this is fine. On four, setUp() runs four times and the last assignment
wins — so all four tests drive whichever browser was created last, while the other three
sit open and idle until the JVM exits. Then tearDown() fires four times on that one
driver, and three of them throw.
The failures this produces are genuinely confusing, because none of them look like a
concurrency bug:
-
NoSuchElementExceptionon an element that is definitely on the page — because another thread navigated away between yourget()and yourfindElement(). -
NoSuchSessionException/SessionNotCreatedException— another thread already calledquit()on the session you're using. - Assertions that fail with the other test's data, especially anything reading a logged-in user's name.
- Chrome processes surviving the build and slowly eating the agent's RAM.
Making the field non-static doesn't fix it either if anything else in the suite reaches for
a driver statically — listeners and screenshot helpers are the usual culprits, and we'll
come back to those.
Bug 1 — the shared driver: ThreadLocal ownership, done properly
The fix is to give each thread its own driver and make it impossible to reach another
thread's. ThreadLocal<WebDriver> does that: every thread that calls get() sees only the
value that thread set.
public final class DriverFactory {
private static final ThreadLocal<WebDriver> DRIVER = new ThreadLocal<>();
private DriverFactory() {}
public static void create() {
if (DRIVER.get() != null) return; // idempotent: safe under retry
DRIVER.set(new ChromeDriver());
}
public static WebDriver get() {
WebDriver driver = DRIVER.get();
if (driver == null) {
throw new IllegalStateException(
"No WebDriver on thread " + Thread.currentThread().getName()
+ ". create() was never called, or quit() already ran.");
}
return driver;
}
public static void quit() {
WebDriver driver = DRIVER.get();
if (driver != null) {
try {
driver.quit();
} finally {
DRIVER.remove(); // ← the line people forget
}
}
}
}
Three details in there matter more than the ThreadLocal itself.
DRIVER.remove() is not optional. This is the single most common way a "thread-safe"
driver factory still breaks. Test runners reuse threads — TestNG's thread pool, JUnit 5's
parallel executor, and every CI agent that runs more than one suite per JVM. If you call
quit() without remove(), the next test scheduled onto that thread inherits a
ThreadLocal entry pointing at a dead session, and you get NoSuchSessionException from a
test that never did anything wrong. Worse, on a long-lived JVM the entries are a genuine
memory leak: ThreadLocalMap keys are weak, values are not.
The null check needs to name the thread. When this does go wrong, the difference
between NullPointerException and a message telling you which thread had no driver is
about forty minutes of debugging.
Idempotent creation buys you retries. If a retry listener re-runs a failed test on the
same thread, create() firing twice must not orphan a browser. Returning early when a
driver already exists is the cheap version; explicitly quitting the old one is the thorough
version. Pick one deliberately.
Bug 2 — shared state that isn't the driver
ThreadLocal on the driver fixes the driver. It does nothing for everything else your
suite shares, and this is where the second wave of failures comes from once the obvious one
is gone.
Look for these three:
Page objects with mutable fields. A page object that caches a WebElement, or stores
"the row I just clicked," is fine on one thread and a race on four. Page objects should
hold a driver reference and nothing else — every piece of per-test state belongs in the
test, or in a thread-scoped context object.
Static test data. A static String orderId written by one test and read by another is
the most common non-driver race. So is a shared SimpleDateFormat (not thread-safe), a
shared HashMap used as a scratchpad, and a static counter used to generate "unique"
usernames without an atomic type.
Anything a listener writes to. A TestNG ITestListener or JUnit 5 extension is invoked
on the test's own thread, which is convenient — but if it accumulates into a plain
ArrayList<Result>, four threads appending concurrently will silently drop entries or
throw ArrayIndexOutOfBoundsException from inside ArrayList.add. Use a concurrent
collection, or a per-thread accumulator merged at the end.
The rule that survives contact with real suites: anything a test writes to must be
thread-scoped or thread-safe. There is no third option. If you can't say which of the two
a field is, it's a bug you haven't hit yet.
Bug 3 — screenshots and reports attributed to the wrong test
This one doesn't fail the build, which is why it survives so long. It corrupts your
evidence instead.
A failure screenshot helper that reaches for a driver via a static field, or writes to a
fixed filename like target/failure.png, will happily capture thread 3's browser for
thread 1's failure. You then spend an afternoon debugging a screenshot of a page the failing
test never visited.
Both halves need fixing:
-
Capture from the failing thread's driver — the listener runs on the test's thread, so
pull from your
ThreadLocal, not from a field someone assigned during setup. -
Write to a unique path — include the test method name and something disambiguating
(a timestamp, or the invocation count for data-driven tests). Two
@DataProviderrows of the same method failing at once will otherwise overwrite each other.
The same applies to any report aggregator. If you're using ExtentReports, the per-test node
has to be held in a ThreadLocal too, or your steps interleave across tests into an
unreadable report.
Bug 4 — more browsers than the machine or grid can hold
Thread count and browser count are not the same number, and treating them as one is how
parallel suites go from "flaky" to "the agent fell over."
Chrome is roughly 300–700 MB of RSS per instance with a real page loaded. Eight threads on
a 4 GB CI container is not a thread-safety bug — it's the OOM killer, and it shows up as
SessionNotCreatedException, chrome not reachable, or a build that simply dies with no
useful log. Against a remote Grid or a cloud vendor, the same over-subscription shows up as
queued sessions that time out, or as sessions your plan won't grant.
So cap the concurrency separately from the thread count.
Cap concurrency separately from thread count
The pattern is a semaphore between the test and driver creation. Threads that can't get a
slot wait rather than fail:
private static final Semaphore SLOTS = new Semaphore(4, true); // fair
public static void create() {
if (DRIVER.get() != null) return;
try {
if (!SLOTS.tryAcquire(30, TimeUnit.SECONDS)) {
throw new IllegalStateException("No browser slot free after 30s");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Interrupted waiting for a browser slot", e);
}
DRIVER.set(new ChromeDriver());
}
and release the permit in quit(), in a finally. The true argument makes the semaphore
fair, which stops one unlucky thread from starving while others cycle through.
Why wait instead of fail fast? Because a test that waits four seconds for a slot is a
correct test. A test that fails because the machine was momentarily busy is a false red,
and false reds are how teams learn to ignore CI.
Set the cap from what the environment can hold, not from what the suite would like. On CI
that's usually memory divided by ~700 MB, minus one. On a cloud grid it's your plan's
concurrency limit.
The bug ThreadLocal can't touch: waits
Parallel execution doesn't create wait bugs. It exposes the ones you already had.
A sleep is calibrated against a load profile. Thread.sleep(2000) was chosen on an
idle laptop talking to a warm backend. Run four browsers on one agent and that machine no
longer exists — same code, different CPU contention, different response times. Sleeps also
cost you on every green run: forty sleeps × 2 seconds is eighty seconds per thread that you
pay even when the page was ready in 200 ms.
Mixing implicit and explicit waits is undefined behaviour. Selenium says so in its own
docs — set implicitlyWait and then use WebDriverWait, and the two compose in ways
neither one documents. You ask for 5 seconds and wait 15, or you get a NoSuchElementException
before your explicit condition ever polls. Under load that unpredictability widens, because
the driver is retrying against a slower page. Set the implicit wait to zero and use explicit
waits only.
"Visible" is not "loaded." A wait for element visibility tells you something rendered.
It doesn't tell you the table has rows. Under parallel the gap between rendered and
populated gets wider, because your backend is serving four suites at once. Wait for the
condition you actually mean: row count above zero, spinner detached, text equal to what you
expect.
Explicit, condition-based waits are the only kind that degrade gracefully. A polling wait on
a busy machine just takes longer. A sleep tuned for an idle machine simply becomes wrong.
Audit your own suite: seven checks
Run these against your repo before you raise the thread count again. Each one takes a minute
except the last, which takes a CI run and is worth more than the other six.
-
No
static WebDriveranywhere.grep -rn "static WebDriver" src/— every hit is a bug, including the "temporary" one in a utility class. -
Your driver holder calls
remove(). Open the class that owns theThreadLocaland search for.remove(). No hit means stale sessions on reused threads plus a slow leak. -
No mutable, non-thread-safe fields on shared objects. Walk your page objects and
step-definition classes: a field should be
finaland thread-safe, or it shouldn't be a field. -
Count your sleeps.
grep -rn "Thread.sleep" src/ | wc -l— that number is how sensitive your suite is to machine load. -
Screenshot paths carry the test name. Look at the artifacts from your last red build.
If you see
failure.pngorscreenshot (3).png, your evidence isn't attributable. - The browser cap is a separate number from the thread count. Search your config for both. If there's only one number, the two are coupled and one of them is wrong for your CI box.
- Run the whole suite twice at your target thread count. Different failures each time means shared state. The same failures both times means real bugs — or a genuinely flaky application. This single check separates those two categories better than any amount of reading.
One answer: don't own the lifecycle at all
Everything above is roughly two hundred lines of infrastructure that has nothing to do with
your application. A driver holder, a semaphore, a screenshot listener that knows about
threads, a config knob for concurrency. It gets written once, reviewed once, and then
maintained — across Selenium upgrades, across a CI migration, and again at the next company.
That's the maintenance tax, and it's the same argument as
The Hidden Cost of Every Selenium Framework You've Built.
One way out is to not own that layer. Selenium Boot — the Spring Boot of Selenium — ships
these fixes as the default path: DriverManager is a ThreadLocal holder that calls
remove() on quit; concurrent sessions are capped by a fair Semaphore sized from
execution.maxActiveSessions, and a thread that can't get a slot waits up to 30 seconds
instead of failing; screenshots and report nodes resolve from the calling thread. It never
hides Selenium — getDriver() returns the real WebDriver, and you can drop to raw
By / WebElement the moment the conventions don't fit.
The configuration is the whole of the setup — this is all of selenium-boot.yml:
execution:
mode: local
baseUrl: https://example.com
parallel: methods # none | methods | classes
threadCount: 4
maxActiveSessions: 4 # fair semaphore; tests wait up to 30s for a slot
browser:
name: chrome
headless: true
timeouts:
explicit: 10
pageLoad: 30
One honest limit: this covers the framework's own state. It cannot make your static
orderId thread-safe. Bug 2 stays your job under any framework, including this one.
More detail in the parallel execution guide
and the configuration reference,
or the why it exists page if you want the design argument first.
When building it yourself is the right call
Plenty of teams should just write the code above.
If you have one suite, one team, and no parallel ambition beyond four threads, thirty lines
of ThreadLocal is genuinely enough — adopting a framework to avoid it is a bad trade. If
you have a hard constraint no framework will match, like a bespoke driver provider or an
unusual grid topology, own the layer that touches it. And if your team wants to learn
concurrency properly, building it is a good way to do that; the debugging is the point.
None of this is a Playwright argument. If you're greenfield with no Selenium investment,
evaluate Playwright on its merits. This post is for the suites that already exist.
The failure is in the framework, not the app
"Green on one thread, red on four" is a property of your test framework, not your
application. It's a shared driver, or shared state that isn't the driver, or evidence
captured from the wrong thread, or more browsers than the machine can hold — and now you can
tell which. Fix the four, then raise the thread count on purpose instead of hopefully.
Top comments (0)