WebDriver BiDi
Enable the bidirectional protocol and use it for console log capture, JavaScript error detection, basic authentication, DOM mutation events, preload scripts and emulation, with browser support and migration guidance as of Selenium 4.48.
Classic WebDriver has a blind spot: the browser can never talk first. You cannot be told that a JavaScript error happened, that a request failed, or that the DOM changed. WebDriver BiDi (“bidirectional”) is the W3C specification that fixes this by opening a WebSocket next to the classic session, over which the browser streams events and accepts commands that HTTP WebDriver never had. It is the most important thing happening in Selenium in 2026 and the direction every release note points at.
Status as of Selenium 4.48
| Browser | BiDi support |
|---|---|
| Chrome, Edge (Chromium) | Yes, stable; BiDi is implemented on top of CDP inside the browser |
| Firefox | Yes, stable; Firefox’s only bidirectional protocol since CDP was removed in Selenium 4.29 |
| Safari | Technology Preview only (added in Selenium 4.47) |
The Selenium bindings expose BiDi at two levels:
- High-level APIs on the driver (
driver.script(),driver.network()and their equivalents) that cover the common cases with one call. Use these. - Low-level modules that mirror the specification command-for-command (
browsingContext,network,script,log,input,storage,emulation,webExtension,speculation). Use these when the high-level API lacks a feature.
Since 4.46 the low-level layer is generated from one shared schema, so the bindings gain features together. Some high-level names still differ per language; this lesson shows the current ones.
Enabling BiDi
One capability. Without it the WebSocket is never opened and every BiDi call fails.
ChromeOptions options = new ChromeOptions();options.setCapability("webSocketUrl", true);RemoteWebDriver driver = new ChromeDriver(options); // RemoteWebDriver type gives script()/network()
// Works the same for FirefoxOptions and EdgeOptionsfrom selenium import webdriver
options = webdriver.ChromeOptions()options.enable_bidi = Truedriver = webdriver.Chrome(options=options)
# Same for FirefoxOptions and EdgeOptionsconst { Builder } = require('selenium-webdriver');const chrome = require('selenium-webdriver/chrome');
const options = new chrome.Options().enableBidi();const driver = await new Builder().forBrowser('chrome').setChromeOptions(options).build();var options = new ChromeOptions { UseWebSocketUrl = true };var driver = new ChromeDriver(options);
// .NET exposes BiDi through an async session objectvar bidi = await driver.AsBiDiAsync();Check driver.getCapabilities() for a webSocketUrl value if you are unsure it took effect. On a Grid, the Node proxies the WebSocket for you; nothing else changes.
Console Messages and JavaScript Errors
The most valuable five lines you can add to any suite. Every console.error and every uncaught exception in the page becomes visible to the test.
List<String> jsErrors = new CopyOnWriteArrayList<>();
driver.script().addConsoleMessageHandler(entry -> { System.out.printf("[console.%s] %s%n", entry.getLevel(), entry.getText());});
driver.script().addJavaScriptErrorHandler(error -> { jsErrors.add(error.getText());});
driver.get("https://app.example.com/checkout");driver.findElement(By.id("place-order")).click();
// After the scenario, assert the page stayed healthyassertTrue("Uncaught JS errors: " + jsErrors, jsErrors.isEmpty());js_errors = []
driver.script.add_console_message_handler( lambda entry: print(f"[console.{entry.level}] {entry.text}"))
driver.script.add_javascript_error_handler( lambda error: js_errors.append(error.text))
driver.get("https://app.example.com/checkout")driver.find_element(By.ID, "place-order").click()
assert not js_errors, f"Uncaught JS errors: {js_errors}"const jsErrors = [];
await driver.script().addConsoleMessageHandler((entry) => {console.log(`[console.${entry.level}] ${entry.text}`);});
await driver.script().addJavaScriptErrorHandler((error) => {jsErrors.push(error.text);});
await driver.get('https://app.example.com/checkout');await driver.findElement(By.id('place-order')).click();
assert.deepStrictEqual(jsErrors, [], `Uncaught JS errors: ${jsErrors}`);var jsErrors = new ConcurrentBag<string>();var bidi = await driver.AsBiDiAsync();
await bidi.Log.OnEntryAddedAsync(entry =>{ Console.WriteLine($"[console.{entry.Level}] {entry.Text}"); if (entry.Level == "error") jsErrors.Add(entry.Text);});
driver.Navigate().GoToUrl("https://app.example.com/checkout");driver.FindElement(By.Id("place-order")).Click();
Assert.That(jsErrors, Is.Empty, $"Uncaught JS errors: {string.Join("; ", jsErrors)}");Handlers return an id; remove them with the matching remove...Handler call when a test finishes if the driver is reused. Entries carry level, text, timestamp, source and (for errors) a stack trace.
Basic Authentication
Pages behind HTTP basic auth used to require embedding credentials in the URL, which modern browsers block. BiDi’s network module answers the challenge.
import org.openqa.selenium.UsernameAndPassword;
driver.network().addAuthenticationHandler(new UsernameAndPassword("admin", "s3cret"));
// Or scope it to a host so unrelated challenges are left alonedriver.network().addAuthenticationHandler( uri -> uri.getHost().equals("intranet.example.com"), new UsernameAndPassword("admin", "s3cret"));
driver.get("https://intranet.example.com/reports");assertEquals("Reports", driver.getTitle());
driver.network().clearAuthenticationHandlers();handler_id = driver.network.add_auth_handler("admin", "s3cret")
driver.get("https://intranet.example.com/reports")assert driver.title == "Reports"
driver.network.remove_auth_handler(handler_id)
# Finer control: inspect the challenge, then provide or canceldef on_challenge(request): if request.realm == "Restricted Area": request.provide_credentials("admin", "s3cret") else: request.cancel()
driver.network.add_authentication_handler(on_challenge)await driver.network().addAuthenticationHandler('admin', 's3cret');
await driver.get('https://intranet.example.com/reports');assert.strictEqual(await driver.getTitle(), 'Reports');
await driver.network().clearAuthenticationHandlers();var bidi = await driver.AsBiDiAsync();
await bidi.Network.OnAuthRequiredAsync(async e =>{ await e.Request.ContinueWithAuthAsync(new AuthCredentials("admin", "s3cret"));});
driver.Navigate().GoToUrl("https://intranet.example.com/reports");Assert.That(driver.Title, Is.EqualTo("Reports"));DOM Mutation Events
Waiting for “the price changed” by polling is fine; being told the moment it changes is better, and it works even when the mutation is too fast to observe with polling.
CompletableFuture<String> priceChanged = new CompletableFuture<>();
driver.script().addDomMutationHandler(mutation -> { if ("data-price".equals(mutation.getAttributeName())) { priceChanged.complete(mutation.getCurrentValue()); }});
driver.findElement(By.id("apply-coupon")).click();String newPrice = priceChanged.get(10, TimeUnit.SECONDS);assertEquals("89.00", newPrice);# Python's high-level DOM mutation helper is exposed through the script module# (introduced with the cross-binding script API in 4.45)changes = []
driver.script.add_dom_mutation_handler( lambda m: changes.append(m) if m.attribute_name == "data-price" else None)
driver.find_element(By.ID, "apply-coupon").click()WebDriverWait(driver, 10).until(lambda d: changes)assert changes[-1].current_value == "89.00"let newPrice;await driver.script().addDomMutationHandler((m) => {if (m.attribute_name === 'data-price') newPrice = m.current_value;});
await driver.findElement(By.id('apply-coupon')).click();await driver.wait(() => newPrice !== undefined, 10000);assert.strictEqual(newPrice, '89.00');// .NET: subscribe to script messages from a preload script that observes mutations// (a high-level mutation helper is not yet exposed; see the preload script section)Under the hood the binding installs a MutationObserver via a preload script and forwards its events through a BiDi channel, which is exactly what you would do by hand with the low-level API.
Preload Scripts
Run JavaScript before any page script executes, on every navigation. Use it to stub Date, freeze Math.random, disable animations, or install observers.
import org.openqa.selenium.bidi.module.Script;
Script script = new Script(driver);String preloadId = script.addPreloadScript(""" () => { const fixed = new Date('2026-09-09T10:00:00Z').getTime(); Date.now = () => fixed; const style = document.createElement('style'); style.textContent = '*, *::before, *::after { transition: none !important; animation: none !important; }'; document.addEventListener('DOMContentLoaded', () => document.head.appendChild(style)); }""");
driver.get("https://app.example.com/dashboard");// ... later:script.removePreloadScript(preloadId);preload_id = driver.script.add_preload_script(""" () => { const fixed = new Date('2026-09-09T10:00:00Z').getTime(); Date.now = () => fixed; const style = document.createElement('style'); style.textContent = '*, *::before, *::after { transition: none !important; animation: none !important; }'; document.addEventListener('DOMContentLoaded', () => document.head.appendChild(style)); }""")
driver.get("https://app.example.com/dashboard")driver.script.remove_preload_script(preload_id)const { Script } = require('selenium-webdriver/bidi/script');
const script = await new Script(driver).init();const preloadId = await script.addPreloadScript(`() => { const fixed = new Date('2026-09-09T10:00:00Z').getTime(); Date.now = () => fixed; const style = document.createElement('style'); style.textContent = '*, *::before, *::after { transition: none !important; animation: none !important; }'; document.addEventListener('DOMContentLoaded', () => document.head.appendChild(style));}`);
await driver.get('https://app.example.com/dashboard');await script.removePreloadScript(preloadId);var bidi = await driver.AsBiDiAsync();
var preload = await bidi.Script.AddPreloadScriptAsync(@"() => { const fixed = new Date('2026-09-09T10:00:00Z').getTime(); Date.now = () => fixed; const style = document.createElement('style'); style.textContent = '*, *::before, *::after { transition: none !important; animation: none !important; }'; document.addEventListener('DOMContentLoaded', () => document.head.appendChild(style));}");
driver.Navigate().GoToUrl("https://app.example.com/dashboard");await preload.RemoveAsync();Emulation: Viewport, Geolocation, Network Conditions
The emulation module grew rapidly through 4.40 to 4.48: viewport, device pixel ratio, geolocation, screen orientation, touch, network conditions, media features and screen settings. Chrome and Edge implement most of these; Firefox is catching up. The low-level API is the stable way to reach them today.
import org.openqa.selenium.bidi.module.Emulation;import org.openqa.selenium.bidi.emulation.GeolocationCoordinates;import org.openqa.selenium.bidi.emulation.SetGeolocationOverrideParameters;
Emulation emulation = new Emulation(driver);String context = driver.getWindowHandle();
emulation.setGeolocationOverride(new SetGeolocationOverrideParameters( new GeolocationCoordinates(51.5074, -0.1278)).contexts(List.of(context)));
driver.get("https://app.example.com/stores/nearby");// The app now sees London coordinatescontext = driver.current_window_handle
driver.emulation.set_geolocation_override( coordinates={"latitude": 51.5074, "longitude": -0.1278}, contexts=[context],)
driver.get("https://app.example.com/stores/nearby")// Low-level: send the spec command directly through the BiDi connectionconst bidi = await driver.getBidi();const context = await driver.getWindowHandle();
await bidi.send({method: 'emulation.setGeolocationOverride',params: { coordinates: { latitude: 51.5074, longitude: -0.1278 }, contexts: [context] },});
await driver.get('https://app.example.com/stores/nearby');var bidi = await driver.AsBiDiAsync();var context = await driver.AsBiDiContextAsync();
await bidi.Emulation.SetGeolocationOverrideAsync( new GeolocationCoordinates(51.5074, -0.1278), new() { Contexts = [context] });
await context.NavigateAsync("https://app.example.com/stores/nearby");Emulation APIs are the least settled part of BiDi; names have changed between minor versions. Pin your Selenium version when you depend on them and check the Releases page before upgrading.
Where BiDi Beats CDP
- Cross-browser: one code path for Chrome, Edge, Firefox (and soon Safari).
- Version-independent: no
selenium-devtools-v1xxartifacts to keep in sync with Chrome’s two-week releases. - Works through Grid without special configuration.
- Standardised event model with subscriptions per browsing context.
Where CDP still wins today: a handful of Chromium-only features (performance metrics, coverage, some emulation), and .NET or JavaScript teams whose high-level BiDi APIs are still filling out. See Chrome DevTools Protocol for the decision table.
Practical Guidance
- Enable BiDi in a base test class and register a JavaScript error handler for every test. Product bugs surface for free.
- Remove handlers or quit the driver between tests; handlers persist for the session.
- On a Grid, the WebSocket travels through the Router; if you see
webSocketUrlin capabilities but no events, check that the Grid version is 4.20 or newer and that any proxy in front of it passes WebSocket upgrades. - Expect naming differences per language until the generated high-level layer converges. The Python and Java high-level APIs are the most complete in 4.48.
Summary
- BiDi adds a WebSocket for events and commands classic WebDriver cannot express, standardised by the W3C.
- Enable it with one capability; use
script()for logs, errors, DOM mutations and preload scripts,network()for auth and interception. - It is cross-browser and version-independent, unlike CDP, which Selenium 5 will drop.
- Emulation is powerful but still moving; pin versions when you rely on it.
Copy-paste recipes for this topic
- Pass HTTP Basic Authentication
Get through a browser's basic auth prompt with BiDi's authentication handler, and the URL-embedded fallback for older setups.