Selenium: WebDriver BiDi

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

Classic WebDriver is a conversation in which the test always speaks first: it sends a command over HTTP and waits for the answer. WebDriver BiDi adds a second channel, a WebSocket, over which the browser can speak first — to report a console message, a JavaScript error, a request that is about to leave the page or a tab that has just opened — and over which the test can ask the browser for things classic WebDriver never offered, such as pausing and rewriting network traffic. This page was written against Selenium 4.51.0 (released 9 October 2026) and checked on 25 October 2026 against the official documentation and the W3C Working Draft of 8 October 2026. The Python (pytest) and Java (JUnit 5) examples were run against the Bookshelf application with Chrome 155 on macOS (headless), and the Python suite was run again against Firefox 158 in the selenium/standalone-firefox:4.51.0 Docker image. C#, Ruby and JavaScript are described from the official documentation and were not run. For how BiDi fits next to classic WebDriver and the Chrome DevTools Protocol, see How Browser Automation Works; the sibling pages cover Getting Started, Drivers, Browsers & Options (the Options classes used below) and Selenium Grid (running BiDi through a Grid).

What BiDi adds

Classic WebDriver (W3C WebDriver Level 2) is request and response only. That is deliberate — it guarantees the browser does the expected things in the expected order — but it means a test cannot learn about anything it did not ask for. BiDi keeps all of classic WebDriver and adds a WebSocket on which both sides send JSON messages: commands from the test (each answered by a result or an error) and events from the browser (which the test must subscribe to first).

Area Classic WebDriver With BiDi

Console and JavaScript errors

Browser-specific log retrieval that you poll, nothing standard across browsers

Push events log.entryAdded with level, text, stack trace and source, in every BiDi browser

Network

Nothing; add a proxy, or use CDP in Chromium

Observe every request and response, pause them, change or fail them, answer them with a mock, answer authentication challenges

Script

executeScript in the page only

script.evaluate and script.callFunction in any realm or sandbox, preload scripts that run before the page’s own scripts, channels from page to test, realm events

Browsing contexts (tabs and windows)

Window handles; one active context at a time

Create, activate, close and navigate contexts by id without switching to them; events for created, destroyed, navigation started, DOM content loaded, load, fragment navigation and prompts; viewport; screenshots and printing of any context

Input

Actions API through the active context

input.performActions and input.releaseActions addressed to a context, input.setFiles

Other modules

Cookies and a few window commands

storage (cookies by partition), emulation (geolocation, time zone, locale, user agent, network conditions, screen), permissions, webExtension, browser (user contexts, client windows, download behaviour)

BiDi is not a different tool. A BiDi session is an ordinary WebDriver session that happens to have a WebSocket attached, so driver.find_element, waits, the Actions API and everything on the other Selenium pages keep working unchanged, and you add BiDi calls only where you need an event or a capability classic WebDriver lacks. Selenium is also moving its own implementation of classic features (screenshots, printing, window management) onto BiDi where the browsers support it while keeping backwards compatibility; the official BiDi section documents the new functionality that bidirectional communication makes possible.

Enabling BiDi

A session opts in by requesting the webSocketUrl capability with the value true. If the driver and browser support BiDi, the new-session response contains a webSocketUrl capability with the address of the WebSocket (for example ws://localhost:60538/session/abc1375e followed by the session id); the binding opens it lazily the first time you use a BiDi API. Every binding has a dedicated setting on its Options classes:

Binding Enable BiDi

Java

options.enableBiDi() (all Options classes), or options.setCapability("webSocketUrl", true)

Python

options.enable_bidi = True, or options.web_socket_url = True

C#

UseWebSocketUrl = true on the Options object

Ruby

options.web_socket_url = true

JavaScript

new Options().enableBidi()

Kotlin

Same as Java

Source: WebDriver BiDi

The two tabs of the official page that differ most from habit are Python (a dedicated enable_bidi property) and Java (enableBiDi() is shorthand for setting the capability; the official page still shows setCapability("webSocketUrl", true)). A pytest fixture that gives every test a fresh headless Chrome with BiDi enabled, plus a polling helper because events arrive on another thread:

import os
import time

import pytest
from selenium import webdriver

BASE_URL = os.environ.get("BASE_URL", "http://localhost:3915")


@pytest.fixture
def driver():
    # A fresh headless Chrome with the BiDi WebSocket enabled (capability webSocketUrl).
    options = webdriver.ChromeOptions()
    options.add_argument("--headless=new")
    options.enable_bidi = True
    d = webdriver.Chrome(options=options)
    yield d
    d.quit()


def wait_for(condition, timeout=10):
    """Poll until condition() is truthy; BiDi events arrive on another thread."""
    end = time.time() + timeout
    while time.time() < end:
        value = condition()
        if value:
            return value
        time.sleep(0.05)
    raise AssertionError("condition not met within %s s" % timeout)

Source: WebDriver BiDi

The Java equivalent is a JUnit 5 base class. driver is declared as RemoteWebDriver because the high-level BiDi methods (script(), network()) are defined there, not on the WebDriver interface:

abstract class BidiBase {
  static final String BASE_URL = System.getProperty("BASE_URL", "http://localhost:3915");

  RemoteWebDriver driver;

  @BeforeEach
  void start() {
    ChromeOptions options = new ChromeOptions();
    options.addArguments("--headless=new");
    options.enableBiDi(); // sets the webSocketUrl capability
    driver = new ChromeDriver(options);
  }

  @AfterEach
  void stop() {
    driver.quit();
  }
}

Source: WebDriver BiDi

Imports are omitted from the Java snippets on this page (org.openqa.selenium.*, org.openqa.selenium.bidi.* and the JUnit and java.util.concurrent classes they use). Notes that apply to every example:

  • Chrome and Firefox are the browsers with a complete BiDi implementation. Chromium’s BiDi support is provided by chromedriver (a BiDi mapper that translates to the DevTools Protocol), Firefox’s by geckodriver and the browser itself. Safari gained BiDi support in Selenium’s Ruby binding for Safari Technology Preview in 4.47; other bindings and Edge follow Chromium or are still catching up, so check a feature on your browser before depending on it. Features can be missing in one browser while present in another; the examples below note the differences found.

  • Headed or headless makes no difference to the protocol. The examples run headless; the same code runs headed.

  • The capability is only a request. A driver that does not support BiDi ignores it, and the first BiDi call then fails with an "Unable to find url to connect to from capabilities" error (Python) or a similar exception in the other bindings. Check driver.capabilities["webSocketUrl"] in a smoke test if a pipeline mixes browsers.

  • Through a Grid the URL is rewritten. A Grid session returns a webSocketUrl that points at the Grid (ws://localhost:4542/session/<id>/se/bidi in the Docker run for this page) and proxies the socket to the node, so only the Grid port has to be reachable. See Selenium Grid.

How an event subscription works

Everything in the event half of BiDi follows the same pattern, whatever the module. The binding sends session.subscribe with the event names, the browser answers with a subscription id, and from then on each matching event is pushed as a message with no request attached. A handler registered with the high-level API is called on a thread of the binding, not on the test thread (in Python each event starts a new thread), which is why the examples wait for a list to fill or a future to complete instead of asserting immediately. Removing the handler sends session.unsubscribe.

sequenceDiagram participant T as Test code participant S as Selenium binding participant D as chromedriver, geckodriver or Grid participant B as Browser T->>S: options.enable_bidi = True, start the driver S->>D: POST /session with alwaysMatch webSocketUrl true D->>B: launch the browser and set up BiDi D-->>S: sessionId and capabilities.webSocketUrl S->>D: open the WebSocket T->>S: driver.script.add_console_message_handler(callback) S->>D: session.subscribe for log.entryAdded D-->>S: success with a subscription id T->>S: driver.get() and driver.execute_script() B-->>D: the page calls console.log("catalogue ready") D-->>S: event log.entryAdded (type console, level info, text) S->>T: callback(ConsoleLogEntry) on a background thread T->>S: driver.script.remove_console_message_handler(id) S->>D: session.unsubscribe for the subscription id D-->>S: success

The figure shows a console subscription end to end. Commands such as browsingContext.navigate use the same socket but follow the plain request, result pattern with an id on both messages. Network interception adds a twist described in the network section: the event is also a question ("this request is paused, what should happen to it?") that the test must answer or the page stalls.

The three layers of API in Selenium

The official BiDi section states its aim plainly: low-level BiDi domains are reachable from code, but the project wants high-level methods for real-world use cases and documents only those. In practice you meet three layers:

Layer What it is Where

High-level

Methods on the driver for common jobs: console and JavaScript-error handlers, DOM mutation handlers, pinned scripts, authentication and request handlers

Java RemoteWebDriver.script() and network(); Python driver.script and driver.network; Ruby driver.script, driver.network, driver.on_log_event

Module classes

One class per BiDi module with a method per command or event, written by hand against the specification

Java org.openqa.selenium.bidi.module.* (Network, Script, LogInspector, BrowsingContextInspector, Input, Storage, Browser, Permission), org.openqa.selenium.bidi.browsingcontext.BrowsingContext; JavaScript LogInspector, Network, ScriptManager, BrowsingContext; .NET driver.AsBiDiAsync(); Python driver.browsing_context, driver.input, driver.storage, driver.emulation, driver.permissions, driver.webextension, driver.browser

Generated protocol layer

Classes generated from the specification’s schema that map one to one onto wire messages

Java org.openqa.selenium.bidi.protocol.*, .NET OpenQA.Selenium.BiDi.*, Python selenium.webdriver.common.bidi.*, Ruby Selenium::WebDriver::BiDi::*

Source: WebDriver BiDi

The "BiDi implementation API" warning

If your IDE strikes through a Selenium BiDi class or method, shows an "Internal API" tooltip, or (in .NET) emits a compiler warning, you have reached one of the internal BiDi implementation APIs. The official "BiDi implementation API is internal" page explains that these are the low-level classes that map directly onto the BiDi (and CDP) wire protocol, under the namespaces org.openqa.selenium.bidi.* (Java), OpenQA.Selenium.BiDi.* (.NET), selenium.webdriver.common.bidi.* (Python) and Selenium::WebDriver::BiDi::* (Ruby). They are public only because the higher-level features need them. They are marked internal to steer you toward the high-level APIs, which are meant to cover everything you need, but they remain available as an escape hatch for a use case the high-level APIs do not yet cover. The page points to the high-level network and script APIs and asks you to open an issue if your use case is only possible through the internal classes, so the high-level API can grow to cover it.

The practical rules this gives you are:

  • Prefer a high-level method when one exists, and fall back to a module class for events and commands the high-level layer lacks (browsing-context events, Network intercepts, preload scripts, input).

  • Treat the generated protocol layer as unstable. Between 4.46 and 4.51 Selenium marked the existing BiDi classes beta, moved Python, Ruby, JavaScript and .NET onto generated bindings and added a BiDi code generator to Java, so names and signatures in these layers can change in a minor release, and the examples on this page may need adjusting when you upgrade.

  • Pin the Selenium version in your build and read the changelog of the binding before upgrading a suite that uses module classes.

Logging: console messages and JavaScript errors

Because "logging" can mean many things, the official page groups the high-level handlers under the script namespace. Both are backed by the BiDi log.entryAdded event: a console handler receives entries whose type is console (a call to console.log, console.warn and so on), a JavaScript-error handler receives entries whose type is javascript (an uncaught exception or unhandled rejection). Adding a handler returns an id that you keep to remove it; a removed handler stops receiving entries. Handlers are tied to the session, not to a page, so they keep working across navigations.

def test_console_and_javascript_errors(driver):
    console, errors = [], []
    driver.script.add_console_message_handler(console.append)
    driver.script.add_javascript_error_handler(errors.append)

    driver.get(f"{BASE_URL}/books")
    driver.execute_script("console.log('catalogue ready'); setTimeout(() => { throw new Error('boom') }, 0)")

    wait_for(lambda: console and errors)
    assert console[0].text == "catalogue ready"
    assert console[0].level == "info"
    assert errors[0].text == "Uncaught Error: boom"
    assert errors[0].level == "error"


def test_removed_handler_stops_receiving(driver):
    entries = []
    handler_id = driver.script.add_console_message_handler(entries.append)
    driver.script.remove_console_message_handler(handler_id)
    driver.get(f"{BASE_URL}/books")
    driver.execute_script("console.log('nobody listens')")
    # Round-trip a second event so the absence is meaningful.
    marker = []
    driver.script.add_console_message_handler(marker.append)
    driver.execute_script("console.log('marker')")
    wait_for(lambda: marker)
    assert entries == []

Both tests passed with Chrome 155. With Firefox 158 (Docker) the second test passed and the first failed on one assertion: Firefox reports the error text as Error: boom where Chrome reports Uncaught Error: boom. Assert on a substring ("boom" in errors[0].text) if the suite runs on both. The Java version uses a CompletableFuture instead of a list:

@Test
void highLevelConsoleAndErrorHandlers() throws Exception {
  CompletableFuture<ConsoleLogEntry> console = new CompletableFuture<>();
  CompletableFuture<JavascriptLogEntry> error = new CompletableFuture<>();
  String consoleId = driver.script().addConsoleMessageHandler(console::complete);
  String errorId = driver.script().addJavaScriptErrorHandler(error::complete);

  driver.get(BASE_URL + "/books");
  driver.executeScript(
      "console.log('catalogue ready'); setTimeout(() => { throw new Error('boom') }, 0)");

  assertEquals("catalogue ready", console.get(5, TimeUnit.SECONDS).getText());
  assertEquals("Uncaught Error: boom", error.get(5, TimeUnit.SECONDS).getText());
  driver.script().removeConsoleMessageHandler(consoleId);
  driver.script().removeJavaScriptErrorHandler(errorId);
}

When you need the level, the source (context and realm) or the stack trace, or want to filter by level, use the module class instead. Java’s LogInspector has onConsoleEntry, onJavaScriptException, onJavaScriptLog, onGenericLog and onLog, each optionally taking a FilterBy; JavaScript’s LogInspector has onConsoleEntry and onJavascriptException. Each entry carries the level (debug, info, warn or error), the text, a timestamp, the source and the stack trace; console entries add the method (log, warn, …​) and the argument values:

@Test
void logInspectorReportsTheLevel() throws Exception {
  CompletableFuture<ConsoleLogEntry> console = new CompletableFuture<>();
  try (LogInspector inspector = new LogInspector(driver)) {
    inspector.onConsoleEntry(console::complete);
    driver.get(BASE_URL + "/books");
    driver.executeScript("console.warn('low stock')");
    ConsoleLogEntry entry = console.get(5, TimeUnit.SECONDS);
    assertEquals("low stock", entry.getText());
    assertEquals("warn", entry.getLevel().toString());
  }
}

The Python bindings also have add_console_handler and add_error_handler, which deliver entries that carry the source URL, line and column numbers and the stack trace; the *_message_handler and *_javascript_error_handler variants used above deliver the simpler entry type. In C# and JavaScript the official logging page shows no high-level handlers ("Implementation Missing"): use the DevTools-based IJavaScriptEngine events in C# (see the CDP section) and the W3C LogInspector in JavaScript. In Ruby, driver.script.add_console_message_handler and add_javascript_error_handler take a block and mirror Python.

A good use of these handlers in a suite is a global safety net: register both in the driver fixture, collect the entries, and fail the test (or at least attach the entries to the report) when an uncaught JavaScript error occurred during it. That catches application errors a UI assertion would not notice.

Network

The network module has two halves. Events are observation only: network.beforeRequestSent, network.responseStarted, network.responseCompleted, network.fetchError and network.authRequired tell you what the browser is doing. Intercepts turn observation into control: network.addIntercept registers an intercept for one or more phases (before the request is sent, when the response starts, when authentication is required) and optional URL patterns; from then on every matching request is paused (the event arrives with isBlocked true) until the test resolves it with one of:

Command Effect on the paused request

network.continueRequest

Let it go, optionally changing the URL, method, headers, cookies or body

network.continueResponse

Let the response through, optionally changing status, headers or cookies

network.provideResponse

Answer the request yourself with a status, headers and body (a mock); the server is not contacted

network.failRequest

Fail the request as a network error

network.continueWithAuth

Answer an authentication challenge with credentials, cancel it, or fall back to the browser’s default dialog

Other commands in the module: network.removeIntercept, network.setCacheBehavior (default or bypass the cache), network.setExtraHeaders, and data collectors (addDataCollector, getData, disownData) that keep request and response bodies so you can read them after the fact.

High-level handlers (Python)

Python 4.51 builds a friendly layer on top of intercepts. A handler receives a Request it may observe, change with set_url, set_method, set_headers, set_cookies and set_body, fail with fail(), or answer with provide_response(status, headers, body). After all matching handlers have run, Selenium reconciles the outcome (fail beats provide-response beats continue-with-changes beats plain continue) and continues the request itself, so a handler that merely observes never stalls the page. URL patterns are globs (*, ** and ?); the default matches everything. The add_response_handler and add_authentication_handler methods work the same way for responses (set_status, set_headers, set_body) and for authentication challenges (provide_credentials(user, password) or cancel()), and add_extra_header merges a header into every request.

def test_observe_requests(driver):
    driver.get(f"{BASE_URL}/books")
    seen = []
    handler_id = driver.network.add_request_handler(["**/api/books*"], lambda r: seen.append((r.method, r.url)))
    # The page-initiated fetch is paused at beforeRequestSent, handed to the handler, then continued.
    driver.execute_script("window.status_ = null; fetch('/api/books?q=dune').then(r => window.status_ = r.status)")
    assert wait_for(lambda: driver.execute_script("return window.status_")) == 200
    assert seen == [("GET", f"{BASE_URL}/api/books?q=dune")]
    driver.network.remove_request_handler(handler_id)


def test_mock_a_response(driver):
    context = driver.current_window_handle
    driver.get(f"{BASE_URL}/books")
    body = json.dumps({"items": [{"id": 1, "title": "Mocked Book"}], "total": 1, "page": 1, "pageSize": 6, "pages": 1})

    def mock(request):
        request.provide_response(status=200, headers={"content-type": "application/json"}, body=body)

    driver.network.add_request_handler(["**/api/books*"], mock)
    title = driver.script.execute("async () => (await (await fetch('/api/books')).json()).items[0].title", context_id=context)
    assert title == {"type": "string", "value": "Mocked Book"}
    driver.network.clear_request_handlers()
    real = driver.script.execute("async () => (await (await fetch('/api/books')).json()).total", context_id=context)
    assert real["value"] == 24


def test_block_a_request_and_modify_a_response(driver):
    context = driver.current_window_handle
    driver.network.add_request_handler(["**/covers/*"], lambda request: request.fail())
    # BiDi navigate (not driver.get) so the blocking classic command does not wait on a paused request.
    driver.browsing_context.navigate(context=context, url=f"{BASE_URL}/books/1", wait="complete")
    assert driver.execute_script("return document.querySelector('img').naturalWidth") == 0
    driver.network.clear_request_handlers()

    def add_header(response):
        response.set_headers({"x-intercepted": "yes"})

    driver.network.add_response_handler(["**/api/session"], add_header)
    header = driver.script.execute("async () => (await fetch('/api/session')).headers.get('x-intercepted')", context_id=context)
    assert header["value"] == "yes"

All three passed on Chrome 155 and Firefox 158. The first test relies on import json and the helpers from the fixture section. The official documentation lists the Python request handlers as available since 4.32 and the authentication handlers since 4.46; the response-handler and extra-header methods shown here are present in the 4.51.0 package but are not yet on the documentation page, so check the docstrings of your installed version.

Pitfall: do not intercept the request a blocking classic command is waiting for. The first attempt at these examples used driver.get(f"{BASE_URL}/books") with a request handler registered. On Chrome 155 with chromedriver the call did not return (it was stopped after 100 seconds): the Python handler pauses every request while it decides (the URL filter runs in the client), the page navigation itself was one of those paused requests, and the command that would continue it never got an answer while the classic get was still blocked. The same call returned in 0.1 s on Firefox 158, and the Java high-level driver.network().addAuthenticationHandler(...) followed by driver.get(...) hung the same way on Chrome. Two safe patterns follow: navigate with BiDi (driver.browsing_context.navigate(context=..., url=..., wait="complete") in Python, new BrowsingContext(driver, id).navigate(url, ReadinessState.COMPLETE) in Java), which is itself a BiDi command and does not block the channel that continues the request; or register the handler after the page has loaded and trigger the traffic from the page, as the first test does with a fetch that is not awaited, polling for the result. Likewise, in the mock test the page script runs through driver.script.execute (BiDi) rather than a classic synchronous execute_script.

Network module in Java

Java has the high-level driver.network() with addAuthenticationHandler and addRequestHandler (a predicate on the URI and a function that rewrites an HttpRequest), and the full Network module class for events and intercepts. Adding an intercept and answering the paused request yourself:

@Test
void observeRequestsWithTheNetworkModule() throws Exception {
  driver.get(BASE_URL + "/books");
  CompletableFuture<BeforeRequestSent> sent = new CompletableFuture<>();
  try (Network network = new Network(driver)) {
    network.onBeforeRequestSent(
        event -> {
          if (event.getRequest().getUrl().contains("/api/books")) {
            sent.complete(event);
          }
        });
    driver.executeScript("fetch('/api/books?q=dune')");
    BeforeRequestSent event = sent.get(5, TimeUnit.SECONDS);
    assertEquals("GET", event.getRequest().getMethod());
    assertFalse(event.isBlocked());
  }
}

@Test
void mockAResponse() throws Exception {
  driver.get(BASE_URL + "/books");
  try (Network network = new Network(driver)) {
    network.addIntercept(
        new AddInterceptParameters(InterceptPhase.BEFORE_REQUEST_SENT)
            .urlPattern(new UrlPattern().pathname("/api/books")));
    network.onBeforeRequestSent(
        event -> {
          if (event.isBlocked()) {
            network.provideResponse(
                new ProvideResponseParameters(event.getRequest().getRequestId())
                    .statusCode(200)
                    .headers(
                        List.of(
                            new Header(
                                "content-type",
                                new BytesValue(BytesValue.Type.STRING, "application/json"))))
                    .body(
                        new BytesValue(
                            BytesValue.Type.STRING, "{\"items\":[],\"total\":42}")));
          }
        });
    // Start the fetch without blocking the classic command channel, then poll for the result.
    driver.executeScript(
        "window.total = null; fetch('/api/books').then(r => r.json()).then(j => window.total = j.total)");
    Object total =
        new WebDriverWait(driver, TIMEOUT).until(d -> driver.executeScript("return window.total"));
    assertEquals(42L, total);
  }
}

Both passed on Chrome 155. TIMEOUT is a ten-second Duration from the base class. In the second test the intercept is limited to /api/books by a URL pattern, so other requests are not paused at all, which is the better design when the filter can run in the browser. The official Java example for the other phases uses network.addIntercept(new AddInterceptParameters(InterceptPhase.BEFORE_REQUEST_SENT)), network.onAuthRequired(...) with network.continueWithAuth(requestId, new UsernameAndPassword(...)), continueWithAuthNoCredentials and cancelAuth, and network.failRequest(requestId) for the same pattern.

Authentication

Browsers that show a native sign-in dialog for HTTP Basic or Digest authentication cannot be driven by Selenium’s alert commands, and putting credentials in the URL is blocked by browsers. BiDi answers the challenge instead. The Bookshelf application has no authenticated route, so the examples use a ten-line throwaway Node.js server on its own port that returns 401 with WWW-Authenticate: Basic realm="Restricted Area" for /secret unless the Authorization header matches the admin credentials from BOOKSHELF_ADMIN_PASSWORD:

def test_basic_auth(driver):
    context = driver.current_window_handle
    challenges = []

    def answer(challenge):
        challenges.append(challenge.url)
        challenge.provide_credentials("admin", ADMIN_PASSWORD)

    handler_id = driver.network.add_authentication_handler(answer)
    driver.browsing_context.navigate(context=context, url=f"{AUTH_URL}/secret", wait="complete")
    assert driver.find_element(By.TAG_NAME, "h1").text == "Secret shelf"
    assert challenges == [f"{AUTH_URL}/secret"]
    driver.network.remove_authentication_handler(handler_id)

Passed on Chrome 155 and Firefox 158. The official page asserts challenge.scheme == "Basic" and challenge.realm == "Restricted Area"; that held on Firefox 158, but Chrome 155 delivers the network.authRequired event without the authChallenges list, so scheme and realm were None and challenges was empty. Assert on the URL if the suite runs on both. The credentials-only shortcut driver.network.add_auth_handler("admin", password) exists as well, but must not be combined with add_authentication_handler: both would answer the same challenge and the second answer fails. In Java the high-level form is driver.network().addAuthenticationHandler(new UsernameAndPassword("admin", password)) (optionally with a Predicate<URI> to scope it), and it needs the BiDi navigation described in the pitfall above:

@Test
void basicAuthWithTheHighLevelNetworkApi() {
  driver.network().addAuthenticationHandler(new UsernameAndPassword("admin", ADMIN_PASSWORD));
  // BiDi navigation: the classic driver.get would block on the paused request in Chrome.
  new BrowsingContext(driver, driver.getWindowHandle())
      .navigate(AUTH_URL + "/secret", ReadinessState.COMPLETE);
  assertEquals("Secret shelf", driver.findElement(By.tagName("h1")).getText());
}

Passed on Chrome 155. The high-level network page of the official documentation (Authentication Handlers, Request Handlers, Response Handlers, Remove Handler, Clear Handlers) still has mostly empty example tabs, and says the implementation of these features is tracked in an upstream issue; the working examples are on the W3C network page and in the module classes. The Ruby example there registers driver.network.add_authentication_handler('test', 'test') and is skipped in the project’s own test run.

What to use instead of a proxy

For most suites BiDi interception replaces a proxy such as mitmproxy or BrowserMock for these jobs: stubbing an API response, blocking analytics or third-party scripts so they cannot slow or flake a test, adding a header to every request, and asserting that the page made (or did not make) a call. It does not replace a proxy for non-HTTP traffic, for throttling that must apply below the browser, or for recording and replaying entire sessions. Playwright and Cypress have equivalent features; compare with Network Mocking, Clock & API Testing and Network, Stubs, Spies & Clocks.

Script

The script module runs JavaScript in the browser outside the page’s normal call path. Where executeScript always runs in the current page’s main world, BiDi lets you pick the realm (a JavaScript global such as a window, an iframe or a worker), or a named sandbox realm that shares the page’s DOM but has its own globals, so a test’s helper variables cannot collide with the application’s. Commands and events:

API Purpose

script.evaluate

Evaluate an expression in a realm or browsing context (optionally in a sandbox) and optionally await a promise; the result is a typed RemoteValue (type and value)

script.callFunction

Call a function declaration with arguments (LocalValue objects) and a this; also in a realm or sandbox

script.getRealms

List realms, optionally for one context or one realm type (window, worker, …​)

script.disown

Release remote object handles that a call returned with resultOwnership: root

script.addPreloadScript and removePreloadScript

Run a function declaration in every new document of the selected contexts before any page script; optional sandbox and channel arguments

script.message

Event: the page called a channel argument ("page to test" messages)

script.realmCreated and realmDestroyed

Events when a realm appears or disappears (a new page, iframe or worker)

Besides these, the high-level API lists script pinning (pin a function once, run it many times with arguments; unpin), execute and DOM mutation handlers (addDomMutationHandler). The official high-level script page lists those headings but, like the high-level network page, has no examples there; the working pin and mutation examples are on the CDP script page, where the Java, C# and Ruby versions use DevTools.

def test_preload_script(driver):
    result = driver.script.add_preload_script("() => { window.bookshelfTest = 'preloaded'; }")
    script_id = result["script"]
    driver.get(f"{BASE_URL}/")
    assert driver.execute_script("return window.bookshelfTest") == "preloaded"
    driver.script.remove_preload_script(script_id)
    driver.get(f"{BASE_URL}/books")
    assert driver.execute_script("return window.bookshelfTest") is None


def test_evaluate_and_call_function(driver):
    context = driver.current_window_handle
    driver.get(f"{BASE_URL}/books")
    target = {"context": context}
    evaluated = driver.script.evaluate("document.title", target, True)
    assert evaluated["result"] == {"type": "string", "value": "Catalogue - Bookshelf"}

    called = driver.script.call_function(
        "(a, b) => a + b", True, target, arguments=[{"type": "number", "value": 20}, {"type": "number", "value": 22}]
    )
    assert called["result"]["value"] == 42

    realms = driver.script.get_realms(context=context)
    assert realms["realms"][0]["type"] == "window"

Both passed on Chrome 155 and Firefox 158. A preload script is the supported way to install test hooks that must exist before the application starts: stub Math.random or Date, set a feature flag in localStorage, wrap fetch to record calls, or expose a function the page can call. Java’s equivalent is the Script module class, which also covers the other official examples (call a function in a browsing context, a sandbox or a realm, evaluate in each, disown handles, list realms, channel messages and realm events):

@Test
void preloadScriptAndEvaluate() {
  try (Script script = new Script(driver)) {
    String id = script.addPreloadScript("() => { window.bookshelfTest = 'preloaded'; }");
    driver.get(BASE_URL + "/");
    assertEquals("preloaded", driver.executeScript("return window.bookshelfTest"));
    script.removePreloadScript(id);

    EvaluateResult result =
        script.evaluateFunctionInBrowsingContext(
            driver.getWindowHandle(), "1 + 2", true, Optional.empty());
    assertEquals(3L, ((EvaluateResultSuccess) result).getResult().getValue().get());
  }
}

@Test
void highLevelScriptExecute() {
  driver.get(BASE_URL + "/books");
  var title = driver.script().execute("() => document.title");
  assertEquals("Catalogue - Bookshelf", title.getValue().get());
}

Both passed on Chrome 155. In the second test driver.script().execute(...) takes a JavaScript function declaration and returns the typed RemoteValue. Typed values are the main difference from executeScript: BiDi does not convert to the language’s native types for you, so strings, numbers, arrays and maps come back as { type, value } trees (a RemoteValue object in Java; a dict in Python), and arguments you pass to callFunction are LocalValue objects. JavaScript’s ScriptManager exposes the same calls with ArgumentValue and LocalValue helpers, and addPreloadScript, onMessage and onRealmCreated require a manager created for the context.

Browsing contexts

A browsing context is BiDi’s name for a tab, a window or an iframe, identified by an id that is the same string as the classic window handle for top-level contexts. Every command takes a context id, so a test can screenshot, navigate or print a background tab without switching to it, and navigation commands can wait for a readiness state (none, interactive or complete) and report the navigation id and final URL.

Command or event Notes

browsingContext.create

New tab or window (with a reference context; the exact placement is operating-system specific); Selenium 4.8+

browsingContext.getTree

The context and its child contexts (iframes), optionally limited by depth; "top-level contexts" lists all tabs and windows

browsingContext.navigate and reload

Wait for none, interactive or complete; reload can ignore the cache

browsingContext.traverseHistory

Move back or forward by a delta; Selenium also has back() and forward() helpers

browsingContext.activate and close

Bring a context to the front or close it

browsingContext.handleUserPrompt

Accept or dismiss an alert, confirm or prompt, optionally with text

browsingContext.captureScreenshot

Viewport, document, a clipped box or one element; PNG or another image format, returned as base64

browsingContext.print

PDF of a context with orientation, scale, background, margins, page size and page ranges, returned as base64

browsingContext.setViewport

Viewport size and device pixel ratio

browsingContext.locateNodes

Find nodes by CSS, XPath, inner text or accessibility locator without a classic element reference

Events

contextCreated, contextDestroyed, navigationStarted, navigationCommitted, domContentLoaded, load, fragmentNavigated, historyUpdated, navigationAborted, navigationFailed, userPromptOpened, userPromptClosed, downloadWillBegin, downloadEnd

def test_navigation_events(driver):
    context = driver.current_window_handle
    events = []
    driver.browsing_context.add_event_handler("navigation_started", lambda e: events.append(("started", e.url)))
    driver.browsing_context.add_event_handler("dom_content_loaded", lambda e: events.append(("domContentLoaded", e.url)))
    driver.browsing_context.add_event_handler("load", lambda e: events.append(("load", e.url)))

    driver.browsing_context.navigate(context=context, url=f"{BASE_URL}/books?q=dune", wait="complete")

    wait_for(lambda: {name for name, _ in events} == {"started", "domContentLoaded", "load"})
    assert all(url == f"{BASE_URL}/books?q=dune" for _, url in events)


def test_context_created_and_destroyed(driver):
    created, destroyed = [], []
    driver.browsing_context.add_event_handler("context_created", created.append)
    driver.browsing_context.add_event_handler("context_destroyed", destroyed.append)

    tab = driver.browsing_context.create(type="tab")
    wait_for(lambda: any(info.context == tab for info in created))
    assert tab in driver.window_handles

    driver.browsing_context.close(tab)
    wait_for(lambda: any(info.context == tab for info in destroyed))


def test_context_tree_and_history(driver):
    context = driver.current_window_handle
    driver.browsing_context.navigate(context=context, url=f"{BASE_URL}/books", wait="complete")
    driver.browsing_context.navigate(context=context, url=f"{BASE_URL}/books/1", wait="complete")
    tree = driver.browsing_context.get_tree()
    assert tree[0].context == context
    assert tree[0].url == f"{BASE_URL}/books/1"
    driver.browsing_context.traverse_history(context=context, delta=-1)
    wait_for(lambda: driver.current_url == f"{BASE_URL}/books")

All three passed on Chrome 155 and Firefox 158. The Python event names are the snake-case form of the specification’s event names (navigation_started for browsingContext.navigationStarted, dom_content_loaded, load, context_created, context_destroyed, fragment_navigated, history_updated, user_prompt_opened, user_prompt_closed, and the others in the table). A context created by browsing_context.create is a real tab: it shows up in driver.window_handles, and you can driver.switch_to.window(tab) to it for classic commands. In Java, BrowsingContextInspector exposes the same events (onBrowsingContextCreated, onBrowsingContextDestroyed, onNavigationStarted, onDomContentLoaded, onBrowsingContextLoaded, onFragmentNavigated, onUserPromptOpened, onUserPromptClosed, onHistoryUpdated, …​):

@Test
void browsingContextEvents() throws Exception {
  try (BrowsingContextInspector inspector = new BrowsingContextInspector(driver)) {
    CompletableFuture<BrowsingContextInfo> created = new CompletableFuture<>();
    CompletableFuture<NavigationInfo> loaded = new CompletableFuture<>();
    inspector.onBrowsingContextCreated(created::complete);
    inspector.onBrowsingContextLoaded(loaded::complete);

    String tab = driver.switchTo().newWindow(WindowType.TAB).getWindowHandle();
    assertEquals(tab, created.get(5, TimeUnit.SECONDS).getId());

    BrowsingContext context = new BrowsingContext(driver, tab);
    context.navigate(BASE_URL + "/books", ReadinessState.COMPLETE);
    assertTrue(loaded.get(5, TimeUnit.SECONDS).getUrl().endsWith("/books"));
  }
}

@Test
void traverseHistory() {
  BrowsingContext context = new BrowsingContext(driver, driver.getWindowHandle());
  context.navigate(BASE_URL + "/books", ReadinessState.COMPLETE);
  context.navigate(BASE_URL + "/books/1", ReadinessState.COMPLETE);
  context.back();
  assertEquals(BASE_URL + "/books", driver.getCurrentUrl());
}

Both passed on Chrome 155. In Java the BrowsingContext constructor either wraps an existing handle (new BrowsingContext(driver, handle)), creates a WindowType.TAB or WindowType.WINDOW, or takes CreateContextParameters. The official page shows "since" versions for each call: creating, navigating, tree, close and the events from 4.8-4.10, screenshots, viewport, activate and reload from 4.13-4.15, history traversal, back and forward from 4.16-4.17, and the destroyed event from 4.18. JavaScript has the same operations through its BrowsingContext and BrowsingContextInspector factories. The .NET page (added in 2026) uses driver.AsBiDiAsync() and bidi.BrowsingContext.

Screenshots and printing

Classic WebDriver already has TakesScreenshot (viewport, element) and PrintsPage, and Selenium keeps those. The BiDi commands add control: any context (including a background tab), a clip rectangle in page coordinates, an element clip, a chosen image format, and printing with the full set of print options. Both return base64 data:

def test_screenshot_and_print(driver):
    context = driver.current_window_handle
    driver.get(f"{BASE_URL}/books")
    png = base64.b64decode(driver.browsing_context.capture_screenshot(context=context))
    assert png.startswith(b"\x89PNG")
    pdf = base64.b64decode(driver.browsing_context.print(context=context, background=True))
    assert pdf.startswith(b"%PDF")

Passed on Chrome 155 and Firefox 158. The same in Java, plus an element screenshot (the element is addressed by its shared-id, which RemoteWebElement.getId() returns):

@Test
void screenshotsAndPrint() {
  BrowsingContext context = new BrowsingContext(driver, driver.getWindowHandle());
  context.navigate(BASE_URL + "/books/1", ReadinessState.COMPLETE);
  String png = context.captureScreenshot();
  assertTrue(java.util.Base64.getDecoder().decode(png).length > 1000);
  String element =
      context.captureElementScreenshot(
          ((org.openqa.selenium.remote.RemoteWebElement)
                  driver.findElement(By.cssSelector("[data-testid=book-cover]")))
              .getId());
  assertNotNull(element);
  String pdf = context.print(new PrintOptions());
  assertTrue(new String(java.util.Base64.getDecoder().decode(pdf), 0, 4).equals("%PDF"));
}

Passed on Chrome 155. For screenshot strategies in a suite (full page, element, failure artefacts) see Screenshots, Videos & Traces and, for comparing images, Visual Regression Testing. Printing to PDF is not limited to headless Chrome when you use the BiDi command, but a browser that does not implement browsingContext.print returns an "unknown command" error.

Input

The input module takes the same action sequences as the classic Actions API but addresses them to a context, and adds input.releaseActions (release held keys and buttons) and input.setFiles (set the files of an <input type=file>). In the official examples the sequences are built with the familiar Actions builder and then handed to the module:

def test_input_perform_actions(driver):
    from selenium.webdriver.common.bidi.input import KeyDownAction, KeySourceActions, KeyUpAction

    context = driver.current_window_handle
    driver.get(f"{BASE_URL}/books")
    search = driver.find_element(By.CSS_SELECTOR, "[data-testid=search-input]")
    search.click()
    keys = KeySourceActions(id="keyboard", actions=[KeyDownAction(value="d"), KeyUpAction(value="d"),
                                                      KeyDownAction(value="u"), KeyUpAction(value="u")])
    driver.input.perform_actions(context, [keys])
    driver.input.release_actions(context)
    assert search.get_attribute("value") == "du"
@Test
void inputPerformActions() {
  driver.get(BASE_URL + "/books");
  WebElement search = driver.findElement(By.cssSelector("[data-testid=search-input]"));
  search.click();
  Input input = new Input(driver);
  Actions typing = new Actions(driver).sendKeys("du");
  input.perform(driver.getWindowHandle(), typing.getSequences());
  assertEquals("du", search.getAttribute("value"));
  input.release(driver.getWindowHandle());
}

Both passed on Chrome 155 (the Python input test also on Firefox 158). Use BiDi input when you need to drive a context other than the one classic commands are focused on, or when you want one transport for everything; for ordinary tests the classic Actions API described on Locating & Interacting is simpler and equally reliable. The official JavaScript example builds the sequences with driver.actions(), calls input.perform(browsingContextId, sequences) and then input.release(browsingContextId); it was added in 4.17.

More modules in the Python bindings

Selenium 4.51’s Python package exposes more of the specification than the official pages document: driver.storage (cookies by partition), driver.emulation (geolocation, time zone, locale, user agent, network conditions, screen orientation and settings, scripting on or off, media features, forced colours, touch), driver.permissions, driver.webextension (install and uninstall extensions), and driver.browser (user contexts, client windows). The changelogs show the same breadth arriving in .NET (Emulation.SetMediaFeaturesOverride, screencast, SetTextLayoutModeOverride in 4.48-4.50), Ruby (web extension install and uninstall in 4.50) and JavaScript (web extension methods in 4.51). Emulation is the most useful in tests, because it replaces CDP-only tricks with a cross-browser command:

def test_emulation_module(driver):
    context = driver.current_window_handle
    driver.get(f"{BASE_URL}/")
    driver.emulation.set_timezone_override("Pacific/Auckland", contexts=[context])
    assert driver.execute_script("return Intl.DateTimeFormat().resolvedOptions().timeZone") == "Pacific/Auckland"
    driver.emulation.set_timezone_override(None, contexts=[context])  # clear the override
    assert driver.execute_script("return Intl.DateTimeFormat().resolvedOptions().timeZone") != "Pacific/Auckland"

Passed on Chrome 155 and Firefox 158. Because these modules are not yet on the official documentation pages, treat their signatures as subject to change between minor releases, as explained in the warning section above.

Per-binding status

The status below comes from the official documentation pages (which mark gaps with "Implementation Missing" or "Add Example"), the 4.51 changelogs and the runs described on this page. "Run" means the examples on this page were run; "documented" means only the official pages were read.

Binding BiDi support in 4.51 Status here

Java

High-level script() and network() on RemoteWebDriver; module classes LogInspector, BrowsingContextInspector, Network, Script, Input, Storage, Browser, Permission, BrowsingContext; generated protocol layer under bidi.protocol. Classes were marked beta in 4.46.

Run (Chrome 155, JUnit 5, 12 tests)

Python

High-level script, network (request, response and authentication handlers), plus browsing_context, input, storage, emulation, permissions, webextension, browser; BiDi layer generated since 4.44-4.48. The documentation’s Python tabs are marked with the bidi driver type.

Run (Chrome 155: 16 of 16 passed; Firefox 158: 14 of 16, see below)

C#

UseWebSocketUrl, driver.AsBiDiAsync() with typed modules and events; DevTools-based IJavaScriptEngine for console, exceptions and DOM mutation. The high-level logging tabs say "Implementation Missing".

Documented, not run

Ruby

options.web_socket_url, driver.script, driver.network, driver.on_log_event, web extension install; BiDi on Safari Technology Preview from 4.47.

Documented, not run

JavaScript

Options().enableBidi(), LogInspector, Network, ScriptManager, BrowsingContext, BrowsingContextInspector, Input; web extension methods in 4.51. The high-level logging tabs say "Implementation Missing".

Documented, not run

Kotlin

Uses the Java classes

Documented, not run

Source: WebDriver BiDi

The C#, Ruby and JavaScript examples were not run because those toolchains were not set up for this page, not because they failed. Browsers: Chrome 155 ran every example. Firefox 158 ran the Python suite in Docker (the Firefox on the macOS host could not be launched, see the Selenium getting-started page) and passed 14 of 16 tests. The two that failed are expected: the CDP test, which cannot pass on Firefox by design (the binding raises the error quoted in the CDP section), and the console and error test, whose single failing assertion is the Uncaught Error: boom text that Firefox reports as Error: boom. Safari needs Allow remote automation enabled in its Developer settings, a system setting that was not changed, so no Safari BiDi example was run.

Chrome DevTools Protocol: temporary

Before BiDi, the only way to listen to network or log events was the Chrome DevTools Protocol (CDP), the protocol Chrome’s own developer tools use. The official CDP pages open with the statement that "CDP support is temporary until WebDriver BiDi has been implemented". CDP is not designed for testing and has no stable API, so functionality depends heavily on the browser version; Selenium plans to provide access to CDP features where applicable until BiDi covers them. Consequences:

  • Chrome and Edge only. CDP is a Chromium protocol. Firefox removed CDP support, and from Selenium 4.47 the Python binding refuses CDP calls on Firefox (RuntimeError: CDP support for Firefox has been removed. Please switch to WebDriver BiDi).

  • Versioned. Selenium supports the three most recent Chrome versions at a time and generates a class set per version: 4.51 ships CDP v153, v154 and v155 (4.50: v152-v154). Keep Chrome, the driver and Selenium aligned, or the generated classes (Java DevTools, Python bidi_connection, .NET IDevTools) may not match the browser. Some bindings need a code change when the version moves; a few have an "idealised" implementation that works with any version.

  • Two flavours. A one-shot command (executeCdpCommand in Java, execute_cdp_cmd in Python, ExecuteCdpCommand in C#, execute_cdp in Ruby) works on Chrome and Edge but cannot receive events and requires you to know the exact domain, method and parameter names. The generated DevTools classes give typed access to domains and events (Network, Performance, Browser.setDownloadBehavior, Log, Runtime), but they are what BiDi is replacing.

  • Some CDP examples are of little practical value. The official page calls out emulated geolocation (most sites use the IP address to locate the user) and overriding device metrics (Chrome’s mobile emulation in the Options classes, or the BiDi emulation module, is the better route).

def test_cdp_command_is_chromium_only(driver):
    # CDP: a one-shot command, no events. Chrome and Edge only; the CDP version tracks the browser.
    driver.get(f"{BASE_URL}/")
    driver.execute_cdp_cmd("Network.setCookie", {"name": "cheese", "value": "gouda", "url": BASE_URL})
    assert driver.get_cookie("cheese")["value"] == "gouda"
    driver.execute_cdp_cmd("Emulation.setTimezoneOverride", {"timezoneId": "Pacific/Auckland"})
    assert driver.execute_script("return Intl.DateTimeFormat().resolvedOptions().timeZone") == "Pacific/Auckland"
@Test
void cdpCommandIsChromiumOnly() {
  driver.get(BASE_URL + "/");
  ((HasCdp) driver)
      .executeCdpCommand(
          "Network.setCookie", Map.of("name", "cheese", "value", "gouda", "url", BASE_URL));
  assertEquals("gouda", driver.manage().getCookieNamed("cheese").getValue());
}

Both passed on Chrome 155; the Python one fails on Firefox with the error quoted above (the official page’s version of the cookie example uses domain instead of url). The three CDP sub-pages cover, per binding, the use cases below; none of them was run for this page, and the "BiDi instead" column shows the replacement to prefer:

CDP feature (official page) Bindings shown BiDi instead

Console logs and JavaScript exceptions (Log domain; Java onLogEvent, Python Log(...).add_listener, C# JavaScriptConsoleApiCalled, Ruby on_log_event)

Java, Python, C#, Ruby

script.add_console_message_handler, add_javascript_error_handler, LogInspector

Basic authentication (Java HasAuthentication.register, Python extra HTTP header, C# NetworkAuthenticationHandler, Ruby driver.register)

Java, Python, C#, Ruby

network.add_authentication_handler, driver.network().addAuthenticationHandler

Network interception: response information, response transformation, request interception (Java NetworkInterceptor, C# INetwork handlers, Ruby driver.intercept)

Java, C#, Ruby

Intercepts, provideResponse, continueRequest, Python request and response handlers

Performance metrics, setting cookies, waiting for downloads (Performance.getMetrics, Network.setCookie, Browser.setDownloadBehavior)

Java, Python, C#, Ruby

storage module for cookies, browser.setDownloadBehavior, browsingContext.downloadEnd; no BiDi counterpart for performance metrics

Script pinning and DOM mutation handlers

Java, C#, Python (pin_script), Ruby

The high-level driver.script equivalents (pin, execute, addDomMutationHandler) as they land, and script.addPreloadScript for code that must run first

The practical advice is: use BiDi first, use CDP only for a Chromium-only job that BiDi cannot do yet (performance metrics, tracing, a few device switches), isolate those calls in one helper so they are easy to remove, and expect them to break when Chrome moves on.

The W3C specification

BiDi is a W3C specification of the Browser Testing and Tools Working Group, a Working Draft published on 8 October 2026 at https://www.w3.org/TR/webdriver-bidi/. It defines the transport (a WebSocket established when a new session requests webSocketUrl: true), commands and events as JSON messages, a set of modules, and the rules for sandboxed script execution and user contexts. Because it is a draft, modules and commands continue to grow, and a browser may implement only part of it. The modules in the current draft and what Selenium exposes:

Module Contents In Selenium

session

new, status, end, subscribe, unsubscribe, proxy and user-prompt configuration

Used internally by every handler; Java BiDi and HasBiDi

browser

close, user contexts (isolated cookie jars), client windows, download behaviour

Java Browser module, Python driver.browser

browsingContext

Commands and events listed in the browsing-context section, plus screencast and CSP bypass

BrowsingContext, BrowsingContextInspector, Python driver.browsing_context

emulation

Geolocation, locale, time zone, user agent, screen, media features, touch, scripting, network conditions, and more

Python driver.emulation, Java Emulation, .NET

network

Intercepts, data collectors, cache behaviour, extra headers, request, response and authentication events

Network, driver.network

script

evaluate, callFunction, realms, preload scripts, channels

Script, driver.script

storage

Cookies with partition keys

Java Storage, Python driver.storage

log

entryAdded

LogInspector, console and error handlers

input

performActions, releaseActions, setFiles, fileDialogOpened

Input, Python driver.input

webExtension

Install and uninstall extensions

Python driver.webextension, Ruby, JavaScript, Java WebExtension

Source: WebDriver BiDi

The spec also defines user contexts (separate browser profiles inside one browser, which makes isolated sessions cheap) and sandbox realms (script realms that see the DOM but not the page’s globals), the two ideas behind the userContext parameters and the sandbox arguments in the APIs above. Puppeteer uses the same protocol for Firefox, which is one reason BiDi is expected to become a common denominator of browser automation; see How Browser Automation Works and Choosing a Tool.

Pitfalls and good practice

  • Handlers run on another thread. Do not assert inside a handler; collect into a thread-safe list or a future and assert on the test thread after waiting (wait_for, CompletableFuture.get(timeout)). Use CopyOnWriteArrayList in Java.

  • Wait for events, never sleep. An event may arrive after the classic command that caused it has returned. Poll for the event, as in the examples, or wait for an application signal.

  • Subscribe before the action. An event that happened before session.subscribe is lost; register the handler first, then navigate or click. A preload script is the tool for code that must run before the page’s own scripts.

  • Always resolve a paused request. With a raw intercept, every matching request stays blocked until you continue, fail or answer it. Scope intercepts with URL patterns, remove them when done, and prefer the Python handlers that auto-continue.

  • Keep classic commands off the critical path of an intercepted request. See the network pitfall; navigate with BiDi or trigger traffic from the page.

  • Remove what you add. Clear handlers, intercepts and preload scripts in teardown (clear_request_handlers, remove_preload_script) or use one fresh driver per test; quit() closes the WebSocket.

  • Compare browsers. Event text, authentication challenge details and unsupported commands differ between Chrome and Firefox, as seen above. Assert on what is portable.

  • In CI, BiDi needs no extra ports on a local driver (the WebSocket is on the driver’s port); through a Grid the Grid port must be reachable from the test and the nodes need no inbound port. Event handlers add no cost when nothing subscribes, but intercepts add a round trip per request, so enable them only in the tests that need them.

What changed recently

  • Selenium 4.51.0 (9 October 2026): supports CDP v153 to v155 in every binding; Java gains a BiDi code generator and removes Guava from the client bindings; JavaScript adds web extension methods and vendor-specific class generation; BiDi response parsing is more efficient (Java decodes nested remote values once).

  • 4.50.0 (30 September 2026): CDP v152 to v154; Ruby adds web extension install and uninstall through BiDi; .NET adds image size on screenshots, text-layout emulation, activity-source monitoring and fields on context events; Java fixes listener clean-up in Network.close() and Script.close().

  • 4.49.0 (September 2026): Python raises typed WebDriver errors for BiDi wire error codes; .NET adds browsing-context screencast; schema-driven field names and enum types across bindings.

  • 4.48.0: Python generates its internal BiDi protocol layer from a shared, binding-neutral schema; .NET adds SetMediaFeaturesOverride.

  • 4.47.0: Python refuses CDP on Firefox; Java adds a transport layer for generated modules and subscription parameters; Ruby supports BiDi on Safari Technology Preview and Grid honours a client-advertised se:remoteUrl for BiDi, CDP and VNC URLs.

  • 4.46.0: Java marks all existing BiDi classes beta; the documentation lists the Python authentication handlers as available from this version.

  • June 2026: the documentation gained the "BiDi implementation API is internal" warning page.

  • Documentation: the W3C pages now include .NET browsing-context examples (July 2026) and Python network, authentication and request-failure examples (July 2026); the high-level network and script pages remain largely placeholders.

  • W3C: the specification is a Working Draft dated 8 October 2026 (previous version 30 September 2026); the Selenium features marked in the documentation as "since 4.8" onwards (browsing context, log, script, network, input) are the ones the draft has carried the longest.