PRACTICAL GUIDE / ConcurrentHashMap Selenium session metadata

Why your parallel Selenium report blames the wrong browser

Parallel Selenium suites report the wrong browser and the wrong screenshot. Here is the session-keyed metadata registry that fixes attribution for good.

By The Testing AcademyUpdated August 4, 202624 min read
All field guides
In this guide14 sections
  1. The mechanism, stated precisely
  2. Worked example one: the static field two threads share
  3. The fix: key on the session, store facts that cannot change
  4. Wiring it into the reporter
  5. Worked example two: the map that never shrinks
  6. Worked example three: the null session ID you were not expecting
  7. How to tell it is this and not a look-alike
  8. The diagnostic that settles it
  9. CI wiring
  10. What the fix costs
  11. Rollout path
  12. Trade-offs worth arguing about
  13. When not to do this
  14. Practise the judgment, not the syntax

What you will learn

  • The mechanism, stated precisely
  • Worked example one: the static field two threads share
  • The fix: key on the session, store facts that cannot change
  • Wiring it into the reporter

A checkout test fails on Firefox. The HTML report says the browser was Chrome, the screenshot attached to the failure belongs to a test that passed two seconds earlier, and the session ID printed in the failure banner points at a session that had already been closed. Rerun the same suite with a thread count of one and every field is correct. Nothing about the test changed, only the number of threads writing into the same metadata holder.

That is the bug. It is not flakiness, it is not the Grid, and no amount of waiting will move it. Somewhere in the framework there is a slot that holds "the current browser" or "the current session" and several workers are taking turns clobbering it. The failure itself is real; the label attached to the failure is fiction. Teams lose days to this because the wrong label sends them to the wrong system.

The mechanism, stated precisely

Java gives you three tools that sound interchangeable and are not.

A plain HashMap shared between threads has no memory-visibility or structural-safety guarantees at all. Under concurrent writes it can lose entries, and in older JDKs it could corrupt its internal structure badly enough to spin. It has no place in a parallel test framework.

A ThreadLocal binds a value to a thread, not to a test. The Oracle documentation describes it as providing each thread its own independently initialized copy of a variable. That is exactly right for "which driver does the code running right now own", which is why driver factories use it. The trap is the second half of the sentence: a value bound to a thread outlives the test that put it there. TestNG's parallel="methods" and JUnit 5's parallel execution both dispatch onto a pool of worker threads, and a pool reuses threads. If test A leaves a value behind and test B lands on the same worker, test B reads A's data unless something removed it.

A ConcurrentHashMap gives you a shared table that many threads can read and write safely. The class documentation is specific about what "safely" means, and the details matter more than most tutorials admit:

  • Retrieval operations, including get, generally do not block, and reflect the results of the most recently completed update operations holding upon their onset.
  • The class does not allow null as a key or a value, unlike HashMap.
  • The mapping function passed to computeIfAbsent must not modify the map during computation, and the same restriction applies to the remapping function in compute.
  • Results of aggregate status methods including size, isEmpty and containsValue are typically useful only when the map is not undergoing concurrent updates, and otherwise reflect transient states adequate for monitoring or estimation but not for program control.

Read that last point twice, because it is the one that quietly breaks assertions. A concurrent map is thread-safe per operation. It does not make your sequence of operations atomic. if (!map.containsKey(k)) map.put(k, v) is two operations and two threads can both pass the check. That is why the atomic compound methods exist: putIfAbsent, computeIfAbsent, compute, merge, and the two-argument remove(Object key, Object value).

Worked example one: the static field two threads share

Here is the shape almost every broken framework has. It is not stupid code. It reads fine, it passes review, and it works perfectly until the day someone raises the thread count.

Java
// BROKEN. Included so you can recognise it in your own repo.
public final class TestContext {

  // One slot. Every thread in the suite writes here.
  private static String currentBrowser;
  private static String currentSessionId;
  private static WebDriver currentDriver;

  public static void startSession(WebDriver driver) {
    currentDriver = driver;
    currentBrowser = ((RemoteWebDriver) driver).getCapabilities().getBrowserName();
    currentSessionId = ((RemoteWebDriver) driver).getSessionId().toString();
  }

  public static String browser() {
    return currentBrowser;
  }

  public static String sessionId() {
    return currentSessionId;
  }
}

With eight workers, the interleaving is trivial to construct. Worker 3 creates a Firefox session and sets currentBrowser to "firefox". Worker 6 creates a Chrome session a millisecond later and overwrites it with "chrome". Worker 3's test then fails, its listener calls TestContext.browser(), and the report says Chrome. Nothing threw. There is no stack trace to follow, because from Java's point of view nothing went wrong at all.

The naive repair is to make the field a ConcurrentHashMap keyed by thread name and call it done. That is better, but it moves the bug rather than removing it. Thread names are recycled by the pool, so a leftover entry from a finished test is indistinguishable from a live one, and any code outside the test thread (a reporter, a retry analyser, a post-run script reading Grid logs) has no way to know which thread ran which test.

The fix: key on the session, store facts that cannot change

Two decisions do the actual work here, and they matter more than the choice of map class.

Key on the session ID. It is the one identifier that appears on both sides of the boundary. Your Java code has it from RemoteWebDriver.getSessionId(). The Grid Session Map, per the components documentation, is the data store that keeps the relationship between the session id and the Node where the session is running, so the same string is what the Router uses to route your commands. It also comes back from the Grid GraphQL endpoint under sessionsInfo { sessions { id ... } }. One key, three systems.

Store immutable facts, never the driver. A WebDriver is a live handle to a remote process. Putting it in a shared map means any code that can reach the map can drive someone else's browser, and it means the map holds a reference to an object whose state changes under it. Capture the small set of things you will actually need in a report, freeze them, and store that.

Java
package com.thetestingacademy.grid;

import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import org.openqa.selenium.Capabilities;
import org.openqa.selenium.remote.RemoteWebDriver;
import org.openqa.selenium.remote.SessionId;

public final class SessionRegistry {

  /** Facts captured once, at creation, and never mutated afterwards. */
  public record SessionFacts(
      String sessionId,
      String testId,
      String threadName,
      String browserName,
      String browserVersion,
      String platformName,
      String gridUrl,
      long createdAtEpochMillis) {}

  private static final ConcurrentHashMap<String, SessionFacts> BY_SESSION_ID =
      new ConcurrentHashMap<>();

  private SessionRegistry() {}

  public static SessionFacts register(RemoteWebDriver driver, String testId, String gridUrl) {
    SessionId sessionId = driver.getSessionId();
    if (sessionId == null) {
      // The API declares this nullable. Fail here, loudly, rather than
      // letting a null propagate into the map (which would throw anyway).
      throw new IllegalStateException(
          "Null session id while registering " + testId
              + ": the session was never created, or is already closed");
    }

    Capabilities caps = driver.getCapabilities();
    SessionFacts facts = new SessionFacts(
        sessionId.toString(),
        testId,
        Thread.currentThread().getName(),
        caps.getBrowserName(),
        caps.getBrowserVersion(),
        String.valueOf(caps.getPlatformName()),
        gridUrl,
        System.currentTimeMillis());

    // Atomic claim. If someone already owns this id, that is a framework bug
    // and we want to know on the spot, not three weeks later in a report.
    SessionFacts previous = BY_SESSION_ID.putIfAbsent(facts.sessionId(), facts);
    if (previous != null) {
      throw new IllegalStateException(
          "Session " + facts.sessionId() + " already claimed by " + previous.testId()
              + " on thread " + previous.threadName());
    }
    return facts;
  }

  public static Optional<SessionFacts> lookup(String sessionId) {
    return Optional.ofNullable(BY_SESSION_ID.get(sessionId));
  }

  /** Teardown calls this. It returns the record so a reporter can still read it. */
  public static Optional<SessionFacts> release(String sessionId) {
    return Optional.ofNullable(BY_SESSION_ID.remove(sessionId));
  }

  /** Snapshot for the end-of-suite leak assertion. See the caveat below. */
  public static Map<String, SessionFacts> snapshot() {
    return Map.copyOf(BY_SESSION_ID);
  }
}

Three details are load-bearing.

putIfAbsent rather than put. A duplicate claim is a genuine defect (usually a factory that got called twice, or a retry that reused a stale ID), and put would silently paper over it. Making it throw turns an invisible reporting error into a visible test-infrastructure error.

The null check before insertion. The map rejects null values anyway, but the exception you would get from that is far less useful than one naming the test.

Map.copyOf in snapshot(). The map's own size() is documented as an estimate under concurrent update, so treating a live count as a hard number is the same category of mistake as the original bug. Take a copy at a point where you know writes have stopped.

Wiring it into the reporter

The registry is only worth having if something reads it from outside the test thread. Here is the TestNG side. The pattern is the same in JUnit 5 with a TestWatcher extension.

Java
package com.thetestingacademy.grid;

import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import org.testng.ITestListener;
import org.testng.ITestResult;

public class SessionAwareListener implements ITestListener {

  private static final Path LOG = Path.of("target", "session-facts.log");

  @Override
  public void onTestFailure(ITestResult result) {
    // The session id was stashed on the result by the driver factory,
    // on the test thread, at the moment the session was created.
    Object stashed = result.getAttribute("sessionId");
    if (!(stashed instanceof String sessionId)) {
      System.err.println("No session id on " + qualifiedName(result)
          + "; the failure happened before a browser existed");
      return;
    }

    SessionRegistry.lookup(sessionId).ifPresentOrElse(
        facts -> append(record(facts, result)),
        () -> System.err.println(
            "Session " + sessionId + " already released before the listener ran"));
  }

  @Override
  public void onTestSuccess(ITestResult result) {
    Object stashed = result.getAttribute("sessionId");
    if (stashed instanceof String sessionId) {
      SessionRegistry.lookup(sessionId).ifPresent(facts -> append(record(facts, result)));
    }
  }

  private String record(SessionRegistry.SessionFacts f, ITestResult r) {
    String cause = r.getThrowable() == null
        ? ""
        : r.getThrowable().getClass().getName();
    return """
        {"sessionId":"%s","testId":"%s","threadName":"%s","browserName":"%s",\
        "browserVersion":"%s","platformName":"%s","status":"%s","cause":"%s"}"""
        .formatted(
            f.sessionId(), f.testId(), f.threadName(), f.browserName(),
            f.browserVersion(), f.platformName(),
            r.isSuccess() ? "PASS" : "FAIL", cause);
  }

  private String qualifiedName(ITestResult r) {
    return r.getTestClass().getName() + "." + r.getMethod().getMethodName();
  }

  private synchronized void append(String jsonLine) {
    try {
      Files.createDirectories(LOG.getParent());
      Files.writeString(LOG, jsonLine + System.lineSeparator(),
          StandardOpenOption.CREATE, StandardOpenOption.APPEND);
    } catch (IOException e) {
      throw new UncheckedIOException("Could not append session record", e);
    }
  }
}

Note what the listener does not do. It does not ask a driver for anything. It does not read a static "current" field. It takes an identifier that was captured on the correct thread at the correct moment, and looks it up. The lookup is a pure read, so two listeners firing at once cannot interfere with each other.

The result.getAttribute("sessionId") call assumes the factory stashed it. That is one line in the factory, on the test thread, right after register(...) returns. Keep the stash and the registration adjacent so a reviewer can see both in one screen.

Worked example two: the map that never shrinks

The registry solves attribution and immediately creates a new problem, which is the honest part most write-ups skip. Every session adds an entry. Nothing removes it unless you say so. A nightly suite with four thousand sessions ends up holding four thousand records, and if you ever put the driver in the map (do not) you would also be pinning four thousand remote connections.

The failure mode is not usually an out-of-memory error. It is worse than that: it is a stale hit. A session ID from a previous run, or a previous shard, is still in the map, so a lookup that should have missed instead returns confident, wrong data.

Removal has to live in the same try/finally as quit(), because that is the only place guaranteed to run on both the pass and the fail path:

Java
public final class DriverLifecycle {

  public static void withSession(String testId, String gridUrl, Consumer<RemoteWebDriver> body) {
    RemoteWebDriver driver = DriverFactory.create(gridUrl);
    SessionRegistry.SessionFacts facts = SessionRegistry.register(driver, testId, gridUrl);
    try {
      body.accept(driver);
    } finally {
      // Order matters. Release first so a slow quit() cannot strand the entry,
      // then quit. The reporter has already read what it needs by this point.
      SessionRegistry.release(facts.sessionId());
      driver.quit();
    }
  }
}

Then assert it at the end of the suite, so nobody has to remember:

Java
@AfterSuite(alwaysRun = true)
public void assertNoOrphanedSessionRecords() {
  Map<String, SessionRegistry.SessionFacts> leaked = SessionRegistry.snapshot();
  if (!leaked.isEmpty()) {
    String detail = leaked.values().stream()
        .map(f -> f.testId() + " (" + f.sessionId() + ", " + f.threadName() + ")")
        .collect(Collectors.joining("\n  "));
    throw new AssertionError(
        leaked.size() + " session record(s) were never released:\n  " + detail);
  }
}

That assertion has caught more real bugs for me than the attribution fix did. An orphaned record almost always means a driver was also never quit, which means a browser process is still alive on a Node, which means your Grid slowly runs out of capacity for reasons nobody can explain. The same class of symptom shows up when a Node hands back slots late or not at all, so having the client side provably clean narrows the search fast.

One caveat on alwaysRun = true: if the JVM is killed (a CI timeout, an OOM kill), no teardown runs and the assertion never fires. Treat it as a guard against logic bugs, not as a guarantee.

Worked example three: the null session ID you were not expecting

RemoteWebDriver.getSessionId() is declared to return a nullable SessionId. Most code ignores that and does .getSessionId().toString() inline. In a single-threaded happy path this is fine. Under parallel load with a Grid in front, it is not, and the resulting NullPointerException lands in teardown where it masks the real failure.

Two situations produce it in practice. First, the session was never created: the New Session Queue timed out, the Distributor found no matching slot, and your factory is holding a driver reference that failed partway through construction. Second, the code is asking a driver that has already been through quit(), typically because a retry listener or a nested teardown ran twice.

Both are prevented by the same discipline: read the session ID exactly once, at creation, and pass the string around afterwards. The registry above does this. If your framework currently calls getSessionId() in more than one place, that is the thing to grep for first.

Shell
# Every place the framework reaches for a session id at runtime.
# Ideally this returns exactly one hit, in the factory.
grep -rn --include='*.java' 'getSessionId()' src/

# And every place a capability is read outside the factory, which is
# the same bug wearing a different hat.
grep -rn --include='*.java' 'getCapabilities()' src/ | grep -v DriverFactory

How to tell it is this and not a look-alike

Three other bugs produce reports that "look wrong", and the fixes are completely different. Distinguish them before you write any code.

Stale ThreadLocal, not a shared field. Signature: the wrong data is always the data from a previous test on the same worker, never from a concurrent one. Check by logging the thread name alongside the browser. If test B on TestNG-PoolService-3 reports the browser that test A on TestNG-PoolService-3 used, and A finished before B started, you have a missing ThreadLocal.remove() and a concurrent map will not help you. Fix the removal.

Report aggregation, not capture. Signature: your own log file has the right values but the HTML report does not. The capture layer is fine and the reporter is joining rows on the wrong key (usually test name, which repeats across data providers and retries). Check by diffing the raw record count against the report row count. If the report has fewer rows than you have sessions, it is collapsing on a non-unique key.

Grid actually gave you a different browser. Signature: the report is internally consistent, and it disagrees with what you requested. This is not a threading bug at all. Requested capabilities and returned capabilities are allowed to differ; the returned set is what you got. Check by logging both. A browserVersion request of "100" that comes back as something else means the Distributor matched a slot whose stereotype you did not expect, which is a routing question rather than a concurrency one.

The discriminator is cheap and worth building once: log the requested capabilities, the returned capabilities, the session ID and the thread name, all four, on one line, at creation. Every one of the three diagnoses above falls out of that line.

The diagnostic that settles it

Once records are on disk as newline-delimited JSON, the analysis is a few jq invocations. These operate on the file the listener writes.

Shell
# 1. Did any session id get claimed by more than one test?
#    Non-empty output here is a hard framework bug.
jq -s 'group_by(.sessionId)
       | map(select(length > 1))
       | map({sessionId: .[0].sessionId, owners: (map(.testId) | unique)})' \
  target/session-facts.log

# 2. Did any single test id report more than one browser?
#    This is the stale-ThreadLocal signature.
jq -s 'group_by(.testId)
       | map(select((map(.browserName) | unique | length) > 1))
       | map({testId: .[0].testId, browsers: (map(.browserName) | unique)})' \
  target/session-facts.log

# 3. How much interleaving is actually happening? If a worker thread
#    only ever appears once, you are not running parallel at all and
#    the bug you are chasing is somewhere else entirely.
jq -r '.threadName' target/session-facts.log | sort | uniq -c | sort -rn

# 4. Cross-check the client's view against the Grid's own view.
#    sessionsInfo is documented on the Grid GraphQL support page.
curl -s -X POST -H 'Content-Type: application/json' \
  --data '{"query":"{ sessionsInfo { sessions { id, capabilities, startTime, nodeId, sessionDurationMillis } } }"}' \
  http://localhost:4444/graphql | jq '.data.sessionsInfo.sessions'

Query four is the one that ends arguments. If your record says session abc123 was Firefox and the Grid's sessionsInfo says the capabilities for abc123 were Firefox, your capture layer is correct and the report is wrong downstream. If they disagree, your capture layer is reading a slot that something else wrote. There is no third option, and it takes about ninety seconds to check.

Adding se:name to the requested capabilities makes the join readable by humans as well. The Grid getting-started documentation shows metadata being attached by prefixing a capability with se:, with se:name displayed in the Grid UI in place of the raw session id:

Java
ChromeOptions options = new ChromeOptions();
options.setCapability("se:name", testId);            // shown in the Grid UI
options.setCapability("se:sampleMetadata", buildId); // visible via GraphQL
RemoteWebDriver driver = new RemoteWebDriver(new URL(gridUrl), options);

Now the Grid UI shows your test name, the GraphQL response carries your build ID, and the local record carries the same session ID. The join is trivial from either end.

CI wiring

The point of the whole exercise is that CI can fail the build on a broken join, rather than a human noticing a weird report six weeks later.

YAML
name: selenium-parallel
on: [push, pull_request]

jobs:
  regression:
    runs-on: ubuntu-latest
    services:
      selenium:
        # Pin this to the Grid version you actually run.
        image: selenium/standalone-chromium:4.41.0
        ports: ["4444:4444"]
        options: --shm-size=2g

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v4
        with:
          java-version: "21"
          distribution: temurin
          cache: maven

      - name: Wait for the Grid to report ready
        run: |
          for i in $(seq 1 30); do
            if curl -sf http://localhost:4444/status | jq -e '.value.ready' >/dev/null; then
              echo "grid ready after ${i}s"; exit 0
            fi
            sleep 1
          done
          echo "grid never became ready"; exit 1

      - name: Run the suite with eight workers
        env:
          GRID_URL: http://localhost:4444
          BUILD_ID: ${{ github.run_id }}
        run: mvn -B test -Dsurefire.suiteXmlFiles=testng-parallel.xml

      - name: Gate on session-record integrity
        if: always()
        run: |
          test -s target/session-facts.log || { echo "no records written"; exit 1; }

          dupes=$(jq -s '[group_by(.sessionId)[] | select(length > 1)] | length' \
            target/session-facts.log)
          mixed=$(jq -s '[group_by(.testId)[]
            | select((map(.browserName) | unique | length) > 1)] | length' \
            target/session-facts.log)

          echo "duplicate session claims: ${dupes}"
          echo "tests reporting mixed browsers: ${mixed}"
          test "${dupes}" -eq 0
          test "${mixed}" -eq 0

      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: session-facts
          path: target/session-facts.log
          retention-days: 14

The if: always() on the gate step is deliberate. A run where tests failed is exactly the run where you most need to know whether the failure labels can be trusted.

Two things this workflow does not do, on purpose. It does not retry. Retrying before the integrity gate would let a second attempt overwrite the evidence from the first. And it does not merge records across shards, because in a matrix build each shard has its own JVM and its own map; if you shard, upload one artifact per shard and merge afterwards.

What the fix costs

Being honest about the price is what separates a design from a slogan.

Latency: effectively nothing. Two map operations per session, against a browser session that takes hundreds of milliseconds to create. The concurrent map's retrievals are documented as generally non-blocking, and you are doing one insert and one remove per session. If your suite is slow, this is not why.

Memory: small but not zero. One record per live session, holding maybe eight short strings. At a hundred concurrent sessions that is negligible. It becomes a problem only if you leak, which is what the end-of-suite assertion exists to catch.

Complexity: one new class and one new discipline. The class is easy. The discipline is the cost: every driver creation path must register, and every teardown path must release. If your framework has three ways to build a driver (a base class, a fixture, and one legacy helper somebody wrote in 2021), you have to fix all three or the registry silently under-reports. Budget for the grep, not for the class.

Coverage: it fixes attribution, not causation. Knowing for certain that the failure was Firefox 121 on node abc does not tell you why it failed. It tells you where to look. That is a real improvement and it is also a limited one.

A behaviour change you should expect. putIfAbsent throwing on a duplicate claim will surface bugs that were previously invisible. The first run after you ship this may fail in a way that looks like a regression and is actually a pre-existing defect finally becoming visible. Say so in the pull request or you will spend a morning defending the change.

Rollout path

Shipping this into an existing suite in one commit tends to go badly. Stage it.

  1. Add the registry and the listener in shadow mode. Register and release, write the JSON records, and change nothing about the existing report. Run for a week. You now have ground truth without having touched anything that people depend on.

  2. Run the two jq checks manually against those records. If duplicates or mixed-browser tests show up, you have found the bug before changing any reporting code, and you have the evidence to justify the rest of the work.

  3. Switch putIfAbsent's duplicate branch from a log line to a thrown exception. Do this as its own commit so it can be reverted independently. Expect noise; the noise is the point.

  4. Repoint the reporter at the registry. Only now does the visible report change. Because you have a week of shadow records, you can diff old report against new and explain every difference.

  5. Add the CI gate and the end-of-suite leak assertion. Last, because both will fail loudly, and you want them failing on a codebase you have already cleaned.

  6. Delete the old static fields. If you skip this, someone will use them again within a month. Remove the alternative, do not merely deprecate it.

If your factory is also responsible for choosing between local drivers, a Grid, and a vendor, this is a good moment to read up on how a session factory should be structured before you bolt the registry onto whatever is there now.

Trade-offs worth arguing about

Static registry versus injected dependency. A static map is easy to call from a listener that the framework instantiates for you. It is also global mutable state, which makes unit-testing the registry itself awkward and makes it impossible to run two isolated suites in one JVM. If your framework has proper dependency injection, prefer an injected singleton scoped to the suite. If it does not, a static with an aggressive leak assertion is a reasonable trade, and pretending otherwise just means people keep the broken static fields instead.

Records in memory versus records on disk. The version above does both: the map for live lookup, the log for post-run analysis. You could skip the map and write only to disk, at the cost of not being able to answer questions during the run (retry logic, for example, often wants to know what the previous attempt used). You could skip the disk and keep only the map, at the cost of losing everything when the JVM exits, including on the CI timeouts you most want to investigate. Keeping both is duplication, and it is duplication that has repeatedly earned its place.

Throwing on a duplicate claim versus logging it. Throwing surfaces bugs immediately, and it also converts a reporting inaccuracy into a test failure, which some teams will not accept on a release branch. A middle position: throw in CI, log in local development, gated on an environment variable. Decide explicitly rather than defaulting.

Session ID versus a synthetic correlation ID. A synthetic UUID generated before session creation covers the case where the session is never created at all, which the session ID cannot. It also cannot be joined against Grid logs, the Session Map, or GraphQL, which is most of the value. The pragmatic answer is both: a synthetic ID as the primary key of your own record, with the session ID as a nullable field on it. That costs one more column and removes the whole class of "the session failed to start so we have no record" gaps.

When not to do this

Your suite is single-threaded and will stay that way. Then there is exactly one writer, the static field is correct, and this is ceremony. Fix the thread count first if you want the speed; do not add concurrency machinery to a sequential suite because an article said to.

You are running one test per JVM fork. Surefire with forkCount high and reuseForks=false gives each test its own process and its own static field. There is no sharing to protect against. The correlation problem still exists across forks, but a ConcurrentHashMap does not solve it; a shared log file or an external store does.

You are on pytest rather than Java. The mechanism is Java-specific. Python's GIL does not make shared mutable state safe, but the idiomatic fix is completely different: session-scoped and function-scoped fixtures already give each test its own driver object, and pytest-xdist workers are separate processes with separate memory. Do not port this design; port the principle.

Your reports are already correct and you cannot demonstrate a problem. Run the jq duplicate check against a week of runs first. If it comes back empty every time, whatever your framework is doing is working, and there is a better use of the sprint. Fix bugs you can prove, not bugs you have read about.

The real problem is Grid capacity, not attribution. If tests are queuing and timing out, the report labels are a side show. Sessions that never start produce no metadata to correlate, and the fix lives in Node capacity and dynamic provisioning, not in a map. Look at the queue depth before you look at the map.

You would have to store the driver to make it work. If the only way your reporter can get what it needs is a live WebDriver from a shared map, stop and change what you capture instead. A shared mutable driver handle is a worse bug than the one you started with: it turns a wrong label into a test that can genuinely interfere with another test's browser.

Practise the judgment, not the syntax

Reciting putIfAbsent in an interview is worth very little. Being able to say "the report is wrong because two workers own one slot, and here is the one query that proves it" is worth a lot, and it is the same skill that gets you out of a bad Friday afternoon.

Take a run from your own suite this week and answer three questions with evidence rather than memory. Does any session ID appear under two test names? Does any test name appear with two browser names? Does the Grid's sessionsInfo agree with your local record for a sample of ten sessions? If all three come back clean, your attribution is sound and you can stop reading about this. If any of them does not, you now know exactly which of the three diagnoses above you are looking at.

You can drill the same reasoning under time pressure in the QABattle arena: pick a Java or Selenium scenario, commit to a diagnosis before you look at the answer, and name the single artifact that would rule out your second-best hypothesis. That last habit, naming the artifact that would prove you wrong, is the one that transfers.

// 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 4, 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

Why does my TestNG report show the wrong browser only when I run in parallel?

Two workers almost certainly wrote to one metadata slot. A static field, or a plain HashMap, or a ThreadLocal that was never cleared, holds one value while several threads are trying to own it. Single-threaded runs hide the bug because there is only ever one writer. The report is not lying about the failure, it is reading a slot that a different test overwrote between the exception and the listener callback.

Is ConcurrentHashMap enough on its own, or do I still need ThreadLocal?

Both solve different halves of the problem and most frameworks need both. ThreadLocal answers the question 'which driver does the code running right now own', which is what page objects need. A concurrent map answers 'given this session id from a log line or a report row, what were the facts', which is what reporters, listeners and post-run analysis need. Using only ThreadLocal means anything outside the test thread is blind.

What should the map key be: thread name, test name, or session ID?

Session ID is the only key that survives every boundary you care about. Thread names get recycled by the pool. Test names repeat across data-driven iterations and retries. The session ID appears in your Java code, in the Grid Session Map, in Node logs and in the GraphQL response, so keying on it lets a failure record join to infrastructure evidence without guessing.

Why does driver.getSessionId() come back null in my teardown?

Selenium declares that return value as nullable in the RemoteWebDriver API, so null is a legal answer and your code has to handle it rather than assume a value. The practical rule is to read the session ID once, immediately after the driver is created, and store the string. Code that reaches for it again during teardown is asking a driver whose lifecycle it no longer controls.

How do I stop the registry from leaking entries across a long suite?

Removal has to be owned by the same layer that created the entry, and it has to run on the failure path too. If registration happens in a factory and removal happens in an @AfterMethod that a hard failure can skip, the map grows for the whole run. Put removal in the same try/finally that calls quit(), then add a suite-level assertion that the map is empty when the run ends.

Does any of this help when tests run in separate JVM forks?

No, and that is worth being blunt about. A static map lives inside one JVM. Surefire forks, Gradle max-parallel-forks and sharded CI jobs each get their own copy, so cross-shard correlation needs the records flushed to a file or a service and merged afterwards. The map fixes attribution inside a process; it does not fix attribution across processes.