PRACTICAL GUIDE / Selenium BiDi navigation network event correlation

Join Selenium BiDi navigation and network events correctly

Correlate Selenium BiDi navigation, request, response, redirect, and frame events by protocol identity, with runnable Java tests and diagnostics.

By The Testing AcademyUpdated August 7, 202625 min read
All field guides
In this guide8 sections
  1. Use protocol ids as joins, not URLs
  2. Capture one main-document lifecycle
  3. Keep every redirect hop separate
  4. Reject frame and subresource near-misses
  5. Separate a child response from a replacement navigation
  6. Diagnose missing or mismatched events
  7. Roll out correlation where it adds evidence
  8. Assign the fix from the first divergent field

What you will learn

  • Use protocol ids as joins, not URLs
  • Capture one main-document lifecycle
  • Keep every redirect hop separate
  • Reject frame and subresource near-misses

The test navigates through two redirects and lands on the right page, but its assertion records the 200 response from an iframe instead of the main document. Both responses contain /account, and the first matching listener wins. The fix is not a narrower timestamp window. The events need to be joined by the identities the BiDi protocol provides.

Network events are asynchronous and globally interleaved. URLs, callback order, and list position describe what you happened to see. Request, redirect, navigation, and browsing-context ids describe which operation owned it.

Use protocol ids as joins, not URLs

Selenium's Java Network module subscribes to W3C WebDriver BiDi network events over the session's bidirectional connection. The browser must expose that connection. Selenium's official Java examples request it with the webSocketUrl capability before constructing the driver:

Java
FirefoxOptions options = new FirefoxOptions();
options.setCapability("webSocketUrl", true);
WebDriver driver = new FirefoxDriver(options);

The network callbacks receive event objects derived from BaseParameters. The fields used for correlation are:

  • getRequest().getRequestId() identifies the network request chain.
  • getRedirectCount() identifies one hop inside that chain.
  • getNavigationId() identifies the navigation, or returns null when the request is not tied to one.
  • getBrowsingContextId() identifies the top-level page or child context that owns the event.
  • getTimestamp() records when the event was emitted.

BeforeRequestSent also contains the request method, URL, headers, and initiator. ResponseDetails adds getResponseData(), including response URL, protocol, status, headers, cache state, and sizes.

Two keys answer different questions:

Example
navigation key = (browsingContextId, navigationId)
request-hop key = (requestId, redirectCount)

The navigation key groups the main transition. The hop key joins beforeRequestSent to responseStarted or responseCompleted for one HTTP exchange. Keep the event phase in the journal as well so a start record cannot be mistaken for a completed body.

Redirects make this distinction essential. The WebDriver BiDi specification keeps the same request id for a request resulting from a redirect. redirectCount advances. A map keyed only by request id replaces the 302 record with the 307 and later the 200. A list keyed only by URL fails when a redirect revisits a URL or two contexts request the same address.

Navigation ids can be null. A background fetch, image, or stylesheet may belong to a browsing context without being the navigation request. Do not replace null with the latest navigation id in a global variable. That creates a causal link the protocol did not report.

Event order is local, not a universal sequence. One request's beforeRequestSent precedes its response events, but events for other requests can appear between them. A page can start parsing, create an iframe, and issue fetches before the main response's body completes. Sorting every event by timestamp does not give one linear navigation transaction.

The Java network API is marked beta in Selenium's current Javadocs. Pin Selenium and browser versions in CI, compile against the methods your suite uses, and review release notes before upgrading. Do not hide API changes behind reflection or untyped maps merely to keep one helper compiling.

Capture one main-document lifecycle

This JUnit 5 example subscribes before navigation, stores every request start by hop key, and completes its future only for the main document's successful terminal response. It uses the public Selenium Java APIs shown in the official docs.

Java
// src/test/java/example/NavigationNetworkCorrelationTest.java
package example;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;

import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.bidi.module.Network;
import org.openqa.selenium.bidi.network.BeforeRequestSent;
import org.openqa.selenium.bidi.network.ResponseDetails;
import org.openqa.selenium.firefox.FirefoxDriver;
import org.openqa.selenium.firefox.FirefoxOptions;

class NavigationNetworkCorrelationTest {
  private static final String TARGET =
      "https://www.selenium.dev/selenium/web/bidi/logEntryAdded.html";

  private WebDriver driver;

  record HopKey(String requestId, long redirectCount) {
    static HopKey from(BeforeRequestSent event) {
      return new HopKey(event.getRequest().getRequestId(), event.getRedirectCount());
    }

    static HopKey from(ResponseDetails event) {
      return new HopKey(event.getRequest().getRequestId(), event.getRedirectCount());
    }
  }

  @BeforeEach
  void createDriver() {
    FirefoxOptions options = new FirefoxOptions();
    options.setCapability("webSocketUrl", true);
    driver = new FirefoxDriver(options);
  }

  @AfterEach
  void quitDriver() {
    if (driver != null) {
      driver.quit();
    }
  }

  @Test
  void correlatesTheMainDocumentResponse() throws Exception {
    Map<HopKey, BeforeRequestSent> starts = new ConcurrentHashMap<>();
    CompletableFuture<ResponseDetails> documentCompleted = new CompletableFuture<>();
    String topLevelContext = driver.getWindowHandle();

    try (Network network = new Network(driver)) {
      network.onBeforeRequestSent(event -> starts.put(HopKey.from(event), event));

      network.onResponseCompleted(event -> {
        boolean isTopLevelContext =
            topLevelContext.equals(event.getBrowsingContextId());
        boolean isNavigation = event.getNavigationId() != null;
        boolean isTarget = TARGET.equals(event.getResponseData().getUrl());
        boolean isSuccess = event.getResponseData().getStatus() == 200;

        if (isTopLevelContext && isNavigation && isTarget && isSuccess) {
          documentCompleted.complete(event);
        }
      });

      driver.get(TARGET);

      ResponseDetails completed = documentCompleted.get(5, TimeUnit.SECONDS);
      BeforeRequestSent started = starts.get(HopKey.from(completed));

      assertNotNull(started, "No matching beforeRequestSent event");
      assertNotNull(completed.getNavigationId(), "Document response has no navigation id");
      assertEquals(started.getNavigationId(), completed.getNavigationId());
      assertEquals(started.getBrowsingContextId(), completed.getBrowsingContextId());
      assertEquals(started.getRequest().getUrl(), completed.getResponseData().getUrl());
      assertEquals("get", started.getRequest().getMethod().toLowerCase());
      assertEquals(200, completed.getResponseData().getStatus());
      assertEquals(driver.getCurrentUrl(), completed.getResponseData().getUrl());
    }
  }
}

The URL predicate narrows candidates, but it is not the join. The response still has to belong to the top-level context and a navigation, and its hop key must retrieve the matching start event. The final URL assertion connects protocol evidence with the user-visible WebDriver state.

Using a plain CompletableFuture completed by the first response callback is a common near-miss. The first response may be an HTTP redirect, preload, favicon, iframe document, or request from a previous action. Put the predicate inside the callback. A future cannot discard a wrong value after complete() succeeds.

The concurrent map matters because BiDi callbacks do not run as part of the test's sequential Java call stack. A HashMap shared between callback execution and the test thread has no concurrency guarantees. ConcurrentHashMap, CopyOnWriteArrayList, or another deliberate synchronization mechanism makes visibility explicit.

Five seconds in this example bounds event delivery after a public test page navigation. It is not a cure for an incorrect predicate. A future that watches the wrong context will time out at fifty seconds too. On a team service, use the test framework's owned timeout budget and report the last observed keys when it expires.

Keep every redirect hop separate

A local server makes redirect correlation deterministic. This JDK HttpServer sends 302 from /start, 307 from /login, then 200 from /home.

Java
// Additional imports for the same JUnit test class
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;

private HttpServer server;
private String origin;

@BeforeEach
void startServer() throws Exception {
  server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
  server.createContext("/start", exchange -> redirect(exchange, 302, "/login"));
  server.createContext("/login", exchange -> redirect(exchange, 307, "/home"));
  server.createContext("/home", exchange -> {
    byte[] body = "<h1>Home</h1>".getBytes(StandardCharsets.UTF_8);
    exchange.getResponseHeaders().add("Content-Type", "text/html; charset=utf-8");
    exchange.sendResponseHeaders(200, body.length);
    exchange.getResponseBody().write(body);
    exchange.close();
  });
  server.start();
  origin = "http://127.0.0.1:" + server.getAddress().getPort();
}

private static void redirect(HttpExchange exchange, int status, String location)
    throws java.io.IOException {
  exchange.getResponseHeaders().add("Location", location);
  exchange.sendResponseHeaders(status, -1);
  exchange.close();
}

@AfterEach
void stopServer() {
  if (server != null) {
    server.stop(0);
  }
}

If the class already creates and quits the driver in @BeforeEach and @AfterEach, JUnit runs all methods at each lifecycle point. The redirect test records completed hops and sorts them by redirect count before asserting.

Java
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.Comparator;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.stream.Collectors;

record CompletedHop(
    String requestId,
    long redirectCount,
    String navigationId,
    String contextId,
    int status,
    String url) {}

@Test
void preservesAllRedirectHops() throws Exception {
  Map<HopKey, BeforeRequestSent> starts = new ConcurrentHashMap<>();
  List<CompletedHop> completedHops = new CopyOnWriteArrayList<>();
  CompletableFuture<ResponseDetails> finalResponse = new CompletableFuture<>();

  try (Network network = new Network(driver)) {
    network.onBeforeRequestSent(event -> starts.put(HopKey.from(event), event));
    network.onResponseCompleted(event -> {
      if (event.getNavigationId() == null) {
        return;
      }

      CompletedHop hop = new CompletedHop(
          event.getRequest().getRequestId(),
          event.getRedirectCount(),
          event.getNavigationId(),
          event.getBrowsingContextId(),
          event.getResponseData().getStatus(),
          event.getResponseData().getUrl());
      completedHops.add(hop);

      if ((origin + "/home").equals(hop.url()) && hop.status() == 200) {
        finalResponse.complete(event);
      }
    });

    driver.get(origin + "/start");
    ResponseDetails terminal = finalResponse.get(5, TimeUnit.SECONDS);
    String navigationId = terminal.getNavigationId();

    List<CompletedHop> navigationHops = completedHops.stream()
        .filter(hop -> navigationId.equals(hop.navigationId()))
        .filter(hop -> driver.getWindowHandle().equals(hop.contextId()))
        .sorted(Comparator.comparingLong(CompletedHop::redirectCount))
        .toList();

    assertEquals(List.of(302, 307, 200),
        navigationHops.stream().map(CompletedHop::status).toList());
    assertEquals(List.of(0L, 1L, 2L),
        navigationHops.stream().map(CompletedHop::redirectCount).toList());

    Set<String> requestIds = navigationHops.stream()
        .map(CompletedHop::requestId)
        .collect(Collectors.toSet());
    assertEquals(1, requestIds.size(), "Redirect chain should keep one request id");

    assertTrue(navigationHops.stream().allMatch(hop ->
        starts.containsKey(new HopKey(hop.requestId(), hop.redirectCount()))));
    assertEquals(origin + "/home", driver.getCurrentUrl());
  }
}

A useful log for this test keeps all keys visible:

Example
phase=responseCompleted request=8f... redirect=0 navigation=2c... context=71... status=302 url=http://127.0.0.1:53119/start
phase=responseCompleted request=8f... redirect=1 navigation=2c... context=71... status=307 url=http://127.0.0.1:53119/login
phase=responseCompleted request=8f... redirect=2 navigation=2c... context=71... status=200 url=http://127.0.0.1:53119/home

This output explains ownership without relying on event array position. If a browser implementation omits a terminal event or reports a different redirect behavior, the retained journal shows the exact missing phase and protocol fields. Do not change expected counts until the browser, Selenium, and W3C behavior have been checked together.

Reject frame and subresource near-misses

Two events with the same URL are not necessarily duplicates. A top-level document, iframe document, fetch(), preload, and service worker can all request one endpoint. Browsing-context id separates page and frame ownership. Navigation id distinguishes navigation-tied traffic from requests that merely occurred while a navigation was active.

Suppose /account embeds an iframe that also navigates to /account?compact=1. A listener using url.contains("/account") may complete on the child response. Require the top-level context id and compare the terminal response with driver.getCurrentUrl() for a main-document assertion.

Selenium's Java Network class also provides constructors that accept one browsing-context id or a set of ids. Scoping the subscription can reduce noise:

Java
String topLevelContext = driver.getWindowHandle();
try (Network network = new Network(topLevelContext, driver)) {
  // Register callbacks before driver.get(...).
}

Still retain and verify context ids in the journal. Subscription scope is configuration; the event payload is the observed evidence. If the test later starts listening to child frames, a hidden assumption in the helper should not silently broaden the result.

An API call initiated after navigation often has a null navigation id. Correlate it to the user action with a future registered before the click, then use request-hop identity between its start and completion. Do not copy the page's last navigation id onto the API event. The protocol is correctly telling you that the fetch is not itself a navigation.

Java
CompletableFuture<ResponseDetails> quoteCompleted = new CompletableFuture<>();
Map<HopKey, BeforeRequestSent> starts = new ConcurrentHashMap<>();
String topLevelContext = driver.getWindowHandle();

network.onBeforeRequestSent(event -> starts.put(HopKey.from(event), event));
network.onResponseCompleted(event -> {
  boolean sameContext = topLevelContext.equals(event.getBrowsingContextId());
  boolean rightMethod = "post".equals(event.getRequest().getMethod().toLowerCase());
  boolean rightPath = event.getResponseData().getUrl().endsWith("/api/quote");
  if (sameContext && rightMethod && rightPath) {
    quoteCompleted.complete(event);
  }
});

driver.findElement(By.id("calculate-quote")).click();
ResponseDetails response = quoteCompleted.get(5, TimeUnit.SECONDS);
BeforeRequestSent request = starts.get(HopKey.from(response));
assertNotNull(request);
assertEquals(request.getRequest().getRequestId(), response.getRequest().getRequestId());

This snippet requires org.openqa.selenium.By and an application that exposes the shown button and API. Its important ordering is listener, action, terminal event, hop join. URL and method select the business request; protocol ids prove that the start and response belong together.

Cache is another near-miss. ResponseData.isFromCache() tells you whether the reported response came from cache. A cached 200 can be the correct product behavior but the wrong evidence for a test that intends to inspect a fresh network exchange. Record the cache flag and set the test's cache behavior deliberately through supported BiDi APIs only when cache control is part of the case.

Separate a child response from a replacement navigation

An independent top-level navigation can produce the same compact log as the iframe failure. Consider a page that reaches /account, then application code reloads or replaces that document with another navigation to the same URL. A listener that accepts the first 200 still returns a real top-level document response. The browser eventually displays the document from the second navigation. If the log contains only time, URL, and status, both rows look like valid answers, just as the parent and child responses did.

The root cause is different. An iframe capture has a browsing-context mismatch: the selected response carries the child context rather than the top-level window handle. Two top-level navigations carry the same expected context, so the context check passes for both. Their navigation ids differ, and each navigation has its own request chain. The journal therefore shows two non-null navigation ids in one top-level context, with distinct request ids, even when the response URLs and statuses are identical. That is the evidence that separates an unexpected replacement navigation from a response owned by a frame.

Redirects do not have that shape. Hops in one redirect chain retain the request id and separate the exchanges with redirect count. Independent navigations separate at both navigation id and request id. Looking at those fields together prevents an automatic reload from being misreported as a redirect bug.

The test also needs an explicit action boundary. Clear or replace its per-action candidate collection immediately before the click or navigation that is under examination, without reusing a future from setup. Stop accepting candidates when the expected product state is observed. If two navigation ids still appear inside that boundary, preserve both rather than selecting the earliest or latest callback. Protocol identity proves that two navigations occurred, but it does not say which one the product specification intended.

The final URL cannot resolve this case because both documents can expose the same URL. Add a page-level condition that distinguishes the intended state, such as a known post-login element or another product assertion already owned by the test. Use that condition to close the action window, not to infer a navigation id that the page assertion does not expose. If more than one top-level navigation id remains in that window, report both and treat the extra navigation as the finding. Choosing the last timestamp would hide the behavior the test is supposed to reveal.

Diagnose missing or mismatched events

Read a diagnostic record from identity outward. The session and attempt fields define the outer boundary. The phase says whether the row is a request start, an early response, a completed response, or a fetch error. Request id and redirect count form the exchange key. Navigation id and context state who owned that exchange. Method, URL, status, protocol, cache state, and timestamp describe it, but none of those descriptive fields can repair a failed identity join.

In an illustrative healthy pair, a start row might show session S1, request R7, redirect count 0, navigation N3, and context C1. Its completion row should repeat all five ownership values. The completion adds a successful status and its response URL, while the start supplies the request method. A redirect adds another pair with the same request and navigation values but the next redirect count. The useful visual pattern is not that the rows are adjacent. It is that every completed hop has one start under the same composite key and that the navigation and context remain appropriate for the claim.

For the iframe failure, the broken completion can still show the expected URL and a 200 status. Its context reads C9 while the top-level handle recorded by the test is C1. A non-null navigation id does not make that row healthy because a child document has its own legitimate navigation. The context field is the decisive divergence. For the replacement-navigation failure, context remains C1, but one candidate reads navigation N3 and request R7 while the other reads navigation N4 and request R8. Here the pair of changed ids is decisive, not the URL.

Several values are actively misleading when displayed alone. A 200 status says the server returned a successful response, not that the response belongs to the tested navigation. A null navigation id is healthy for many fetches but broken evidence for a main-document claim. A cache value indicating a cached response can be healthy for a normal page test and unacceptable for a test of a fresh exchange. Close timestamps make two callbacks look related even when their contexts differ. A request id that matches while redirect count differs identifies one redirect chain, not one HTTP hop.

The most useful timeout output names the expected key before listing candidates. It should say which session, attempt, top-level context, action, URL or path, method, and terminal phase the test was waiting for. Each rejected candidate should carry a short reason such as wrong context, different navigation, wrong method, cached when fresh was required, or missing start for the hop. That makes a field-level mismatch visible without asking a reviewer to reconstruct the predicate from a raw event dump.

Run one method alone with the same browser and Selenium version used in CI. Maven and Gradle can target a JUnit 5 method without adding framework retries:

Shell
mvn test -Dtest=NavigationNetworkCorrelationTest#preservesAllRedirectHops

./gradlew test --tests \
  'example.NavigationNetworkCorrelationTest.preservesAllRedirectHops'

Print or attach a bounded journal with phase, request id, redirect count, navigation id, context id, method, URL, status, protocol, cache flag, and event timestamp. Redact authorization, cookies, and sensitive query values. Network bodies are not required to prove event ownership.

Classify the first broken join:

EvidenceLikely causeCountercheck
No events at allBiDi connection or subscription setupConfirm webSocketUrl capability and listener registration before the trigger
Start exists, completion absentRequest failed, listener closed, or browser gapRecord fetch-error events and the Network module lifetime
Completion has no matching hop keyStart was missed or map key is wrongCompare request id plus redirect count, not request id alone
Matched hop has another contextIframe or tab capturedCompare with the top-level window handle and expected page identity
Same URL appears with null navigation idSubresource or fetchRequire a navigation id only for a document-navigation claim
302, 307, and 200 collapse into one recordMap keyed only by request idInclude redirect count and retain each phase
Final protocol event is correct but UI is wrongProduct rendering or later requestKeep the WebDriver assertion after protocol correlation

Subscribe before driver.get(). Building Network after navigation cannot recover earlier events. A larger future timeout does not change that. If setup navigation is needed before the case, create the module afterward only when those setup events are intentionally out of scope, then register all listeners before the product trigger.

Keep the Network object open until the last expected callback is consumed. A try-with-resources block that ends immediately after driver.get() can close subscriptions while background responses are still arriving. Await the matched terminal future inside the block, then close it.

Treat responseStarted and responseCompleted as different phases. The started event proves that response status and headers arrived. It does not prove that the body finished. A download, streamed document, or interrupted connection can start successfully and later fail. Use onResponseStarted when early headers are the subject; use onResponseCompleted for a successful terminal network record.

Fetch errors are the other terminal branch. Record them with the same hop key instead of waiting only for a completion that will never arrive:

Java
import org.openqa.selenium.bidi.network.FetchError;

Map<HopKey, String> terminalByHop = new ConcurrentHashMap<>();

network.onResponseCompleted(event -> {
  HopKey key = HopKey.from(event);
  terminalByHop.put(key, "response:" + event.getResponseData().getStatus());
});

network.onFetchError(event -> {
  HopKey key = new HopKey(
      event.getRequest().getRequestId(),
      event.getRedirectCount());
  terminalByHop.put(key, "fetchError:" + event.getErrorText());
});

Keep the raw error text as evidence, but do not branch product logic on its wording. Browser and operating-system versions can phrase network failures differently. Ownership still comes from request id, redirect count, context, and navigation id. If a start record has neither a completed response nor fetch error when the timeout expires, report the missing terminal phase explicitly.

When events are aggregated outside the test process, include the WebDriver session identity. BiDi request and navigation ids are scoped to their session; they are not globally unique across parallel browsers or Grid nodes. In Java, a Selenium driver that extends RemoteWebDriver exposes its session id:

Java
import org.openqa.selenium.remote.RemoteWebDriver;

String sessionId = ((RemoteWebDriver) driver).getSessionId().toString();
System.out.printf(
    "session=%s request=%s redirect=%d navigation=%s context=%s%n",
    sessionId,
    response.getRequest().getRequestId(),
    response.getRedirectCount(),
    response.getNavigationId(),
    response.getBrowsingContextId());

Add the test case id and attempt number supplied by JUnit or the CI reporter when logs from retries share one file. A request id from attempt two must never satisfy a join left open by attempt one.

Thread safety deserves direct attention. Callback code should do little work: validate fields, write to concurrent storage, and complete a future. Blocking the callback on WebDriver commands or a long file write can delay later BiDi messages and create an artificial timeout. Perform UI assertions and artifact serialization on the test thread after the future resolves.

An assertion that receives a response from an unrelated redirect or frame is not fixed by taking the last event in the list. Under a faster browser, the last event might be telemetry. State the expected owner first, filter on observed context and navigation identity, and join start to completion with the hop key.

Roll out correlation where it adds evidence

Begin with one main-document test and one deterministic redirect fixture. Keep the event journal even on success until the team understands normal browser variation. Add frame and API cases only after the helper represents nullable navigation ids and multiple contexts honestly.

For an existing suite, land the passive collector before changing any verdict. The first change should define the hop key, per-test storage, redaction rules, buffer bounds, listener lifetime, and artifact format. Attach the bounded journal to a small group of current tests, but leave their existing waits and assertions in control. This stage exposes whether the bidirectional connection is available in every supported execution path without turning an observability gap into a wave of product failures.

Land the deterministic contract cases next. They should exercise one ordinary document, the known redirect chain, a rejected child context, and the same-URL replacement-navigation shape if the application has that risk. Run those cases on each topology that the suite actually supports before converting broad product coverage. A passing local contract with an empty remote journal indicates an infrastructure boundary, not a reason to loosen product predicates.

Only then convert one stable product test. Keep its visible page assertion, replace the URL-first network selection with the identity join, and compare the passive journal with the new selected hop. Remove the old selector only after the new path has produced the same intended candidate on repeated normal runs and has rejected the negative controls. Expand by helper family rather than changing every test that mentions a URL in one patch. This keeps one helper defect from multiplying across the entire suite.

Capability setup tends to break first because a session with no usable bidirectional connection produces an empty journal. Shared-driver fixtures break next: a listener can be created too late, closed during teardown, or left alive to observe the following test. Parallel execution then reveals missing session and attempt boundaries in artifacts. The rollout should classify those failures separately from a product response mismatch. Retrying an empty or cross-test journal without fixing ownership makes the suite appear healthier while preserving the race.

The change is working when each selected terminal hop retrieves exactly its matching start, negative-control traffic never completes the future, and retry artifacts contain only their own session and attempt. A product failure should identify the first divergent field rather than end as a bare timeout. Also compare suite duration and timeout distribution with the previous wait strategy. Correct correlation that pushes routine cases against the test budget needs a narrower predicate or a different terminal phase, not a larger global timeout.

There is a concrete latency cost when a migrated test waits for responseCompleted but its old UI condition became true earlier. The test now pays the gap until the response body finishes and the callback is delivered. For a streamed, delayed, or unusually large response, that gap can consume the case's remaining timeout even though the page already looks usable. Use completion only when completed transfer is part of the claim. A headers-only claim can stop at the earlier response phase, while a rendering claim should continue to use a page assertion.

The storage cap creates a coverage cost too. Filtering and bounding the journal keeps a noisy page from retaining every analytics and asset event, but an overly narrow prefilter can discard the very competing request needed to explain a false match. Preserve all phases for any request id that becomes a candidate, plus enough rejected candidates to show why they lost. This requires maintenance whenever the application's request shape changes, especially when a navigation starts using a different context or adds another redirect.

Exercise a negative control before trusting the helper. Start two local navigations with similar paths in different browsing contexts and assert that the main-page future ignores the child context. Then issue two requests to the same API URL with different methods and assert that their hop keys remain distinct. A collector tested only against one request on a blank page has not faced the ambiguity it exists to solve.

Retain a bounded diagnostic snapshot when a future times out: all starts matching the target host, all terminal records for those request ids, and the contexts observed. This turns TimeoutException into a reviewable statement such as "document start observed in context A, response completed in context B" or "start observed, fetchError terminal received." Do not dump every header and cookie to gain that clarity.

Put the join in a small value type such as HopKey, not a static global collector. Give each test its own Network module, maps, futures, and cleanup. A suite-level listener can mix sessions and tests under parallel execution unless it adds session identity and strong ownership at every boundary.

The cost is memory and synchronization. Retaining every event on a modern page can produce thousands of records. Filter early by context, navigation presence, host, or resource role, then cap diagnostic buffers. Preserve all hops for the request under test, not every analytics request on the page.

Protocol correlation also increases version coupling. The Java API is beta, browsers implement BiDi features at different speeds, and Grid adds another connection boundary. Pin a compatible stack, run a small contract test after upgrades, and keep source links near the helper so reviewers can verify method names and nullability.

Run that contract test on the same topology as the suite. A local Firefox run proves the client and browser can exchange events, but it does not prove that a remote Grid preserves the bidirectional connection through its router and node. Keep one deterministic redirect case in each supported topology, and fail it with the negotiated capabilities plus session id when event delivery is absent.

Assign the fix from the first divergent field

The automation team owns the collector until it can produce a bounded, correctly joined record. That includes listener timing, hop keys, concurrent storage, cleanup, redaction, and the assertion that chooses a candidate. An empty journal in one execution topology moves to the browser or Grid platform owner only after the same test and browser combination produces events in a known-good topology and the session capabilities have been captured.

Once the collector is trustworthy, ownership follows the evidence. An unexpected child context or a second top-level navigation belongs first with the frontend team that controls frames and client navigation. A correctly owned request with the wrong redirect target or status belongs with the service that emitted that response. A failure present only through a proxy, router, or remote node belongs with the infrastructure path after a direct run proves the endpoint behavior. The QA owner should retain the reproducer and verify the eventual fix rather than asking another team to diagnose from a screenshot of TimeoutException.

The handoff needs one sanitized event slice containing the request start and every terminal candidate, including session, attempt, phase, request id, redirect count, navigation id, context, method, URL, status, cache state, and timestamps. Add the expected top-level context, the action that opened the observation window, the browser and Selenium versions, the execution topology, negotiated capabilities relevant to BiDi, and whether the deterministic contract passed there. State the first divergent field in one sentence. Include a minimal reproduction or the smallest deterministic fixture that shows the same shape. Headers should be limited to those needed for the diagnosis, with credentials, cookies, and sensitive query values removed.

A cross-team fix is complete only when the same evidence changes in the expected place. If the frontend removes a duplicate navigation, the rerun should contain one top-level navigation id inside the action boundary. If infrastructure repairs event forwarding, the previously empty remote journal should contain the same contract phases as the direct run. If a service repairs a redirect, the hop identity should remain stable while the status or destination changes. This verification prevents a broad retry or timeout increase from being mistaken for repair.

Do not use this machinery when a normal WebDriver assertion answers the question. If the product claim is only that the final URL and heading are correct, driver.getCurrentUrl() plus an element assertion is clearer. Add the network join when redirects, response status, protocol, cache source, request ownership, or competing frames are part of the failure you need to distinguish.

Protocol correlation does not catch a service that applies one business operation twice behind a single HTTP exchange. The journal can show one request id, one correctly joined response, and the expected status while the backend duplicates a charge, reservation, or record update internally. Nothing in the browser-level hop identity proves exactly-once service execution. Verify the resulting application state and use the service's own operation evidence when duplicate effects are the risk. Retrying or changing the network join would leave that defect untouched.

Avoid using event correlation as a performance benchmark. Callback timestamps and fetch timings can support diagnosis, but one functional browser run does not provide a sound load or latency test. Keep performance sampling, traffic generation, and service-level statistics in tools designed for them.

Finally, do not make URL text the fallback whenever an id is missing. A null navigation id is meaningful. Either the event belongs to a different kind of operation, or the implementation did not provide the evidence your claim requires. Narrow the claim, collect a different supported signal, or fail with the missing field. Invented ownership produces a confident report and the wrong root cause.

// 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 selenium.dev reference

    selenium.dev

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

  2. 02
    Official selenium.dev reference

    selenium.dev

    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

How do I match beforeRequestSent with responseCompleted in Selenium BiDi?

Build a hop key from the event's request id and redirect count. Confirm that browsing-context and navigation ids also match the page transition under test before treating the response as its result.

Does a WebDriver BiDi request id change after an HTTP redirect?

The protocol keeps the request id for the redirect chain and increments `redirectCount` for each hop. Using the request id alone overwrites or merges distinct redirect responses.

Why does my CompletableFuture capture the wrong network event?

A listener that completes on its first callback can receive a stylesheet, favicon, iframe, or background fetch. Apply the identifying predicate inside the callback and complete the future only for the expected context, navigation, method, and response.

Can I subscribe to BiDi network events after driver.get returns?

No historical replay is provided by the listener. Create the `Network` module and register callbacks before starting navigation, then wait for the matched terminal event.

Should I correlate network events by URL and timestamp?

URLs repeat and concurrent events interleave, while timestamps are observations rather than ownership keys. Use protocol ids for the join and retain URL, method, status, and timing as attributes to validate the result.