PRACTICAL GUIDE / Selenium JavaScript WebDriver BiDi events

Stop losing WebDriver BiDi events in JavaScript tests

Learn to subscribe before the trigger, match the right network event, capture useful evidence, and clean up Selenium BiDi listeners safely in CI.

By The Testing AcademyUpdated August 4, 202624 min read
All field guides
In this guide6 sections
  1. Why the event disappears even though the request happened
  2. Build a wait that owns its subscription
  3. Read the evidence before changing the timeout
  4. Separate a missed event from the failures that look like it
  5. Roll the pattern through an existing suite
  6. Know what the safer pattern costs

What you will learn

  • Why the event disappears even though the request happened
  • Build a wait that owns its subscription
  • Read the evidence before changing the timeout
  • Separate a missed event from the failures that look like it

The checkout test clicks Pay, the browser sends the request, and your event promise still times out. On another run, the callback catches a favicon or analytics response and asserts against the wrong status. Extending the timeout makes both failures slower without making either diagnosis better.

Why the event disappears even though the request happened

WebDriver BiDi changes the shape of browser automation. Classic WebDriver sends a command and waits for its response. BiDi also lets the remote browser push events to the client over a WebSocket connection. That second direction is what makes network observation possible, but it does not turn browser traffic into a permanent queue that a late test can replay.

A subscription therefore has to exist before the action of interest. In Selenium's JavaScript binding, enableBidi() is set on the browser options before the session is built. The exported Network function is asynchronous because it obtains the driver's BiDi connection. Calling responseCompleted(callback) then subscribes to the protocol event and installs the callback. The returned promise represents that setup work, so it must be awaited before a click, navigation, script execution, or form submission can trigger the request.

This ordering is the first boundary to inspect. Code that starts the browser action and only then calls responseCompleted has a real race. A fast local service may finish before the callback exists. A slower CI service may accidentally hide the bug because registration wins there. The reverse can also happen when local browser startup is slower than the application. Retry behavior says nothing useful about which side won.

The Selenium network documentation shows the same order in its JavaScript examples: create the Network helper, await the event method, and navigate afterward. The JavaScript Network API documents beforeRequestSent, responseStarted, and responseCompleted as separate subscriptions. Those names describe different points in one request lifecycle, not three interchangeable ways to wait.

According to the WebDriver BiDi specification, responseCompleted is emitted after the full response body is received. A server can accept a request, send headers, and keep a body open. In that case responseStarted may be observable while responseCompleted remains pending. Increasing a responseCompleted timeout changes how long the test waits, but it does not change the event's meaning.

Navigation creates another misleading boundary. driver.get waits according to WebDriver's navigation rules. It does not promise that every fetch started later by the page's JavaScript has completed. A dashboard can reach its document-ready state, render a shell, and then request account data. If the assertion cares about that account request, the test must observe that request rather than treating completion of driver.get as a proxy.

Event volume matters as much as event timing. One click can cause the business request, telemetry, an image refresh, and a token renewal. A predicate such as url.includes("api") is an invitation to catch the wrong one. Match the origin, pathname, and method at minimum. Add a business identifier when the application contract provides one. Keep query matching deliberate because query order, cache-busting values, and URL encoding can differ while the endpoint remains the same.

Redirects can make a broad predicate look correct while selecting the wrong hop. WebDriver BiDi identifies a network request and also reports redirectCount. The specification keeps the request identifier across redirects, while the count distinguishes each redirect. When a login endpoint returns a redirect before the final page response, status, URL, and redirect count belong together. Logging only the URL discards the evidence needed to explain a 302 followed by a 200.

One common explanation for a hanging Node process is also wrong. An unresolved Promise by itself does not keep the Node event loop alive. An open WebDriver session, browser process, socket, timer, or test runner handle can. A bounded wait is still necessary because it turns missing evidence into a useful failure, but teardown must quit the driver. Selenium's current WebDriver.quit() implementation closes its BiDi WebSocket after ending the session. Resolving an arbitrary promise is not a substitute for releasing that resource.

The Network helper exposes close(), but it is documented as an event unsubscribe operation, not as removal of one responseCompleted callback. responseCompleted() itself resolves to void, so it gives the caller no documented callback handle to remove later. That difference makes shared-session listener ownership unsafe. The examples here use one driver session per test and make driver.quit() the terminal cleanup boundary.

Build a wait that owns its subscription

A useful helper owns three things: subscription order, event selection, and the deadline. It should accept the browser action as a function so the helper can subscribe first. It should return the actual event so the test can make an application-specific assertion. It should also report what it observed when no event matched, without recording cookies, authorization headers, or full query strings.

The following helper uses the public fields exposed by Selenium's JavaScript Network event objects. The structural types are local TypeScript types, not new Selenium classes. They make the test's dependency on request.url, request.method, request.request, redirectCount, response.status, and response.fromCache visible during review.

TypeScript
export type ResponseCompletedEvent = {
  id: string | null
  redirectCount: number
  request: {
    request: string
    url: string
    method: string
  }
  response: {
    url: string
    status: number
    fromCache: boolean
  }
  timestamp: number
}

export type NetworkClient = {
  responseCompleted(
    callback: (event: ResponseCompletedEvent) => void,
  ): Promise<void>
}

type CaptureOptions = {
  network: NetworkClient
  matches: (event: ResponseCompletedEvent) => boolean
  trigger: () => Promise<unknown>
  timeoutMs?: number
}

function endpointOnly(rawUrl: string): string {
  const url = new URL(rawUrl)
  return url.origin + url.pathname
}

export async function captureResponse({
  network,
  matches,
  trigger,
  timeoutMs = 8_000,
}: CaptureOptions): Promise<ResponseCompletedEvent> {
  const observed: string[] = []
  let resolveEvent!: (event: ResponseCompletedEvent) => void
  let rejectEvent!: (error: Error) => void
  let settled = false

  const eventPromise = new Promise<ResponseCompletedEvent>((resolve, reject) => {
    resolveEvent = resolve
    rejectEvent = reject
  })

  await network.responseCompleted((event) => {
    try {
      observed.push(
        event.request.method +
          " " +
          endpointOnly(event.request.url) +
          " -> " +
          event.response.status,
      )
      if (observed.length > 12) observed.shift()

      if (!settled && matches(event)) {
        settled = true
        resolveEvent(event)
      }
    } catch (error) {
      if (!settled) {
        settled = true
        rejectEvent(error instanceof Error ? error : new Error(String(error)))
      }
    }
  })

  const timer = setTimeout(() => {
    if (!settled) {
      settled = true
      rejectEvent(
        new Error(
          "Timed out after " +
            timeoutMs +
            " ms waiting for the matching response. Observed: " +
            observed.slice(-12).join(", "),
        ),
      )
    }
  }, timeoutMs)

  try {
    const [, event] = await Promise.all([trigger(), eventPromise])
    return event
  } finally {
    clearTimeout(timer)
  }
}

The sequence inside captureResponse is intentional. The callback is installed and its subscription call is awaited. Only then does trigger run. Starting the timer after subscription also keeps session startup time out of the event budget. If subscription itself stalls, the test runner's setup timeout should identify that separate boundary.

The observed list is diagnostic, not the oracle. A test can fail because twelve unrelated responses arrived and none matched. The helper's error then begins with the exact text generated in the code, followed by normalized endpoints and statuses. That output answers whether the browser was producing events at all and whether the predicate was close to the intended request. It does not expose query values that may contain user identifiers or tokens.

The predicate still belongs to the test because only the test knows the application contract. Consider a payment flow where the page URL contains an order supplied by the fixture. The event assertion should prove that the click generated a POST to that order's payment endpoint and that the server completed with the expected status. A DOM assertion should separately prove what the user saw. Either side can fail independently, which is exactly what makes the test useful.

TypeScript
import { strict as assert } from "node:assert"
import { Builder, By, type WebDriver } from "selenium-webdriver"
import firefox = require("selenium-webdriver/firefox")
import {
  captureResponse,
  type NetworkClient,
} from "./support/capture-response"

const { Network } = require("selenium-webdriver/bidi/network") as {
  Network(driver: WebDriver): Promise<NetworkClient>
}

async function runPaymentCheck(): Promise<void> {
  const appUrl = process.env.APP_URL
  const orderId = process.env.ORDER_ID
  if (!appUrl || !orderId) {
    throw new Error("APP_URL and ORDER_ID are required")
  }

  const options = new firefox.Options()
  options.enableBidi()
  options.addArguments("-headless")

  const driver = await new Builder()
    .forBrowser("firefox")
    .setFirefoxOptions(options)
    .build()

  let network: NetworkClient | undefined
  let primaryFailure: unknown

  try {
    network = await Network(driver)

    const checkout = new URL("/checkout", appUrl)
    checkout.searchParams.set("order", orderId)
    await driver.get(checkout.toString())

    const stateLocator = By.css('[data-testid="payment-state"]')
    const previousState = (
      await driver.findElement(stateLocator).getText()
    ).trim()
    const expectedPath =
      "/api/orders/" + encodeURIComponent(orderId) + "/payment"
    const event = await captureResponse({
      network,
      matches: (candidate) => {
        const url = new URL(candidate.request.url)
        return (
          candidate.request.method === "POST" &&
          url.origin === new URL(appUrl).origin &&
          url.pathname === expectedPath
        )
      },
      trigger: async () => {
        await driver.findElement(By.css('[data-testid="pay-now"]')).click()
      },
    })

    assert.equal(event.response.status, 201)
    assert.equal(event.redirectCount, 0)

    const observedState = await driver.wait(async () => {
      const text = (
        await driver.findElement(stateLocator).getText()
      ).trim()
      return text !== previousState ? text : false
    }, 3_000)
    assert.equal(observedState, "Payment accepted")
  } catch (error) {
    primaryFailure = error
    throw error
  } finally {
    const cleanupErrors: unknown[] = []

    try {
      await driver.quit()
    } catch (error) {
      cleanupErrors.push(error)
    }

    if (cleanupErrors.length > 0 && primaryFailure === undefined) {
      throw new AggregateError(cleanupErrors, "BiDi session cleanup failed")
    }
    if (cleanupErrors.length > 0) {
      console.error("Cleanup also failed:", cleanupErrors)
    }
  }
}

runPaymentCheck().catch((error) => {
  console.error(error)
  process.exitCode = 1
})

This test has two oracles that can genuinely fail. A backend regression can change 201 to 500 while the page still paints stale success text. A frontend regression can receive 201 but fail to update the payment state. The event does not self-certify the UI, and the UI does not prove which network request completed.

The cleanup code also protects the original failure. If the assertion throws and teardown has a second problem, the original error remains the thrown error while cleanup is recorded separately. If the test passed and cleanup alone failed, AggregateError makes the test fail. That distinction matters in CI because a quit failure should not rewrite a payment failure into an apparently unrelated infrastructure incident.

A second worked use case is an autosave field. Do not wait for any URL containing profile. The initial page load may issue GET /api/profile, while editing the field issues PATCH /api/profile. Match PATCH, normalize the origin and path, and correlate with a fixture value visible in the UI or request route. If the application cannot expose any stable correlation, serialize that test or assert the resulting UI state instead of pretending that the first matching response belongs to the edit.

Read the evidence before changing the timeout

A timeout tells you that the predicate did not receive its event before a deadline. It does not tell you why. The first useful diagnostic is a short, redacted stream from the exact event phase the test expects. Capture the receive time in Node, protocol timestamp, browsing context, request id, redirect count, method, endpoint, status, and cache flag. Keep the full URL and headers out unless a specific investigation requires them.

Run one event phase per diagnostic process. This keeps the result easy to interpret and avoids assuming that every selenium-webdriver release handles several event callbacks identically behind one helper. Set BIDI_EVENT to beforeRequestSent for one run and responseCompleted for another. The script below invokes only the selected public subscription method.

TypeScript
import { Builder, By, type WebDriver } from "selenium-webdriver"
import firefox = require("selenium-webdriver/firefox")

type EventRecord = {
  id: string | null
  redirectCount: number
  timestamp: number
  request: {
    request: string
    method: string
    url: string
  }
  response?: {
    status: number
    fromCache: boolean
  }
}

type DiagnosticNetwork = {
  beforeRequestSent(callback: (event: EventRecord) => void): Promise<void>
  responseStarted(callback: (event: EventRecord) => void): Promise<void>
  responseCompleted(callback: (event: EventRecord) => void): Promise<void>
}

const { Network } = require("selenium-webdriver/bidi/network") as {
  Network(driver: WebDriver): Promise<DiagnosticNetwork>
}

async function diagnose(): Promise<void> {
  const pageUrl = process.env.PAGE_URL
  const expectedOrigin = process.env.EXPECTED_ORIGIN
  const expectedPath = process.env.EXPECTED_PATH
  const expectedMethod = (process.env.EXPECTED_METHOD || "GET").toUpperCase()
  const triggerSelector = process.env.TRIGGER_SELECTOR
  const phase = process.env.BIDI_EVENT || "responseCompleted"

  if (!pageUrl || !expectedOrigin || !expectedPath) {
    throw new Error("PAGE_URL, EXPECTED_ORIGIN, and EXPECTED_PATH are required")
  }
  if (
    phase !== "beforeRequestSent" &&
    phase !== "responseStarted" &&
    phase !== "responseCompleted"
  ) {
    throw new Error(
      "BIDI_EVENT must be beforeRequestSent, responseStarted, or responseCompleted",
    )
  }

  const options = new firefox.Options()
  options.enableBidi()
  options.addArguments("-headless")
  const driver = await new Builder()
    .forBrowser("firefox")
    .setFirefoxOptions(options)
    .build()

  let network: DiagnosticNetwork | undefined
  let timer: ReturnType<typeof setTimeout> | undefined
  let primaryFailure: unknown
  const normalizedExpectedOrigin = new URL(expectedOrigin).origin

  try {
    network = await Network(driver)
    const capabilities = await driver.getCapabilities()
    console.log(
      JSON.stringify({
        bidiCapabilityPresent: Boolean(capabilities.get("webSocketUrl")),
        phase,
      }),
    )

    const seen: string[] = []
    let resolveMatch!: () => void
    let rejectMatch!: (error: Error) => void
    const matched = new Promise<void>((resolve, reject) => {
      resolveMatch = resolve
      rejectMatch = reject
    })

    const receive = (event: EventRecord): void => {
      const url = new URL(event.request.url)
      const endpoint = url.origin + url.pathname
      const row = {
        receivedAt: new Date().toISOString(),
        protocolTimestamp: event.timestamp,
        contextId: event.id,
        requestId: event.request.request,
        redirectCount: event.redirectCount,
        method: event.request.method,
        endpoint,
        status: event.response?.status ?? null,
        fromCache: event.response?.fromCache ?? null,
      }

      console.log(JSON.stringify(row))
      seen.push(event.request.method + " " + endpoint)
      if (seen.length > 20) seen.shift()

      if (
        event.request.method === expectedMethod &&
        url.origin === normalizedExpectedOrigin &&
        url.pathname === expectedPath
      ) {
        resolveMatch()
      }
    }

    if (phase === "beforeRequestSent") {
      await network.beforeRequestSent(receive)
    } else if (phase === "responseStarted") {
      await network.responseStarted(receive)
    } else {
      await network.responseCompleted(receive)
    }

    timer = setTimeout(() => {
      rejectMatch(
        new Error(
          "No " +
            phase +
            " event matched " +
            expectedMethod +
            " " +
            normalizedExpectedOrigin +
            expectedPath +
            ". Observed: " +
            seen.slice(-20).join(", "),
        ),
      )
    }, 8_000)

    const exercise = async (): Promise<void> => {
      await driver.get(pageUrl)
      if (triggerSelector) {
        await driver.findElement(By.css(triggerSelector)).click()
      }
    }

    await Promise.all([exercise(), matched])
  } catch (error) {
    primaryFailure = error
    throw error
  } finally {
    if (timer) clearTimeout(timer)
    try {
      await driver.quit()
    } catch (error) {
      if (primaryFailure === undefined) throw error
      console.error("Driver quit also failed:", error)
    }
  }
}

diagnose().catch((error) => {
  console.error(error)
  process.exitCode = 1
})

Start with responseCompleted because that is the event used by the failing assertion. Set TRIGGER_SELECTOR to the button's CSS selector when navigation alone does not cause the request. If the target row appears in this script but captureResponse times out in the test, compare the predicates character by character. Typical differences are GET versus POST, a trailing slash, a versioned path, an unexpected origin, or an order id that was encoded differently. The receive timestamp also tells you whether the row belongs to activity before or after the intended click.

Next run beforeRequestSent against the same controlled action when responseCompleted never appears. A matching beforeRequestSent row proves the browser began the request after subscription. It does not prove that a response arrived. The missing completion may be a server stall, a canceled navigation, a connection error, or a response whose body never finishes. Server access logs and browser-side failure evidence now matter more than another listener delay.

No matching row in either run moves the investigation earlier. Confirm that the click succeeded, the correct page was open, and the application actually initiated the request. Check the returned capabilities for webSocketUrl without printing its value. The diagnostic emits only a Boolean for that reason. On a remote Grid, also keep the browser name and version from the job metadata, but avoid dumping all capabilities because proxy credentials and internal endpoints can appear there.

A row with the wrong status is not a missing-event problem. Preserve the status, endpoint, request id, and redirect count, then inspect the service response and the visible application result. A 401 from an expired test account should not be converted into a BiDi timeout by a predicate that accepts only status 200. Match request identity first, return the event, and let the test assert the expected status. That produces a direct 401 versus 200 failure.

The cache flag is supporting evidence, not an automatic verdict. A completed response with fromCache set indicates that the event object reports a cached result. If the test's purpose is to prove a server-side write, a cacheable GET is the wrong oracle regardless of listener timing. If the purpose is to prove that the page obtained a usable resource, a cached response may be acceptable. Decide from the product contract rather than forcing fromCache to false in every helper.

Be careful with protocol timestamps. Keep them as supplied for correlation, but do not subtract them from Date.now() and call the result network latency without checking their defined time origin and units for the exact protocol version. The Node receive time is useful for ordering within the test log. It is not a measurement of browser processing or server duration.

Separate a missed event from the failures that look like it

The first look-alike is a listener attached after the trigger. The timeline is conclusive: click begins, request completes, responseCompleted registration finishes, then the wait starts. Browser or server logs contain the request, but the BiDi log for that test has no matching event. Moving the awaited subscription before the click fixes this case. Adding five seconds cannot recover a past notification.

The second look-alike is a predicate that selects a sibling request. A profile page often loads GET /api/profile and later saves PATCH /api/profile. Both have the same pathname. If the callback resolves on the GET, an assertion expecting 204 may report 200 and look like an API regression. Method and receive order reveal the mismatch. The correct fix is a narrower predicate, not accepting either status.

A related variation occurs with background polling. Suppose an order page requests GET /api/orders/42 every few seconds, and a cancel button sends POST /api/orders/42/cancel. Matching the order id alone lets the poll satisfy the wait. The product could fail to send the cancel request while the test stays green. A change to the code under test that removes the POST must make the test fail. Matching method and cancel pathname provides that failure path; merely checking that some event mentioned order 42 does not.

The third look-alike is an event phase mismatch. A report export endpoint may return headers quickly and stream a large body. responseStarted can arrive while responseCompleted remains absent until the body ends. If the requirement is only to verify that the server accepted the export and returned the expected content type, responseStarted may be the right boundary. If the requirement is that the download finished, changing to responseStarted weakens the test and creates a false pass. Keep responseCompleted and fix the server or test data instead.

The diagnostic distinction is specific. Run the same controlled request once with beforeRequestSent, once with responseStarted if that phase is needed, and once with responseCompleted. Do not subscribe to all phases and assume array position represents protocol order. Compare request id, redirect count, endpoint, and the event phase. A started row without a completed row supports an unfinished response. Neither row supports an earlier failure. A completed row rejects both hypotheses.

Redirects create a fourth near-miss. An authentication gateway may answer the original request with 302 and send the browser to a login page. A loose pathname check can catch the first hop or the destination depending on the route. Record redirectCount and status with the request id. If the test requires a direct API response, assert redirectCount is zero as the payment example does. If redirects are expected, assert the known chain rather than declaring every nonzero count a defect.

A browser action can fail before any network request. An overlay may intercept the click, the element may be stale, or the application may reject invalid form data in the client. In those cases, waiting on a network event hides the more useful UI failure. Keep the trigger inside captureResponse so its exception escapes immediately. Do not catch a click exception and continue waiting for the event. The first broken boundary is the one the report should show.

Session setup can also resemble an application timeout. If BiDi was not enabled before the driver was built, or a remote endpoint did not return a WebSocket URL, Network initialization cannot establish the required connection. Treat that as setup, not as a missing checkout response. Capture the browser and selenium-webdriver versions in CI, verify the returned capability is present, and stop before exercising the product. Do not invent a fallback that silently reads a different logging API, because that changes what the test observes.

Parallel execution introduces ownership bugs that are difficult to spot from a single log. Two tests sharing one driver also share browser activity and a BiDi session. Test A can consume a response caused by Test B when their predicates overlap. The responseCompleted() call does not return a documented callback handle that Test A can later remove. The safest default is one driver and one Network helper per test. Where session reuse is unavoidable, centralize subscriptions at the session owner and route immutable event records to tests using explicit correlation keys.

The final near-miss is teardown, not waiting. A test can assert successfully and still leave the browser process or WebSocket open. The runner then hangs after reporting a pass. Inspect active handles and confirm driver.quit ran. An unresolved event promise is not enough to keep Node alive, so resolving it manually may appear to help while the real socket remains. Ending the owned driver session closes Selenium's BiDi connection; a global force-exit flag only conceals it.

Roll the pattern through an existing suite

Start with a characterization test against one endpoint whose behavior is stable and visible. Pin the selenium-webdriver version in the lockfile, use one supported browser, and record the returned browser version in CI. The goal of this test is not broad product coverage. It proves that the binding, browser, driver, and Grid can subscribe, receive a known responseCompleted event, and tear down on the environment where the suite runs.

Put captureResponse in a small support module rather than a universal network recorder. Its public contract should stay narrow: one Network helper, one predicate, one trigger, one timeout, and one returned event. Avoid accepting arbitrary event names until there are tests for each payload shape. beforeRequestSent does not have response.status, and treating all phases as one unchecked object pushes failures from compilation into CI.

Migrate the highest-signal tests first. Good candidates are flows where an asynchronous request is part of the product contract and the DOM alone cannot explain a failure, such as payment acceptance, autosave, or generation of an export. Leave ordinary button-and-text tests alone. A network listener added to every test increases event traffic, log volume, and cleanup work without improving their oracles.

During rollout, run the old assertion and the new event assertion together for a limited period. Do not let one rescue the other. Record when the UI passes but the network assertion fails, and when the network passes but the UI fails. Those disagreements reveal whether the old test was hiding stale UI, whether the new matcher is too broad, or whether the endpoint contract differs across environments.

Add a dedicated CI job before enabling the helper across all shards. The job below assumes the repository defines test:bidi as the focused contract test and stores any redacted diagnostic files under artifacts/bidi. setup-node can enable the npm cache immediately because npm ships with Node. The explicit pipefail preserves the test command's exit status while tee writes the log.

YAML
name: WebDriver BiDi contract

on:
  pull_request:
  workflow_dispatch:

jobs:
  firefox-bidi:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    env:
      APP_URL: ${{ vars.QA_APP_URL }}
      ORDER_ID: ${{ vars.QA_BIDI_ORDER_ID }}

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - name: Install locked dependencies
        run: npm ci

      - name: Record browser version
        run: firefox --version

      - name: Run the focused BiDi contract
        shell: bash
        run: |
          set -o pipefail
          mkdir -p artifacts/bidi
          npm run test:bidi 2>&1 | tee artifacts/bidi/test.log

      - name: Retain redacted diagnostics
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: bidi-network-diagnostics
          path: artifacts/bidi
          retention-days: 7

Keep the first failing attempt when retries are enabled elsewhere in the pipeline. A passing retry cannot reconstruct the ordering of the failed subscription, click, and response. Store each attempt separately or disable retries for the contract job. If the job is quarantined, give it an owner and a removal condition tied to a reproducible browser or binding issue.

Review redaction before uploading artifacts. Query strings, headers, cookies, and response bodies are unnecessary for the matcher shown here. Endpoint paths and request ids are usually enough to correlate events, but an internal path can still contain customer data. Normalize or hash dynamic path segments when the application embeds sensitive identifiers there. Debug visibility is not permission to publish session traffic.

Broaden browser coverage only after the contract is green on the first browser. BiDi support evolves across browsers, drivers, Grid components, and Selenium bindings. A failure on a newly added browser should remain a compatibility result until the same product behavior is verified through another trustworthy observation. Do not change the application assertion merely to make a protocol contract test portable.

Know what the safer pattern costs

Subscribing before every trigger adds a protocol setup round trip and keeps a callback active until teardown. A busy page can send many events through that callback even when the predicate needs one. The helper retains only a short list of normalized summaries, but the callback still evaluates every completed response. On suites with hundreds of network-heavy tests, that cost is real. Measure it in your own environment before making the helper a default fixture.

Isolation costs browser sessions. One driver per test gives clean ownership but increases startup work and machine pressure. A shared driver reduces startup overhead while forcing the framework to route events, prevent cross-test matches, own subscription lifetime, and recover after a broken session. That trade is often unfavorable for a small number of high-value BiDi tests. It may be reasonable for a purpose-built harness with a single session owner, but not for unrelated tests that happen to run in the same worker.

A narrow predicate also costs testability work. Reliable business correlation may require a stable order id, request method, or endpoint contract. Adding a test-only query parameter can change caching and server behavior, so it should not be the automatic answer. Prefer identifiers already present in the real workflow. If no safe correlation exists, assert the visible outcome or arrange unique fixture data rather than weakening the matcher.

Waiting for responseCompleted can add time because it waits for the full body. That is correct when completion is the requirement. It is wasteful when the test needs only an HTTP status or a visible UI transition. Choosing responseStarted can reduce the boundary, but it also gives up proof that the body finished. Name that loss in the test so a later reviewer does not mistake the faster event for equivalent coverage.

Detailed diagnostics have a privacy cost. Network events can expose URLs, cookie metadata, headers, and timing data. The examples intentionally log origin plus pathname and omit header values, cookies, bodies, and queries. A temporary investigation that needs more detail should use an access-controlled artifact with a short retention period. Permanent CI logs should remain minimal.

Do not use this pattern when the user-visible state is the actual requirement and the DOM already offers a stable assertion. A search test should usually verify the results a user sees, not merely that a search endpoint returned 200. The server can return 200 with an empty or malformed payload. Network evidence can explain that failure, but it cannot replace the product oracle.

Avoid it for performance claims unless the protocol fields and measurement method have been validated for that purpose. Node receive time includes scheduling and transport effects. Protocol timestamps have defined semantics that may not match wall-clock subtraction. A functional event wait is not a load test, a real-user metric, or proof of backend latency.

Skip a completion wait for intentionally streaming responses, long polling, server-sent events, or downloads where the test does not require the stream to end. A deadline around responseCompleted will report the designed open connection as a failure. Assert the first meaningful chunk or visible state through a tool and API that explicitly supports that requirement, and keep the completion contract out of the test.

Finally, do not hide BiDi setup failures behind a Classic WebDriver fallback inside the same helper. The two paths expose different evidence. If browser compatibility requires a fallback, make it an explicit test variant with a distinct name and oracle. A reviewer should be able to tell whether a result came from a BiDi event, a DOM condition, or another logging mechanism without reverse-engineering the helper.

// FIELD DISPATCH

Get the QA Field Notes

Weekly QA battles, AI testing guides, and interview drills. Free on Substack.

// LIVE COURSE / THE TESTING ACADEMY

Playwright Automation Mastery

Go beyond Selenium. Master Playwright with JS/TS in 90 days.

From the instructor behind this guide.

Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

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

Published July 25, 2026 / Reviewed August 4, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Official 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

Why does Selenium miss a WebDriver BiDi network event?

Usually, the browser action ran before the network subscription finished, or the callback filtered for a URL that never matched. Await the subscription method before the trigger, then log the method, normalized URL, request id, redirect count, and status.

Should I wait for responseStarted or responseCompleted?

Choose responseStarted when headers and status are enough for the assertion. Use responseCompleted when the test specifically needs evidence that the full response body arrived, and give streaming responses a different test strategy.

Does driver.get wait for every API response on the page?

No. A completed navigation does not prove that a later fetch started by application JavaScript has finished. Arm the relevant BiDi subscription before the action that causes that fetch, then await the matching event.

How do I stop BiDi listeners leaking into the next test?

Give each test its own driver session and end it with driver.quit() in teardown. The JavaScript responseCompleted() method does not return a documented callback handle, so independently owned listeners should not share a session.

Can I fix a missing event by increasing the timeout?

A longer limit only helps when the correct subscription and matcher are already in place and the response is genuinely slow. It cannot recover an event emitted before registration, correct a wrong path, or make a streaming body complete.