PRACTICAL GUIDE / Java virtual threads Selenium WebDriver sessions

Virtual threads make Selenium concurrency easier to start, not free

Use Java 21 virtual threads with Selenium without flooding Grid, sharing drivers, or hiding queue time, with bounded examples and rollout checks.

By The Testing AcademyUpdated August 7, 202624 min read
All field guides
In this guide7 sections
  1. What virtual threads change in a WebDriver run
  2. Put a hard limit around the expensive resource
  3. A session-start failure can consume every local permit
  4. Tell queue pressure from a Java deadlock
  5. Separate total saturation from an incompatible request
  6. Keep the driver inside one task's lifetime
  7. Roll out based on measured capacity
  8. Migrate the support code before the test bodies
  9. Assign ownership at the boundary the evidence crosses
  10. Abrupt process loss is outside the local guarantee
  11. Avoid virtual threads when they add no useful overlap

What you will learn

  • What virtual threads change in a WebDriver run
  • Put a hard limit around the expensive resource
  • A session-start failure can consume every local permit
  • Tell queue pressure from a Java deadlock

A suite that used 12 platform threads is switched to Java 21 virtual threads, and Grid suddenly receives 400 new-session requests. Nothing in the test logic changed. The executor removed a JVM thread limit that had quietly been acting as browser admission control.

That is the useful and dangerous part of virtual threads. They let many blocking tasks wait efficiently, but a browser session still consumes a process, memory, CPU, a Grid slot, and often a paid cloud-provider minute.

What virtual threads change in a WebDriver run

A virtual thread is still a java.lang.Thread. It can execute ordinary synchronous Java code, block in a WebDriver HTTP call, use try/finally, and carry thread-local values. The Java runtime can suspend a virtual thread during supported blocking operations so its underlying platform thread can do other work. That makes the model attractive for I/O-heavy work because code does not need to be rewritten as callbacks.

Executors.newVirtualThreadPerTaskExecutor() creates a new virtual thread for every submitted task. The executor is deliberately unbounded. Oracle's documentation is explicit that virtual threads provide scale rather than lower latency. A click does not become faster. Chrome does not render faster. Grid does not gain slots. More tasks can wait without requiring an equal number of operating-system threads.

A Selenium command crosses several finite boundaries:

  1. The client serializes a WebDriver command and sends it over HTTP.
  2. The Grid Router sends a new-session request to the New Session Queue or routes an existing-session command.
  3. The Distributor finds a compatible free slot.
  4. A Node starts or contacts a browser and driver.
  5. The browser executes work that consumes CPU and memory.
  6. The response travels back while the calling Java thread waits.

Virtual threads help mainly with the waiting in that path. They do not improve the capacity of the Router, queue, Distributor, Node, browser host, or application under test. If 500 tasks call new RemoteWebDriver at once against a 20-slot Grid, the system has not achieved 500-way browser concurrency. It has created a burst, a queue, and 480 tasks waiting for admission.

The old fixed executor may have hidden that distinction:

Java
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public final class ExecutorComparison {
    public static void main(String[] args) {
        try (ExecutorService oldLimit = Executors.newFixedThreadPool(12)) {
            for (int i = 0; i < 400; i++) {
                oldLimit.submit(() -> runOneScenario());
            }
        }

        try (ExecutorService unboundedStarts =
                 Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 400; i++) {
                unboundedStarts.submit(() -> runOneScenario());
            }
        }
    }

    private static void runOneScenario() {
        // The real scenario creates and owns its WebDriver here.
    }
}

The first executor allows only 12 task bodies to run concurrently. It accidentally caps every resource used by those tasks, including browsers. The second starts all 400 task bodies on separate virtual threads. That is correct executor behavior. The migration failed because concurrency policy was coupled to the old implementation and never stated.

There are at least three limits in a mature suite, and they need separate names:

  • Task concurrency is how many test tasks may be alive, including tasks waiting on data or browser admission.
  • Session concurrency is how many WebDriver sessions this process may own at once.
  • Grid capacity is how many compatible sessions the remote service can actually run across all clients.

Those numbers need not match. A utility test that calls an API can run while browser permits are full. Several CI jobs may share one Grid, so each job's session limit must leave room for the others. Chrome and Safari pools may have different capacity. A single global number is a starting point, not a complete scheduling strategy.

WebDriver itself must still have one clear owner. Selenium documents ThreadGuard for detecting a Java driver used from a different thread, but ThreadGuard is not a concurrency scheduler. It does not create one driver per task, cap session creation, or quit leaked sessions. Thread safety and resource admission are related operational concerns, not the same mechanism.

Put a hard limit around the expensive resource

Acquire capacity before constructing RemoteWebDriver, because session creation is the expensive operation. Releasing a permit only after quit() completes ties the permit to the actual lifetime of the session. If a permit covers only test method execution, slow setup or cleanup can exceed the intended browser count.

A Semaphore is enough for a process-local limit. The following runner reads an explicit concurrency value, reports how long a task waited, creates the driver inside the permitted region, and guarantees that the same virtual thread performs all commands and cleanup.

Java
import java.net.MalformedURLException;
import java.net.URI;
import java.time.Duration;
import java.util.concurrent.Callable;
import java.util.concurrent.Semaphore;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.remote.RemoteWebDriver;

public final class BoundedBrowserRunner {
    private final Semaphore permits;
    private final URI gridUri;

    public BoundedBrowserRunner(int maxSessions, URI gridUri) {
        if (maxSessions < 1) {
            throw new IllegalArgumentException("maxSessions must be positive");
        }
        this.permits = new Semaphore(maxSessions, true);
        this.gridUri = gridUri;
    }

    public <T> T run(String testId, BrowserWork<T> work) throws Exception {
        long queuedAt = System.nanoTime();
        permits.acquire();
        long waitNanos = System.nanoTime() - queuedAt;
        System.out.printf(
            "test=%s browserPermitWaitMs=%d availablePermits=%d%n",
            testId,
            Duration.ofNanos(waitNanos).toMillis(),
            permits.availablePermits()
        );

        WebDriver driver = null;
        try {
            driver = new RemoteWebDriver(
                gridUri.toURL(),
                new ChromeOptions()
            );
            return work.execute(driver);
        } finally {
            try {
                if (driver != null) {
                    driver.quit();
                }
            } finally {
                permits.release();
            }
        }
    }

    @FunctionalInterface
    public interface BrowserWork<T> {
        T execute(WebDriver driver) throws Exception;
    }
}

Fair semaphore ordering is intentional here. A fair semaphore generally gives older waiters priority. That costs some throughput compared with an unfair semaphore, but it makes starvation less likely and queue time easier to interpret. For a small suite, the difference will be negligible. For a high-throughput runner, measure both rather than treating fairness as universally superior.

Use the runner from a virtual-thread executor and wait on every Future so failures remain visible. Merely submitting tasks and leaving the executor scope is not a test result strategy.

Java
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import org.openqa.selenium.By;

public final class SearchBatch {
    public static void main(String[] args) throws Exception {
        int browserLimit = Integer.parseInt(
            System.getenv().getOrDefault("BROWSER_CONCURRENCY", "6")
        );
        URI grid = URI.create(
            System.getenv().getOrDefault(
                "SELENIUM_GRID_URL", "http://localhost:4444")
        );
        BoundedBrowserRunner browsers =
            new BoundedBrowserRunner(browserLimit, grid);

        List<Future<String>> results = new ArrayList<>();
        try (ExecutorService tasks =
                 Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 40; i++) {
                String testId = "docs-search-" + i;
                results.add(tasks.submit(() ->
                    browsers.run(testId, driver -> {
                        driver.get("https://www.selenium.dev/");
                        return driver.findElement(By.tagName("h1")).getText();
                    })
                ));
            }

            for (Future<String> result : results) {
                try {
                    System.out.println(result.get());
                } catch (ExecutionException failure) {
                    failure.getCause().printStackTrace();
                }
            }
        }
    }
}

This example can have 40 cheap tasks alive while at most six own sessions. That is useful if tasks also do independent blocking work before or after the browser. If every task does nothing except wait for a browser permit and then run WebDriver commands, creating 40 virtual threads offers little advantage over submitting work to a six-thread executor. The design still makes the resource policy explicit, which helps future changes.

The process-local semaphore cannot see other CI jobs. If ten jobs each allow ten sessions against a 40-slot shared Grid, aggregate demand is still 100. Solve that at the orchestration layer by limiting concurrent jobs, allocating pool-specific quotas, or using a central lease service your platform already operates. Do not pretend a static environment variable is a distributed lock.

Capacity should also follow compatibility. A Grid may have 30 Chrome slots and two Safari slots. A single pool of 20 permits protects total load but still lets 20 Safari requests queue. Separate permits by requested browser or route jobs through dedicated worker groups. The trade-off is unused reserved capacity when one pool is idle. Dynamic allocation is possible, but it introduces a scheduler that must be observed and tested like production code.

A session-start failure can consume every local permit

Admission control introduces a failure that looks like Grid exhaustion even when Grid is empty. The permit can leak before a browser exists. This broken variant places the try block after RemoteWebDriver construction:

Java
static <T> T brokenRun(
        Semaphore permits,
        URI gridUri,
        BrowserWork<T> work) throws Exception {

    permits.acquire();
    RemoteWebDriver driver = new RemoteWebDriver(
        gridUri.toURL(),
        new ChromeOptions()
    );

    try {
        return work.execute(driver);
    } finally {
        try {
            driver.quit();
        } finally {
            permits.release();
        }
    }
}

If the constructor throws SessionNotCreatedException, or another runtime failure occurs while creating the client session, execution never enters the try block. No driver is available to quit, which is expected, but the acquired permit is also never returned. A burst of failed starts can reduce available permits to zero. Virtual threads then wait cheaply and in large numbers, making the suite look alive while no task can reach Grid. The same bug exists with platform threads, but a smaller fixed pool may expose it more slowly.

The distinguishing evidence is a mismatch between the local gate and the remote service. The following values are illustrative, not measurements:

Example
test=search-17 event=permit-acquired availablePermits=1
test=search-17 event=new-session-failed type=SessionNotCreatedException
test=search-18 event=permit-acquired availablePermits=0
test=search-18 event=new-session-failed type=SessionNotCreatedException
local.availablePermits=0 local.permitWaiters=38
grid.sessionCount=0 grid.sessionQueueSize=0

A thread dump shows the remaining virtual threads waiting in Semaphore.acquire, while Grid reports neither active sessions nor queued requests. That is a local permit leak, not a remote browser leak and not carrier-thread starvation. Raising Grid capacity or its queue timeout cannot release the Java semaphore.

The correct boundary starts immediately after a successful acquire. Initialize the driver variable to null inside that protected region, construct RemoteWebDriver there, quit only when construction returned a driver, and release the permit in an inner finally that runs even when quit fails. The earlier BoundedBrowserRunner uses that ordering. Do not release when acquire itself throws InterruptedException, because that call did not obtain a permit. If code uses timed tryAcquire, release only when its boolean result was true.

Make constructor failure part of the gate's contract test. With a one-permit runner, inject or otherwise arrange one failed session creation, then submit a second attempt. The second attempt must reach its session factory instead of waiting forever. This test checks admission bookkeeping without claiming that a failed new-session request could always be cleaned up from the client, since RemoteWebDriver may never have received a session ID.

Tell queue pressure from a Java deadlock

Once admission is explicit, measure both sides of it. The client should record task submission time, browser-permit acquisition time, new-session start, new-session completion, first command, quit start, and quit completion. Grid should expose session count, maximum sessions, queue size, node state, and compatible stereotypes. A single "test took 90 seconds" duration cannot locate the wait.

The GraphQL endpoint can report current session count, maximum sessions, queue size, node status, slot counts, and active sessions. This diagnostic is read-only and can be captured while a load test is in progress.

Shell
GRID_URL=http://localhost:4444

curl --fail --silent   -H "Content-Type: application/json"   --data '{"query":"{ grid { maxSession sessionCount sessionQueueSize } nodesInfo { nodes { id uri status slotCount sessionCount stereotypes } } }"}'   "$GRID_URL/graphql" |
  python -m json.tool

Suppose the client reports this pattern:

Shell
test=catalog-081 browserPermitWaitMs=18240 availablePermits=0
test=catalog-081 newSessionMs=1380 session=7a6c
test=catalog-081 scenarioMs=9412 quitMs=220

The 18-second delay occurred before a request reached Grid. The local six-session limit is the bottleneck, which may be deliberate. Raising Grid's new-session timeout will not change that wait. Raise the process limit only if Grid and browser hosts have headroom.

Now consider a different record:

Shell
test=catalog-081 browserPermitWaitMs=0 availablePermits=11
test=catalog-081 newSessionMs=61742 result=session-not-created
grid.sessionCount=20 grid.maxSession=20 grid.sessionQueueSize=46

The process admitted the request immediately, but Grid was full and its queue grew. Other clients or an incorrect per-job limit are consuming capacity. Reducing this job's permits may improve system stability even though it makes its local wait visible.

A third pattern looks similar to overload from the test report:

Shell
test=catalog-081 browserPermitWaitMs=0 availablePermits=5
test=catalog-081 newSessionMs=1034 session=7a6c
test=catalog-081 command=getTitle elapsedMs=120000 result=timeout
grid.sessionCount=6 grid.maxSession=24 grid.sessionQueueSize=0

That is not session admission pressure. The stall happened during an existing-session command while Grid had free capacity. Investigate the browser, node-to-application network, application response, or command timeout. Increasing session concurrency could make the outage worse.

Separate total saturation from an incompatible request

A capability mismatch can produce nearly the same client record as a full Grid. The local permit arrives immediately, new-session time keeps rising, the queue is nonempty, and the attempt eventually fails to create a session. The operational response is different. A saturated Grid needs less demand or more capacity. A mismatched request needs its requested capabilities corrected, or an eligible node restored or registered with the capabilities it actually supports.

Read the queued request and node records before using the aggregate totals. sessionsInfo.sessionQueueRequests identifies what the queue is trying to place. For each node, status says whether it is currently eligible, stereotypes describes the kinds of slots it advertises, and sessionCount, slotCount, and the node-level maxSession show how much of that node is already occupied. Compare the queued request's browser and platform constraints with those stereotypes. Do not infer compatibility from a node URI, host name, or the browser used by the preceding test.

Consider three illustrative snapshots. In a healthy snapshot, the queued request asks for a capability set represented by at least one UP node, and that matching node has session headroom below both its slot count and effective session maximum. A brief queue can still exist while assignment is in progress, so a nonzero queue size alone is not broken. In a genuinely saturated snapshot, the request matches eligible nodes, but those nodes have no session headroom. In the mismatch snapshot, grid.sessionCount remains well below grid.maxSession, yet no UP node advertises a stereotype that satisfies the queued request. A matching node shown as DRAINING or DOWN also explains why nominal slots cannot accept the work.

The misleading field is global free capacity, calculated from grid.maxSession and grid.sessionCount. It combines unlike browser and platform pools. It can look generous while the requested pool has no eligible slot. Node status=UP is also insufficient on its own because an available node may advertise only a different browser. Conversely, sessionQueueSize does not prove that more machines are required. The precise separator is a queued capability set with no matching, eligible stereotype, not the queue's length.

Capture the Grid snapshot while the request is still queued. A later snapshot can show free capacity after another session exits and erase the evidence. Keep the client timestamp beside the Grid capture time so the platform team can tell whether the two observations describe the same interval.

For a JVM-side hang, take a thread dump instead of inferring deadlock from elapsed time. Java 21's jcmd can write a JSON thread dump that represents virtual threads more usefully than a traditional platform-thread-oriented view. Run it against the test JVM while the stall is active.

Shell
jcmd -l
jcmd 12345 Thread.dump_to_file -format=json virtual-threads.json

Replace 12345 with the test JVM process ID reported by jcmd -l. Inspect whether virtual threads are waiting in Semaphore.acquire, blocked in WebDriver HTTP I/O, waiting on Future.get, or stuck around an application lock. A large number parked at the semaphore is expected under a deliberate limit. One permit holder blocked forever in custom synchronized code is a different defect.

Virtual-thread pinning can reduce scalability when a virtual thread blocks while it cannot unmount from its carrier, but do not use "virtual threads are pinned" as a default explanation for a full Grid queue. Admission metrics settle that question more directly. Investigate JVM scheduling after proving the time is inside the client, not while the remote browser service is saturated.

Keep the driver inside one task's lifetime

The safest ownership shape is lexical: create the driver inside a task, pass it down the call stack, and quit it before that task returns. The reference never enters a static field, shared collection, or result object. Virtual threads do not make WebDriver safe for concurrent commands.

A static ThreadLocal is common in older parallel frameworks. It can work when set, read, removed, and cleaned up on the same thread. It also hides dependencies and makes lifecycle auditing harder. With a virtual-thread-per-task executor, every submission receives a new thread, so setting a driver in one task and expecting a later submitted task to read it will not work.

This example demonstrates the ownership mistake without opening a browser:

Java
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public final class ThreadLocalBoundary {
    private static final ThreadLocal<String> VALUE = new ThreadLocal<>();

    public static void main(String[] args) throws Exception {
        try (ExecutorService executor =
                 Executors.newVirtualThreadPerTaskExecutor()) {
            executor.submit(() -> VALUE.set("first-task")).get();

            String seen = executor.submit(() -> VALUE.get()).get();
            System.out.println(seen); // null: this is another virtual thread
        }
    }
}

Translating that mistake to WebDriver can cause a null driver, an emergency replacement session, or cleanup that runs on a thread which never stored the original. The right fix is not InheritableThreadLocal. Child inheritance can duplicate a reference to the same non-thread-safe driver across threads, exactly the ownership ambiguity the suite needs to prevent.

Selenium's ThreadGuard can catch a driver called from a thread other than the one that constructed the protected instance. It is useful during migration because failures become immediate and descriptive. Wrap on creation, then still retain explicit session limits and cleanup.

Java
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.support.ThreadGuard;

public final class ConfinedScenario {
    public static String run() {
        WebDriver driver = ThreadGuard.protect(new ChromeDriver());
        try {
            driver.get("https://www.selenium.dev/");
            return driver.getTitle();
        } finally {
            driver.quit();
        }
    }
}

ThreadGuard has a cost: it adds a check and rejects patterns that intentionally hand the driver to another thread. That rejection is usually valuable. It does not detect two sessions accidentally created for one test, a missing quit, or a semaphore permit released too early. Use it as a guardrail, not proof that lifecycle management is complete.

Nested concurrency deserves special attention. A virtual-thread test task that uses parallel streams, CompletableFuture, or another executor must not pass its driver into those child operations. Even read-looking calls such as getTitle() are remote commands and can interleave with navigation. Parallelize independent sessions, not commands within one session.

Roll out based on measured capacity

Start by recording the current fixed executor size, actual peak sessions per browser, Grid queue delay, node resource use, and suite duration. The old thread count is a useful baseline, not automatically the correct browser limit. If twelve platform threads produced ten sessions because two tasks usually did API setup, a session limit of twelve may already increase load.

Introduce the semaphore while still using the old executor. That isolates the admission-control change. Set the permit count equal to or slightly below observed safe concurrency. Verify that permits are released after setup failures, assertion failures, timeouts, and quit failures. A one-permit test configuration is excellent for catching leaks because the next scenario will wait forever if cleanup is wrong.

Then switch the task executor to virtual threads without raising the browser limit. This should preserve peak session demand. Compare submission-to-permit time, permit-to-session time, scenario duration, cleanup duration, Grid queue size, and total suite time. A win appears when non-browser blocking work overlaps more effectively. If only the number of waiting tasks rises, the migration added complexity without throughput.

CI makes the policy visible through environment configuration and retains both test and Grid diagnostics:

YAML
name: bounded-virtual-thread-tests
on:
  pull_request:
jobs:
  browser-tests:
    runs-on: ubuntu-latest
    timeout-minutes: 35
    env:
      BROWSER_CONCURRENCY: "6"
      SELENIUM_GRID_URL: "http://selenium-grid.internal:4444"
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: "21"
      - name: Run the browser batch
        run: ./mvnw -B test
      - name: Save concurrency evidence
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: virtual-thread-concurrency
          path: |
            target/surefire-reports/
            target/browser-metrics/
          if-no-files-found: warn

The Grid URL shown is an example service name; use the actual endpoint available to the runner. Keep credentials in the CI platform's secret mechanism rather than embedding them in repository YAML or RemoteWebDriver URLs.

Raise permits one step at a time. Run a representative mix, not only the fastest happy-path tests. Watch the tail of new-session latency and command latency, because averages can improve while overloaded nodes create severe outliers. Stop when added concurrency no longer reduces wall time, error rates rise, or the application under test becomes the bottleneck.

Cloud sessions introduce billing as another capacity dimension. Doubling concurrency can shorten a job while consuming the same total browser minutes, or it can increase cost if setup duplication, retries, and provider queue behavior grow. Measure completed useful scenarios per paid minute, not simply thread count.

Rollback is straightforward if admission control remains independent. Change the executor back to a fixed pool while leaving the session semaphore and ownership rules intact. That separation is a strong reason to avoid embedding capacity in assumptions about executor internals.

Migrate the support code before the test bodies

In an established suite, reporting and fixture infrastructure often breaks before a WebDriver command does. A listener may use a worker thread identifier as the key for the current test attempt, relying on setup, the test body, teardown, and report attachment to run on that same worker. Once those phases are submitted as separate virtual-thread tasks, the lookup no longer identifies the attempt. The visible symptoms are missing attachments, cleanup attributed to the wrong retry, duplicate timing records, or a passing report that contains fewer completed attempts than the runner submitted.

Land a stable attempt identifier through the runner, lifecycle logs, listener callbacks, and report records while the fixed executor is still active. Preserve retry identity separately from test identity so two attempts of the same test cannot overwrite each other's evidence. Then land the session gate and prove its release behavior under setup and cleanup failures. Only after those invariants hold should one representative CI shard use virtual threads. Keep that shard's job count, browser permit count, browser mix, and test selection unchanged during the comparison. Otherwise a scheduler change and a load change become impossible to separate.

Do not judge the canary only by a green exit code. Reconcile submitted attempt identifiers with terminal outcomes, and require every successful session creation to have a corresponding cleanup outcome. Compare report attachment ownership, per-browser peak sessions, local permit wait, Grid queue delay, and the count of tests selected by the old and new shards. The change is working when test inventory and evidence remain complete, peak browser demand stays inside the existing envelope, and any wall-time improvement comes from overlap outside the browser permit rather than from silently dropping work.

This observability has a specific maintenance cost. Correlation fields must pass through every runner and listener integration, and a retry creates another record that downstream reporting must retain. Using an attempt identifier as a time-series label can also create one label value per attempt, which is unsuitable for an aggregate metric store. Keep per-attempt detail in structured logs or reports, and use bounded dimensions such as browser pool and shard for aggregate timing metrics.

Holding a permit through quit() has a second concrete cost. One stalled cleanup removes one permit for its entire stall. With a six-permit gate, one such cleanup removes one-sixth of the process's intended browser concurrency. Releasing before cleanup finishes would make the queue move, but it could admit a replacement while the old remote session is still consuming a slot. The safer cap therefore converts cleanup latency into admission latency, and the rollout needs a separate cleanup-duration signal so that cost is visible.

Assign ownership at the boundary the evidence crosses

The test automation team owns the first investigation because it controls task identity, permit acquisition, driver construction, and cleanup. It should fix local permit leaks, lost attempt correlation, driver use outside the owning task, and a queued capability set that differs from what the test intended to request. The Grid platform team owns the next step when the intended request passed the local gate but expected nodes are unavailable or do not advertise the agreed stereotype. The CI platform owner handles aggregate pressure created by too many jobs sharing a pool. An application team becomes the owner only when session creation completed normally and a timed command points to the application path rather than session admission.

A handoff should contain the job and shard, stable attempt identifier, requested capabilities with secrets removed, configured local permit count, the number of concurrently running jobs, and absolute timestamps for permit acquisition, new-session start and finish, and cleanup. Include the Grid snapshot from the same interval with queue requests, aggregate counts, node statuses, stereotypes, and node session limits. If the JVM itself appeared stuck, attach the virtual-thread dump and identify the relevant task rather than sending an unfiltered file with no correlation key. State the violated invariant in one sentence, such as a queued request having no eligible matching stereotype. A stack trace or queue graph without this boundary evidence invites each team to redirect the incident.

Abrupt process loss is outside the local guarantee

The semaphore, lexical driver scope, and finally cleanup do not catch a JVM crash, forced runner termination, or lost CI machine. In those cases the cleanup code never executes. The process-local semaphore disappears with the JVM, so its permit count cannot reveal that the remote Grid or provider may still hold an orphaned browser session.

Emit the remote session identifier as soon as creation succeeds, before the scenario starts, and associate it with the CI job and attempt identifier. The Grid or service owner then needs an external reconciliation path using the cleanup and session-reclamation mechanisms that service supports. The check compares sessions still visible remotely with jobs that are still alive. This is platform recovery, not something virtual threads or ThreadGuard can supply. If session identifiers only appear at normal teardown, the one failure that needs them most will leave no usable ownership record.

Avoid virtual threads when they add no useful overlap

A suite with six long browser scenarios and six Grid slots may gain nothing. Each scenario owns a browser for almost its full lifetime, so a six-thread fixed executor already expresses both task and session concurrency clearly. Virtual threads are not a maturity badge.

CPU-heavy test utilities are another poor fit. Image comparison, video transcoding, large JSON transformations, or cryptographic work consumes a carrier while it calculates. Bound that work to available processors with an appropriate executor. It can coexist with virtual threads used for blocking orchestration, but treating all work as unlimited tasks can move the bottleneck into the test JVM.

Do not migrate to hide an undersized Grid. Thousands of cheap waiters make queuing affordable for the JVM, not acceptable for engineers. If new-session wait consumes most of the build, choose whether to add compatible capacity, reduce per-job demand, shard schedules, or accept the queue as a cost decision. The executor cannot make that choice.

Do not share one WebDriver to reduce session count. Concurrent commands can change the active window, frame, URL, alerts, and cookies underneath another task. Even sequential reuse across unrelated tests damages isolation. A lower permit count with independent sessions is slower but trustworthy.

Do not use an enormous ThreadLocal cache on virtual threads. Oracle warns that a JVM may support very many virtual threads, so per-thread state that seemed harmless with 20 platform threads can multiply dramatically. Keep small correlation values if they genuinely simplify logging, remove them predictably, and put substantial context in task-scoped objects.

The real trade is explicit. Bounded virtual threads can improve throughput and make synchronous test code easy to read. They add an admission layer, new timing metrics, and a larger population of waiting tasks. Fair permits may sacrifice some peak throughput. Pool-specific permits can strand capacity. A distributed quota adds an operational service. Those costs are justified only when measured overlap reduces delivery time without compromising browser isolation or Grid reliability.

// 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.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 25, 2026 / Reviewed August 7, 2026

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.

  1. 01
    Official docs.oracle.com reference

    docs.oracle.com

    Primary documentation selected and verified for the claims in this guide.

  2. 02
    Official docs.oracle.com reference

    docs.oracle.com

    Primary documentation selected and verified for the claims in this guide.

  3. 03
    Official selenium.dev reference

    selenium.dev

    Primary documentation selected and verified for the claims in this guide.

  4. 04
    Official selenium.dev reference

    selenium.dev

    Primary documentation selected and verified for the claims in this guide.

FAQ / QUICK ANSWERS

Questions testers ask

Can Selenium tests run on Java virtual threads?

Yes. WebDriver's blocking network calls can run on virtual threads, and Java 21 provides a virtual-thread-per-task executor. Keep each driver confined to its owning task and limit how many tasks may hold browser sessions.

Why did virtual threads overload my Selenium Grid?

The executor can start an unbounded number of virtual threads, while Grid still has a finite number of browser slots, CPUs, and memory. Without admission control, many tasks create or queue sessions at once.

Should I store WebDriver in a ThreadLocal with virtual threads?

A ThreadLocal can associate data with a virtual thread, but it does not enforce cleanup or prevent a driver from being passed elsewhere. Lexical ownership inside one task is simpler, and millions of possible virtual threads make careless thread-local state expensive.

How many concurrent Selenium sessions should I allow?

Start below the measured capacity of the smallest browser pool the suite needs, then raise the limit while watching queue delay, session-creation errors, node resource use, and test duration. The executor's thread count is not a capacity value.

Will virtual threads make each browser test faster?

No. Virtual threads target throughput for workloads that spend time blocked; they do not reduce browser rendering, application response time, or WebDriver command latency. A small suite may see no benefit.