PRACTICAL GUIDE / CompletableFuture WebDriver commands same session
Why CompletableFuture corrupts a shared WebDriver session
See why asynchronous Java tasks scramble commands on one WebDriver, then choose safe serialization or separate sessions and prove the fix in CI.
In this guide7 sections
- Why asynchronous syntax changes command order
- Reproduce the race before choosing a fix
- Second worked failure: a completion hook quits a live sibling
- Serialize a complete interaction when one session is required
- Use separate sessions when work is actually parallel
- Tell concurrency apart from similar failures
- Separate an application redirect from a competing future
- Read the ownership fields before the exception text
- Migrate without turning every test async
What you will learn
- Why asynchronous syntax changes command order
- Reproduce the race before choosing a fix
- Second worked failure: a completion hook quits a live sibling
- Serialize a complete interaction when one session is required
Two futures share one RemoteWebDriver. One navigates to the cart while the other reads the account heading, and the failure says the heading does not exist. Rerunning changes the exception to NoSuchWindowException. The browser is following both task streams; the source code only makes them look separate.
Why asynchronous syntax changes command order
A WebDriver object represents one remote browser session with one current window, one current frame, one page, and one set of cookies. Commands such as get, switchTo, findElement, click, and quit all mutate or depend on that shared state. CompletableFuture does not create another session when it runs a lambda. It only schedules Java work.
The tempting implementation is short:
RemoteWebDriver driver = new RemoteWebDriver(
new URL(System.getenv("GRID_URL")),
new ChromeOptions()
);
CompletableFuture<String> cartTotal = CompletableFuture.supplyAsync(() -> {
driver.get("https://shop.example.test/cart");
return driver.findElement(By.id("total")).getText();
});
CompletableFuture<String> accountName = CompletableFuture.supplyAsync(() -> {
driver.get("https://shop.example.test/account");
return driver.findElement(By.id("customer-name")).getText();
});
CompletableFuture.allOf(cartTotal, accountName).join();
System.out.println(cartTotal.join());
System.out.println(accountName.join());
driver.quit();Without an explicit executor, these asynchronous suppliers normally use Java's common fork-join pool. Either worker can issue its navigation first. More importantly, the first navigation may finish, then the other task may navigate before the first task looks up its element. The lookup runs against whichever page is current at that moment.
allOf is only a completion barrier. It does not serialize the suppliers. Calling join in a particular order also does not order work that has already started. A debugger can make the test pass because pausing one worker changes the interleaving.
Remote execution adds another layer of uncertainty. Each Java thread sends commands through the client to the same session endpoint. Network timing and server processing decide which request arrives first. Even if the remote end processes commands one at a time, it cannot infer that navigation and the following lookup were meant to be an indivisible transaction. Serialization at the protocol boundary is not the same as sequencing at the test-intent boundary.
Element references make the symptom noisy. findElement returns a remote reference tied to the document in which Selenium found it. If another future navigates, that reference may become stale. If another task closes the selected window, the first task may receive NoSuchWindowException. If one future calls quit in its completion handler, every other stage can fail with NoSuchSessionException. These messages name the state that disappeared, not the thread that changed it.
ThreadLocal does not rescue this design. A ThreadLocal driver set by the JUnit test thread is associated with that thread. A common-pool worker is a different thread and will usually see no value. Code that falls back to a global driver when ThreadLocal.get returns null silently recreates the sharing bug.
Non-async continuation methods need care too. thenApply may run on the thread that completes the previous stage. thenApplyAsync schedules elsewhere unless an executor is supplied. A chain can express order, but only when every command-producing stage belongs to that chain. One unchained future can still enter the session halfway through.
The Selenium ThreadGuard documentation describes a useful detector for Java. It rejects access from a thread other than the one that created the protected driver. It detects cross-thread use early, but it does not provide a driver per thread and it does not turn concurrent access into safe access.
Reproduce the race before choosing a fix
Intermittent UI failures are expensive to diagnose when command ownership is absent from logs. Record the session ID, task label, thread name, command boundary, and a monotonic sequence generated in the test process. The sequence does not claim server arrival order. It shows the order in which the client began each intended action.
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Supplier;
import org.openqa.selenium.remote.RemoteWebDriver;
public final class CommandProbe {
private final AtomicLong sequence = new AtomicLong();
public <T> T call(
RemoteWebDriver driver,
String task,
String command,
Supplier<T> action) {
long number = sequence.incrementAndGet();
String session = String.valueOf(driver.getSessionId());
String thread = Thread.currentThread().getName();
System.out.printf(
"seq=%06d phase=start session=%s task=%s thread=%s command=%s%n",
number, session, task, thread, command
);
try {
T result = action.get();
System.out.printf(
"seq=%06d phase=end session=%s task=%s thread=%s command=%s%n",
number, session, task, thread, command
);
return result;
} catch (RuntimeException failure) {
System.out.printf(
"seq=%06d phase=error session=%s task=%s thread=%s command=%s type=%s%n",
number,
session,
task,
thread,
command,
failure.getClass().getSimpleName()
);
throw failure;
}
}
}Wrap whole logical steps, not only findElement. A useful failing log looks like this:
seq=000021 phase=start session=1a94 task=cart thread=ForkJoinPool.commonPool-worker-1 command=open-cart
seq=000022 phase=start session=1a94 task=account thread=ForkJoinPool.commonPool-worker-2 command=open-account
seq=000022 phase=end session=1a94 task=account thread=ForkJoinPool.commonPool-worker-2 command=open-account
seq=000023 phase=start session=1a94 task=account thread=ForkJoinPool.commonPool-worker-2 command=read-name
seq=000021 phase=end session=1a94 task=cart thread=ForkJoinPool.commonPool-worker-1 command=open-cart
seq=000024 phase=start session=1a94 task=cart thread=ForkJoinPool.commonPool-worker-1 command=read-total
seq=000024 phase=error session=1a94 task=cart thread=ForkJoinPool.commonPool-worker-1 command=read-total type=NoSuchElementExceptionThe key evidence is not merely two thread names. Both tasks use session 1a94, and account navigation begins while the cart operation is in flight. If each task has a different session ID, the same exception needs another explanation.
Force the race in a small diagnostic test rather than adding sleeps to the production suite. A CountDownLatch can release two workers together, making overlap likely. Run it repeatedly against a disposable account and a disposable Grid. The goal is to prove that shared ownership exists, not to depend on one exact exception.
@Test
void demonstratesThatTwoTasksShareOneSession() throws Exception {
RemoteWebDriver driver = new RemoteWebDriver(
new URL(System.getenv("GRID_URL")),
new ChromeOptions()
);
ExecutorService pool = Executors.newFixedThreadPool(2);
CountDownLatch start = new CountDownLatch(1);
try {
CompletableFuture<String> first = CompletableFuture.supplyAsync(() -> {
await(start);
driver.get("https://example.test/first");
return driver.getCurrentUrl();
}, pool);
CompletableFuture<String> second = CompletableFuture.supplyAsync(() -> {
await(start);
driver.get("https://example.test/second");
return driver.getCurrentUrl();
}, pool);
start.countDown();
List<String> observed = List.of(first.join(), second.join());
System.out.println("session=" + driver.getSessionId());
System.out.println("observed=" + observed);
} finally {
pool.shutdownNow();
driver.quit();
}
}
private static void await(CountDownLatch latch) {
try {
latch.await();
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new CompletionException(interrupted);
}
}Do not assert which URL wins. That would turn the race into a brittle test. Assert the architectural rule elsewhere, for example by protecting a driver with ThreadGuard or by ensuring the session factory is called once per concurrent scenario.
CI changes timing, so preserve the diagnostic log when the test fails. Search by session ID and sort by the client sequence. Then compare timestamps with Grid events for the same session. A Grid log proves which commands reached the remote side; the client probe proves which task issued them.
Second worked failure: a completion hook quits a live sibling
Navigation races are not the only way futures damage one session. Cleanup attached to one stage can end the session while another stage still has accepted work. This often appears after a team adds whenComplete to guarantee quit on failure. The callback guarantees only that its own upstream stage has completed. It says nothing about sibling stages that happen to share the driver.
This deliberately broken example uses latches instead of sleeps. The profile task announces that it is ready, the cart task completes and triggers quit, then the profile task is released to issue its command. It reuses the await helper from the earlier reproduction.
RemoteWebDriver driver = new RemoteWebDriver(
new URL(System.getenv("GRID_URL")),
new ChromeOptions()
);
String sessionId = String.valueOf(driver.getSessionId());
ExecutorService pool = Executors.newFixedThreadPool(2);
CountDownLatch profileIsReady = new CountDownLatch(1);
CountDownLatch profileMayRead = new CountDownLatch(1);
try {
CompletableFuture<String> profile = CompletableFuture.supplyAsync(() -> {
System.out.printf(
"task=profile phase=ready session=%s%n", sessionId
);
profileIsReady.countDown();
await(profileMayRead);
System.out.printf(
"task=profile command=findElement session=%s%n", sessionId
);
return driver.findElement(By.id("display-name")).getText();
}, pool);
CompletableFuture<Void> cart = CompletableFuture.runAsync(() -> {
await(profileIsReady);
driver.get("https://shop.example.test/cart");
System.out.printf(
"task=cart phase=complete session=%s%n", sessionId
);
}, pool);
CompletableFuture<Void> cartWithCleanup = cart.whenComplete(
(ignored, cartFailure) -> {
System.out.printf(
"task=cart-cleanup command=quit session=%s%n", sessionId
);
try {
driver.quit();
} finally {
profileMayRead.countDown();
}
}
);
CompletableFuture.allOf(profile, cartWithCleanup).join();
} catch (CompletionException batchFailure) {
System.err.println(
"batchCause=" + batchFailure.getCause().getClass().getName()
);
} finally {
pool.shutdown();
}On a run where quit succeeds, the diagnostic sequence ends like this:
task=profile phase=ready session=51e7
task=cart phase=complete session=51e7
task=cart-cleanup command=quit session=51e7
task=profile command=findElement session=51e7
batchCause=org.openqa.selenium.NoSuchSessionExceptionThe short session ID is illustrative. Selenium documents NoSuchSessionException as the result of calling a command after quit. join() reports exceptional completion through CompletionException, so the Selenium exception is its cause. A full WebDriverException printout also includes binding and host details, which vary by Selenium version and environment.
There is another reporting trap in the cleanup callback itself. If cart completed normally and quit throws, the future returned by whenComplete completes exceptionally with the cleanup exception. If cart had already completed exceptionally and quit also throws, the whenComplete contract keeps the upstream exception as the returned stage's failure. Unlike try-with-resources, this API does not promise to attach the callback failure as a suppressed exception. Log a quit attempt and its failure at the session owner, or use a lexical resource boundary, instead of assuming the future graph will preserve both errors.
This failure has stronger evidence than a generic missing element. The same session ID appears in both tasks, the delete-session action precedes the rejected command, and Grid records deletion before the later request. If the node disappears without a delete-session command, investigate a browser or node crash instead. If the rejected command carries another session ID, this cleanup hook did not kill it.
ThreadGuard can change what fails first. The driver above is constructed outside both workers, so a protected driver rejects worker access before this sequence reaches quit. That immediate thread-safety exception is useful in a real migration. Keep this unprotected reproducer only as a disposable demonstration of the lifecycle race. It is intentionally showing code that must not ship.
Changing whenComplete to whenCompleteAsync does not fix ownership. It schedules cleanup through an asynchronous execution facility, which adds another possible thread but no knowledge of sibling work. Attaching cleanup to allOf waits for the listed stages, yet it still leaves the more fundamental defect if those stages issue commands to one driver from different threads. A completion graph is not a WebDriver transaction or a session owner.
The repair depends on the intended workflow. If both interactions belong to one browser journey, submit both as complete operations to one session lane and let the lane close after it stops accepting work and drains its queue. If they are independent checks, let each future construct and quit its own driver. In either design, exactly one owner decides when quit is legal. A leaf future may report its result, but it must not terminate a session borrowed by its siblings.
Add this case to lifecycle contract tests when removing scattered cleanup callbacks. Verify that one failed or completed stage cannot close another stage's session, that the aggregate failure retains the underlying Selenium exception, and that every created session is eventually deleted once. The extra coordination code has a cost, so keep ordinary single-browser journeys synchronous when they do not need an asynchronous boundary.
Serialize a complete interaction when one session is required
Some workflows genuinely need one browser. An example is collecting several independent calculations after one expensive login, provided those calculations do not change page state. The safe design gives the session one execution lane and prevents callers from touching the driver directly.
import java.net.URL;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Function;
import org.openqa.selenium.Capabilities;
import org.openqa.selenium.remote.RemoteWebDriver;
public final class SessionLane implements AutoCloseable {
private final ExecutorService executor;
private final CompletableFuture<RemoteWebDriver> driver;
private CompletableFuture<Void> tail;
private boolean closing;
public SessionLane(URL gridUrl, Capabilities capabilities) {
this.executor = Executors.newSingleThreadExecutor(
runnable -> {
Thread thread = new Thread(runnable, "webdriver-session-lane");
thread.setDaemon(false);
return thread;
}
);
this.driver = CompletableFuture.supplyAsync(
() -> new RemoteWebDriver(gridUrl, capabilities),
executor
);
this.tail = driver.handle((value, failure) -> null);
}
public synchronized <T> CompletableFuture<T> submit(
Function<RemoteWebDriver, T> interaction) {
if (closing) {
return CompletableFuture.failedFuture(
new IllegalStateException("Session lane is closing")
);
}
CompletableFuture<T> accepted = tail.thenApplyAsync(
ignored -> interaction.apply(driver.join()),
executor
);
tail = accepted.handle((result, failure) -> null);
return accepted;
}
public CompletableFuture<String> sessionId() {
return submit(
value -> String.valueOf(value.getSessionId())
);
}
@Override
public void close() {
CompletableFuture<Void> quit;
synchronized (this) {
if (closing) {
return;
}
closing = true;
quit = tail.thenRunAsync(
() -> driver.join().quit(),
executor
);
tail = quit.handle((result, failure) -> null);
}
try {
quit.join();
} finally {
executor.shutdown();
}
}
}The tail is the lane's acceptance order, not a second source of browser work. Synchronized submission updates that tail before another caller can submit or begin closure. Each returned future keeps its own result or exception, while handle creates a normal completion token so one failed interaction does not prevent later accepted work from running or prevent quit. Close marks the lane closed and appends quit to the captured tail, so no accepted interaction can be queued behind session deletion.
Callers submit a complete interaction:
try (SessionLane lane = new SessionLane(gridUrl, new ChromeOptions())) {
CompletableFuture<String> cart = lane.submit(driver -> {
driver.get("https://shop.example.test/cart");
return driver.findElement(By.id("total")).getText();
});
CompletableFuture<String> account = lane.submit(driver -> {
driver.get("https://shop.example.test/account");
return driver.findElement(By.id("customer-name")).getText();
});
System.out.println("session=" + lane.sessionId().join());
System.out.println("cart=" + cart.join());
System.out.println("account=" + account.join());
}The two futures are an asynchronous interface to a serial queue. They do not run browser work in parallel. The second interaction starts after the executor finishes the first. This costs throughput and can create queueing inside the test process. Make that cost clear in the API name and metrics.
A simple synchronized block can also serialize work, but its boundary must enclose the whole interaction. Locking get and findElement separately still allows another task to navigate between them. A ReentrantLock has the same requirement. Once the lock covers the entire scenario, a dedicated executor is often easier to observe because it gives the session a named owner thread and a visible queue.
Close only after submitted work has completed or been deliberately cancelled. Cancellation of a CompletableFuture does not guarantee that a WebDriver command already executing on the remote end stops. Calling quit while a navigation is in flight changes a controlled queue into another race. Define shutdown policy: stop accepting work, wait for accepted interactions, then quit.
Use separate sessions when work is actually parallel
Real parallel browser automation means independent sessions. Each concurrent task creates, uses, and quits its own driver. The tasks may share immutable input data, but they must not share page objects, WebElements, waits, window handles, or driver fields.
record BrowserResult(String browser, String title, String sessionId) {}
static BrowserResult checkHomePage(
URL gridUrl,
Capabilities capabilities) {
RemoteWebDriver driver = new RemoteWebDriver(gridUrl, capabilities);
String sessionId = String.valueOf(driver.getSessionId());
try {
driver.get("https://shop.example.test/");
return new BrowserResult(
driver.getCapabilities().getBrowserName(),
driver.getTitle(),
sessionId
);
} finally {
driver.quit();
}
}
ExecutorService pool = Executors.newFixedThreadPool(2);
try {
CompletableFuture<BrowserResult> chrome = CompletableFuture.supplyAsync(
() -> checkHomePage(gridUrl, new ChromeOptions()),
pool
);
CompletableFuture<BrowserResult> firefox = CompletableFuture.supplyAsync(
() -> checkHomePage(gridUrl, new FirefoxOptions()),
pool
);
System.out.println(chrome.join());
System.out.println(firefox.join());
} finally {
pool.shutdown();
}This design consumes two Grid slots and may double provider cost for the duration. It also creates two application sessions, which can matter when the account allows only one login. Use distinct test users or design the API setup so each browser receives isolated credentials.
Parallel sessions should still have bounded concurrency. A fixed thread pool of 100 does not create capacity on a Grid with 10 slots. It creates queueing at the client and at Grid's new-session queue, making timeouts harder to attribute. Size test concurrency from measured Grid capacity and leave headroom for retries and infrastructure checks.
Do not copy a ThreadLocal value into each future. A copied reference is still the same driver. Instead, create the driver inside the task or inside a per-task fixture, then close it in a finally block. Selenium's guidance to avoid shared state is especially important here: static drivers and static page objects turn otherwise independent futures back into one mutable system.
Work that is not browser work can remain parallel. After a task extracts immutable HTML, a screenshot byte array, or a value object, CPU-heavy parsing and report generation can run on another executor. The rule is about live session state, not about banning concurrency from the whole test framework.
Tell concurrency apart from similar failures
A StaleElementReferenceException alone does not prove two futures touched the driver. Modern applications replace DOM nodes after network responses, and one test thread can stale an element through an ordinary rerender. Look for overlapping task intervals on the same session and a navigation or DOM-changing action between element lookup and use.
NoSuchWindowException has several non-concurrent causes. The application may close an OAuth popup, the test may select a window handle that no longer exists, or a browser crash may remove every window. A cross-thread case shows another task calling close, quit, or switchTo on the same session near the failure. Browser and Grid logs can reveal a crash, while the command probe reveals competing ownership.
A timeout may be capacity rather than interleaving. If the failure occurs while constructing RemoteWebDriver and there is no session ID, commands never shared a session. Inspect Grid's new-session queue and capability matching. If the timeout occurs during findElement after two tasks used the same ID, inspect command order.
ThreadGuard failures are strong evidence of cross-thread access, but their absence is not proof of safety unless every driver is protected. ThreadGuard also rejects a carefully serialized executor if the driver was created on a different thread. Create and use the protected driver on its owner thread, or use the dedicated lane without pretending the creation thread owns commands.
A NullPointerException from ThreadLocal.get inside supplyAsync points to context propagation, not necessarily a shared driver. The dangerous fix is a static fallback. Pass explicit immutable context and create an owned session in the worker. Logging thread names and session IDs will show whether the fix produced one ID per task.
Finally, an assertion can fail because the two tasks modify the same application account even with separate browsers. Two sessions adding to one cart is an application-data race. Different session IDs rule out WebDriver sharing, but they do not isolate server-side test data. Give each task a unique account or coordinate the data deliberately.
Separate an application redirect from a competing future
An authentication or application redirect can copy the navigation-race symptom without a second Java task touching the driver. The cart interaction opens its expected URL, the later lookup cannot find the total, and getCurrentUrl() now reports a login or account page. That final state looks like the account future won the race. The different root cause is that the server response or page logic moved the one browser after the cart command.
The command probe separates them at the state-changing interval. A concurrency defect shows another task label issuing a navigation on the same session between the cart navigation and cart lookup. The redirect case shows only the cart task on the command boundary. Its navigation finishes on, or is followed by, the unexpected URL without an open-account command from a sibling. Browser network evidence or an application trace can then establish whether the move was an HTTP redirect or client-side navigation, but the absence of a competing WebDriver command already rules out this CompletableFuture race.
Thread name is misleading in both directions. One future can run every command on a common-pool worker and still have exclusive access, while two tasks can execute sequentially on a reused worker name and still submit the wrong business order. Read session, task, command, and interval together. For the redirect case, also capture the current URL immediately after navigation and again at failure. Do not infer a second Java owner merely because those values differ.
Ownership follows the first divergent evidence. A sibling navigation under the same session goes to the automation framework or suite code that shared the driver. A redirect with one command owner goes to the feature, authentication, or routing owner with the redirect evidence and application state. Serializing the latter case produces the same redirect more reliably, which is useful diagnosis but not a fix.
Read the ownership fields before the exception text
The command probe has three comparisons that are more useful than the final exception. First compare session. In a healthy independent-session run, overlapping tasks carry different non-null IDs. In a healthy serialized run, tasks share one ID, but every command boundary carries the one lane thread. A broken shared-session run combines one ID with command boundaries from two worker threads, or shows one task starting a state-changing interaction before another task ends its interaction.
Next read task beside command. A task name identifies the intended interaction, while the command label says what browser state it can disturb. Two read labels on the same session are not automatically safe because either read may depend on a page selected earlier. The decisive broken value is a command from another task inside that dependency interval. By contrast, two task names appearing on result-formatting lines after both browser interactions finish are misleading. CompletableFuture may move immutable result processing across threads without putting the driver at risk.
Finally read seq with phase. The number is assigned when the client begins the probed operation. A healthy lane should show one operation ending before the next accepted browser operation begins. The numbers do not prove when Grid received each HTTP request, and a filtered log excerpt may omit intervening values. A start with neither an end nor an error is an incomplete client interval, not proof that the remote end is still executing. Pair it with Grid evidence before choosing between a hung command, a worker interruption, and a lost response.
A misleading trace often contains several thread names but only one thread on lines that include both a session ID and a WebDriver command. That is normal when workers parse already-captured strings or build reports after the lane returns a value. A broken trace places the live driver call itself on those workers. Instrument at the driver boundary so that ordinary CompletableFuture scheduling noise cannot impersonate shared-session access.
Migrate without turning every test async
Inventory every CompletableFuture, parallelStream, executor submission, and reactive callback that can reach a driver or page object. Search for static WebDriver fields and ThreadLocal fallbacks. The risky object may be hidden inside a page object captured by a lambda.
Add session ID, thread name, and task label logging before changing behavior. Run a representative CI shard and group commands by session. Any session with browser commands from several worker threads becomes a migration candidate. Do not assume all such cases fail today; timing bugs can stay quiet for months.
Choose one of two policies per candidate. If the tasks form one user journey, make the sequence synchronous or submit complete interactions to one session lane. If the tasks are independent tests or browser combinations, create one session per task. Record the reason in code because future maintainers may otherwise "optimize" the sessions back into sharing.
Introduce ThreadGuard in a small package as a diagnostic tripwire when your framework creates and uses drivers on the test thread. It will turn intermittent corruption into an immediate, readable failure. It is not a replacement for ThreadLocal in parallel test runners, and it may require framework changes when legitimate commands currently hop threads.
Expect infrastructure callbacks to break before ordinary test bodies. Screenshot listeners, retry handlers, and report enrichers often borrow a driver from a worker chosen by the framework. A ThreadGuard failure in one of those callbacks is evidence that ownership was already ambiguous. Before widening enforcement, change the callback to run through the session owner or have the owner capture immutable evidence that another thread may publish. Disabling the guard for callbacks would preserve the least visible command path.
Land the migration in dependency order. Preserve the ownership log and full CompletionException cause chain first. Add the lane and per-task session factory next, without changing every caller. Move one package after classifying each asynchronous block as a single journey or independent work. Only then remove static fallbacks, leaf-stage quit callbacks, and direct driver access from captured page objects. If those escape hatches disappear first, failures become null drivers and missing cleanup rather than useful ownership violations.
For each migrated package, compare command intervals by session rather than relying on a lower flaky-test count. A one-browser journey is healthy when all live commands for its ID execute on the lane and accepted work finishes before quit. Independent work is healthy when each concurrent task receives its own ID and each ID reaches one quit path. Keep the original timing distribution as well. A correct lane can expose head-of-line blocking that the race previously hid, while separate sessions can move waiting time to Grid's session queue.
Test three shutdown cases: all stages succeed, one stage fails before issuing a command, and one stage fails during a command. Ensure quit runs once for every created session. Check that the original CompletionException cause remains visible and that a cleanup failure does not erase it.
A CI wiring step can keep the race reproduction and the safe contract separate:
- name: Verify WebDriver concurrency contracts
env:
GRID_URL: http://127.0.0.1:4444
run: |
mvn --batch-mode -Dtest=SessionLaneContractTest,IndependentSessionContractTest test
- name: Preserve command ownership log
if: failure()
uses: actions/upload-artifact@v4
with:
name: webdriver-command-ownership
path: target/webdriver-command-ownership.logThe artifact path must match the logger configuration in your suite. Do not add an empty upload step and call observability complete. Confirm that a forced failure produces the expected file and that secrets, cookies, and page contents are not written to it.
Measure the cost after rollout. Serialization can lengthen a workflow because all interactions wait in one lane. Separate sessions increase Grid slot use and startup time. Both are honest costs. Choose based on whether the work needs one browser state or independent browser states, not on which change makes the test green fastest.
The lane also adds a specific maintenance burden. Its acceptance, failure, cancellation, and shutdown rules become framework API behavior that callers will depend on. One slow navigation delays every later accepted interaction for that session, even when a later result is more urgent. Cancelling a returned future does not establish that the remote command stopped, so the owner still has to drain or deliberately terminate the session. Teams adopting the lane must test those lifecycle rules whenever the executor or Selenium integration changes.
Concurrency ownership crosses team boundaries. The framework team owns driver construction, the lane, executor shutdown, ThreadGuard placement, and command-boundary logging. Suite authors own the size of each submitted interaction and must not smuggle live page objects or elements into later stages. Grid operators own capacity and remote command evidence, but only after the client supplies an exact session ID. Application teams own collisions that remain after browser and fixture isolation have been demonstrated.
A useful handoff includes the test and attempt identifiers, every involved session ID, task labels, thread names, the smallest sequence range that shows the overlap, the complete CompletionException cause chain, and the matching Grid event segment. State whether the code intended one journey or independent tests. Also state which value was shared: the driver, an account, or only immutable output. That packet lets the recipient choose a boundary without rerunning a timing-sensitive failure blindly.
Serialization does not catch a logical submission-order bug. A lane can execute every command on one owner thread and still visit account before cart because callers submitted the interactions in the wrong order. Its trace will look perfectly serialized. Assert required business preconditions or compose dependent stages explicitly. Thread correctness proves exclusive access, not the intended journey.
Do not introduce CompletableFuture around blocking WebDriver calls merely to modernize the code. One browser cannot render two pages in parallel. Keep straightforward tests synchronous, use the test runner for parallelism across isolated sessions, and reserve futures for coordination that has a clear owner and a measurable benefit.
// 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 w3.org reference
w3.org
Primary documentation selected and verified for the claims in this guide.
FAQ / QUICK ANSWERS
Questions testers ask
Is Selenium WebDriver thread safe in Java?
No shared-session safety guarantee should be assumed. Keep every command for one driver on one owning thread, or serialize complete browser interactions through a dedicated lane.
Does CompletableFuture run tasks in submission order?
Independent asynchronous stages do not promise the browser command order implied by nearby source lines. allOf waits for completion, but it does not turn concurrent tasks into an ordered sequence.
Can synchronized make one WebDriver safe for parallel tests?
A lock can prevent simultaneous method calls, but a lock around each call is too narrow when an interaction spans navigation, lookup, and assertion. Locking the whole scenario removes the parallelism, so separate sessions are usually clearer.
Why is my ThreadLocal driver null inside supplyAsync?
ThreadLocal values belong to the thread that set them and are not copied into common-pool workers. Pass an immutable input to the task and create a driver there, or submit work to an explicit session owner.
When should CompletableFuture be used with Selenium?
Use it to coordinate independent sessions or to process immutable results after browser commands finish. It rarely improves throughput when several tasks must mutate one browser's page, window, cookies, and element state.
RELATED GUIDES
Continue the learning route
GUIDE 01
Instrument Selenium Commands with WebDriver Listeners
Instrument Selenium commands with WebDriverListener, structured timing events, failure screenshots, safe redaction, and useful execution timelines.
GUIDE 02
Set WebDriver Timeouts at Session Creation
Set the WebDriver timeouts capability at session creation with correct implicit, pageLoad, and script values, Java examples, and Grid verification steps.
GUIDE 03
Combine Classic WebDriver Commands with BiDi Events
Combine Classic WebDriver Commands with BiDi Events: practical implementation, debugging, evidence, security, CI, and release guidance for QA teams.
GUIDE 04
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 05
WebDriver BiDi Event Collection Architecture
Master WebDriver BiDi event architecture with practical examples, architecture decisions, failure analysis, CI guidance, metrics, and scenario-led interview answers.