PRACTICAL GUIDE / Selenium BiDi response body size limit testing

Catch oversized response bodies before they overwhelm your Selenium run

Use WebDriver BiDi response metadata to test payload limits, diagnose misleading size signals, and roll reliable response budgets into CI safely.

By The Testing AcademyUpdated August 7, 202625 min read
All field guides
In this guide6 sections
  1. Know which number you are limiting
  2. Prove the boundary with a controlled response
  3. Separate wire size from decoded size
  4. Catch a page made heavy by many small responses
  5. Diagnose the same symptom without guessing
  6. Separate payload growth from duplicate accounting
  7. Roll the check into CI, and know when to leave it out

What you will learn

  • Know which number you are limiting
  • Prove the boundary with a controlled response
  • Separate wire size from decoded size
  • Catch a page made heavy by many small responses

A report page is fast on a developer laptop, then its CI job is killed while the browser downloads a 28 MB JSON response. The screenshot is missing, the WebDriver exception says the session disappeared, and the API still returns 200. That is a payload budget failure until the evidence proves otherwise.

The useful test is not “did a request happen?” It is “which representation crossed which limit, and did we measure it after the transfer actually finished?” WebDriver BiDi gives Selenium access to the response metadata needed to answer that without copying every response body into the test process.

Know which number you are limiting

An HTTP response has more than one defensible size. Teams get into trouble when a requirement says “responses must stay below 1 MB” and the test silently picks whichever number is easiest to read.

The WebDriver BiDi network model separates several measurements:

FieldWhat it representsA sensible use
bodySizeEncoded response-body size, or null when it cannot be reportedNetwork payload budget
content.sizeDecoded response-body sizeBrowser memory and parsing budget
headersSizeTransmitted response-header size, or nullCatching oversized cookies and headers
bytesReceivedTotal bytes received for the HTTP responseTransfer diagnostics, not a substitute for body size

Selenium's Java binding exposes those values through ResponseData. The exact return types matter. getBodySize() and getHeadersSize() return nullable Long values. getContent() returns an Optional<Long>, and getBytesReceived() returns a primitive long. Converting a missing body size to zero produces a green test for a measurement the browser never supplied. A missing value is either a support gap, an excluded response type, or a reason to mark the check inconclusive.

Encoded and decoded sizes answer different engineering questions. A gzip-compressed JSON document might consume 180 KB on the wire and expand to 12 MB in the renderer. The first number affects transfer time and mobile bandwidth. The second affects allocation, parsing time, garbage collection, and the amount of evidence a test reporter might accidentally retain. One threshold cannot cover both risks honestly.

Content-Length is useful context, but it is not your final measurement. A response can be streamed without that header. Compression changes what the header describes, intermediaries can alter the transfer, and some response classes do not carry a body at all. Reading the header during responseStarted only proves what was declared when headers arrived. The network.responseCompleted event is emitted after the complete body is received, so it is the right event for the final metadata assertion. Selenium documents both event subscriptions in its BiDi network guide.

Pick a budget that names the resource and the representation. “The decoded response from /api/dashboard must not exceed 2 MiB” is testable. “Network calls should be small” is not. Keep binary units explicit as well: 1 MiB is 1,048,576 bytes, while 1 MB is 1,000,000 bytes. An unexplained conversion creates an annoying 4.8 percent disagreement at exactly the point where a boundary test should be precise.

The event must also be correlated to the intended request. Modern pages load documents, scripts, icons, telemetry, and API calls together. Completing the first CompletableFuture from any response often measures the HTML document or a favicon. Match the full URL when the fixture owns it. In a production test, match a stable path plus the expected method, status, and browsing context. Preserve the BiDi request id in failure evidence so a reviewer can join started, completed, redirect, and error records without guessing.

Size checks work best as product contracts, not universal browser rules. A 500 KB account-summary response might be a regression because it runs on every login. A 20 MB customer export can be valid because the user explicitly requested a file. Separate those endpoint classes before writing assertions. Otherwise the first legitimate exception turns a sharp limit into a global 50 MB ceiling that protects nothing.

Prove the boundary with a controlled response

A boundary check needs one response at the limit and another just over it. Production endpoints are poor fixtures for this job because records, compression, headers, and caches change between runs. A tiny local HTTP server lets the test own the byte count while Selenium still observes a real browser transfer.

The following JUnit 5 example serves an uncompressed body whose length comes from the query string. It subscribes before navigation, filters by the exact URL, waits for responseCompleted, and treats a null bodySize as a failed measurement. Firefox is configured to expose the BiDi WebSocket URL, matching Selenium's official Java examples.

Java
package example;

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

import com.sun.net.httpserver.HttpServer;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.bidi.module.Network;
import org.openqa.selenium.bidi.network.ResponseDetails;
import org.openqa.selenium.firefox.FirefoxDriver;
import org.openqa.selenium.firefox.FirefoxOptions;

class ResponseBodyBoundaryTest {
    private static final long LIMIT_BYTES = 256L * 1024L;
    private static HttpServer server;
    private static String baseUrl;

    @BeforeAll
    static void startServer() throws Exception {
        server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
        server.createContext("/payload", exchange -> {
            String query = exchange.getRequestURI().getRawQuery();
            int requestedBytes = Integer.parseInt(query.substring("bytes=".length()));
            byte[] body = "x".repeat(requestedBytes).getBytes(StandardCharsets.UTF_8);

            exchange.getResponseHeaders().set(
                    "Content-Type", "text/plain; charset=utf-8");
            exchange.sendResponseHeaders(200, body.length);
            try (OutputStream output = exchange.getResponseBody()) {
                output.write(body);
            }
        });
        server.start();
        baseUrl = "http://127.0.0.1:" + server.getAddress().getPort();
    }

    @AfterAll
    static void stopServer() {
        server.stop(0);
    }

    @Test
    void reportsTheExactLimitAndTheFirstFailingByte() throws Exception {
        FirefoxOptions options = new FirefoxOptions();
        options.setCapability("webSocketUrl", true);
        WebDriver driver = new FirefoxDriver(options);

        try (Network network = new Network(driver)) {
            long atLimit = measureBody(
                    baseUrl + "/payload?bytes=" + LIMIT_BYTES, driver, network);
            long oneByteOver = measureBody(
                    baseUrl + "/payload?bytes=" + (LIMIT_BYTES + 1), driver, network);

            assertEquals(LIMIT_BYTES, atLimit);
            assertEquals(LIMIT_BYTES + 1, oneByteOver);
            assertTrue(atLimit <= LIMIT_BYTES);
            assertFalse(oneByteOver <= LIMIT_BYTES);
        } finally {
            driver.quit();
        }
    }

    private static long measureBody(
            String target, WebDriver driver, Network network) throws Exception {
        CompletableFuture<ResponseDetails> completed = new CompletableFuture<>();
        network.onResponseCompleted(details -> {
            if (target.equals(details.getResponseData().getUrl())) {
                completed.complete(details);
            }
        });

        driver.get(target);

        ResponseDetails details;
        try {
            details = completed.get(10, TimeUnit.SECONDS);
        } catch (TimeoutException error) {
            throw new AssertionError(
                    "No responseCompleted event for " + target, error);
        }

        Long bodySize = details.getResponseData().getBodySize();
        assertNotNull(bodySize, "Browser returned a null bodySize for " + target);
        return bodySize;
    }
}

Run that test with JUnit in the same way as the rest of the Selenium suite. It should pass because the negative case asserts that the byte above the limit is rejected by the policy expression. For a real endpoint, change the final policy assertion to fail when the observed value exceeds its contract:

Java
if (observedBytes > limitBytes) {
    throw new AssertionError(
            "Encoded body budget exceeded: url=" + url
                    + ", limitBytes=" + limitBytes
                    + ", observedBytes=" + observedBytes);
}

That helper produces a useful, deterministic message:

Example
java.lang.AssertionError: Encoded body budget exceeded: url=https://app.example.test/api/report, limitBytes=1048576, observedBytes=29360128

The local test costs a browser startup and two real navigations. It is slower than a unit test of a comparison function, but it verifies the protocol mapping, listener order, URL correlation, null handling, and boundary arithmetic together. Keep this narrow test in the framework's own verification suite. Product tests can reuse the proven listener rather than rebuilding the protocol experiment in every class.

Do not turn the listener callback into the assertion site. BiDi callbacks are asynchronous, and an exception thrown there can be detached from the JUnit test thread or reported without the context you expect. Complete a future, put an immutable record on a queue, or update a thread-safe collection. Perform the assertion back on the test thread after a bounded wait.

Separate wire size from decoded size

Compression creates the most common convincing near-miss. The transfer dashboard says 90 KB, but the browser process allocates several megabytes and the test worker still runs out of memory. Both observations can be correct.

The Content-Encoding reference explains that encodings such as gzip apply to the representation sent over the connection. WebDriver BiDi reports encoded body size separately from decoded content size. The next runnable example serves highly compressible JSON. Its wire budget passes, while its decoded budget fails.

Java
package example;

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

import com.sun.net.httpserver.HttpServer;
import java.io.ByteArrayOutputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.zip.GZIPOutputStream;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.bidi.module.Network;
import org.openqa.selenium.bidi.network.ResponseData;
import org.openqa.selenium.bidi.network.ResponseDetails;
import org.openqa.selenium.firefox.FirefoxDriver;
import org.openqa.selenium.firefox.FirefoxOptions;

class CompressedResponseBudgetTest {
    private static final long WIRE_LIMIT_BYTES = 32L * 1024L;
    private static final long DECODED_LIMIT_BYTES = 256L * 1024L;
    private static HttpServer server;
    private static String target;
    private static byte[] decoded;
    private static byte[] encoded;

    @BeforeAll
    static void startServer() throws Exception {
        String json = """
                {"items":["%s"]}
                """.formatted("A".repeat(400_000));
        decoded = json.getBytes(StandardCharsets.UTF_8);
        encoded = gzip(decoded);

        server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
        server.createContext("/compressed.json", exchange -> {
            exchange.getResponseHeaders().set("Content-Type", "application/json");
            exchange.getResponseHeaders().set("Content-Encoding", "gzip");
            exchange.sendResponseHeaders(200, encoded.length);
            try (OutputStream output = exchange.getResponseBody()) {
                output.write(encoded);
            }
        });
        server.start();
        target = "http://127.0.0.1:" + server.getAddress().getPort()
                + "/compressed.json";
    }

    @AfterAll
    static void stopServer() {
        server.stop(0);
    }

    @Test
    void appliesIndependentWireAndDecodedBudgets() throws Exception {
        FirefoxOptions options = new FirefoxOptions();
        options.setCapability("webSocketUrl", true);
        WebDriver driver = new FirefoxDriver(options);

        try (Network network = new Network(driver)) {
            CompletableFuture<ResponseDetails> completed = new CompletableFuture<>();
            network.onResponseCompleted(details -> {
                if (target.equals(details.getResponseData().getUrl())) {
                    completed.complete(details);
                }
            });

            driver.get(target);
            ResponseData response = completed.get(10, TimeUnit.SECONDS)
                    .getResponseData();

            Long wireBytes = response.getBodySize();
            assertNotNull(wireBytes, "Encoded body size was not reported");
            long decodedBytes = response.getContent().orElseThrow(
                    () -> new AssertionError("Decoded content size was not reported"));

            assertEquals(encoded.length, wireBytes.longValue());
            assertEquals(decoded.length, decodedBytes);
            assertTrue(wireBytes <= WIRE_LIMIT_BYTES);
            assertFalse(decodedBytes <= DECODED_LIMIT_BYTES);
        } finally {
            driver.quit();
        }
    }

    private static byte[] gzip(byte[] input) throws Exception {
        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
        try (GZIPOutputStream gzip = new GZIPOutputStream(bytes)) {
            gzip.write(input);
        }
        return bytes.toByteArray();
    }
}

The example distinguishes a transport regression from a renderer-risk regression. If bodySize jumps while decoded content stays steady, compression may have been removed, bypassed, or changed. If decoded content grows while the compression ratio stays similar, the API probably returned more records or fields. If both numbers are stable but the process still consumes more memory, stop blaming payload size and inspect body retention, JSON parsing, screenshots, tracing, and reporter attachments.

Avoid asserting an exact compressed byte count against production. Gzip output can change with server libraries, compression levels, dictionaries, and payload ordering while the user impact remains acceptable. Use a maximum wire budget there. Exact equality belongs in the controlled fixture because it detects a change in what the test itself serves.

A decompression ratio is valuable diagnostic metadata:

Example
url=/api/catalog
encodedBodyBytes=184392
decodedContentBytes=11890642
decompressionRatio=64.49
wireLimitBytes=524288
decodedLimitBytes=8388608
decision=FAIL_DECODED_LIMIT

That record explains why a network-only assertion passed. It also avoids retaining eleven megabytes of customer data just to prove that eleven megabytes existed. Store sizes, URL classification, status, cache state, request id, and the chosen policy. Do not attach the body by default.

Catch a page made heavy by many small responses

Per-response limits miss death by a thousand requests. A dashboard can stay under a 100 KiB endpoint cap while loading thirty widgets and consuming several megabytes before it becomes interactive. The failure looks like a single oversized response when the CI worker is killed, but the fix is request consolidation, pagination, lazy loading, or a page-level budget.

The third example creates three API responses. Each passes the individual limit. Their decoded total crosses the route budget. A thread-safe map keeps the final response for each expected path, and a latch prevents the test from asserting before asynchronous fetch calls finish.

Java
package example;

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

import com.sun.net.httpserver.HttpServer;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.bidi.module.Network;
import org.openqa.selenium.bidi.network.ResponseData;
import org.openqa.selenium.firefox.FirefoxDriver;
import org.openqa.selenium.firefox.FirefoxOptions;

class RoutePayloadBudgetTest {
    private static final long RESPONSE_LIMIT_BYTES = 100L * 1024L;
    private static final long ROUTE_LIMIT_BYTES = 200L * 1024L;
    private static final int API_BODY_BYTES = 72 * 1024;
    private static HttpServer server;
    private static String pageUrl;

    @BeforeAll
    static void startServer() throws Exception {
        server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);

        String html = """
                <!doctype html>
                <meta charset="utf-8">
                <title>loading</title>
                <script>
                  Promise.all([
                    fetch('/api/profile').then(response => response.text()),
                    fetch('/api/orders').then(response => response.text()),
                    fetch('/api/alerts').then(response => response.text())
                  ]).then(() => document.title = 'loaded');
                </script>
                """;
        server.createContext("/", exchange -> write(
                exchange, "text/html; charset=utf-8",
                html.getBytes(StandardCharsets.UTF_8)));

        byte[] apiBody = "d".repeat(API_BODY_BYTES)
                .getBytes(StandardCharsets.UTF_8);
        server.createContext("/api/profile", exchange ->
                write(exchange, "text/plain", apiBody));
        server.createContext("/api/orders", exchange ->
                write(exchange, "text/plain", apiBody));
        server.createContext("/api/alerts", exchange ->
                write(exchange, "text/plain", apiBody));

        server.start();
        pageUrl = "http://127.0.0.1:" + server.getAddress().getPort() + "/";
    }

    @AfterAll
    static void stopServer() {
        server.stop(0);
    }

    @Test
    void detectsAnAggregateBreachWhenEveryResponsePasses() throws Exception {
        FirefoxOptions options = new FirefoxOptions();
        options.setCapability("webSocketUrl", true);
        WebDriver driver = new FirefoxDriver(options);
        Map<String, ResponseData> apiResponses = new ConcurrentHashMap<>();
        CountDownLatch completedApis = new CountDownLatch(3);

        try (Network network = new Network(driver)) {
            network.onResponseCompleted(details -> {
                ResponseData response = details.getResponseData();
                String path = URI.create(response.getUrl()).getPath();
                if (path.startsWith("/api/")
                        && apiResponses.putIfAbsent(path, response) == null) {
                    completedApis.countDown();
                }
            });

            driver.get(pageUrl);
            assertTrue(
                    completedApis.await(10, TimeUnit.SECONDS),
                    "Did not observe all three API responses");
            assertEquals(3, apiResponses.size());

            assertTrue(apiResponses.values().stream().allMatch(response ->
                    response.getContent().orElseThrow() <= RESPONSE_LIMIT_BYTES));

            long decodedTotal = apiResponses.values().stream()
                    .mapToLong(response -> response.getContent().orElseThrow())
                    .sum();
            assertEquals(3L * API_BODY_BYTES, decodedTotal);
            assertTrue(decodedTotal > ROUTE_LIMIT_BYTES);
        } finally {
            driver.quit();
        }
    }

    private static void write(
            com.sun.net.httpserver.HttpExchange exchange,
            String contentType,
            byte[] body) throws java.io.IOException {
        exchange.getResponseHeaders().set("Content-Type", contentType);
        exchange.sendResponseHeaders(200, body.length);
        try (OutputStream output = exchange.getResponseBody()) {
            output.write(body);
        }
    }
}

The path filter is deliberately explicit. Summing every response on a page includes framework bundles, fonts, analytics, extension traffic, and browser-generated requests. Those may matter to a total transfer budget, but mixing them with an API contract makes ownership unclear. Maintain separate totals for first-party API data, static assets, media, and third-party resources. A route owner can act on an API regression; they cannot fix a vendor pixel simply because the test placed both in one number.

Aggregation also needs a time boundary. The example knows that exactly three calls complete. A real single-page application may poll indefinitely or load data after user interaction. Define the window as a user journey: from listener installation through “dashboard ready,” or from clicking “Load details” until the named request set completes. Sleeping for five seconds measures whatever happened to fit inside a machine-dependent interval. Waiting for expected requests or a product readiness condition gives the total a stable meaning.

The cost is bookkeeping. A map of metadata is cheap, but endpoint classification and journey ownership need maintenance. Aggregate checks also make failures less local: one route budget can fail after three individually acceptable changes from different teams. Report the contribution of every included URL, sorted largest first, so the regression is actionable.

Diagnose the same symptom without guessing

A killed browser, a missing response event, and an oversized payload can appear together without having the same cause. Start from the last trustworthy observation.

If responseCompleted arrived and its size crossed the named budget, the test has direct evidence. If only responseStarted arrived, the headers were received but the body did not finish. That points toward a stalled stream, connection failure, browser termination, or timeout. It does not prove the completed body would have been large. If neither event arrived, first check listener order, BiDi session support, URL filtering, redirects, and whether the request was ever triggered.

Use a bounded wait with a message that identifies the expected URL. The timeout from the first example becomes:

Example
java.lang.AssertionError: No responseCompleted event for http://127.0.0.1:49172/payload?bytes=262144
Caused by: java.util.concurrent.TimeoutException

That is a measurement failure, not a zero-byte response. Replacing the timeout with orElse(0) or catching it and continuing would turn missing instrumentation into a pass.

Record these fields for every candidate response:

Example
testId=dashboard_payload_budget
sessionId=8c1f...
contextId=1f0a...
requestId=17
url=https://app.example.test/api/dashboard
status=200
fromCache=false
mimeType=application/json
bytesReceived=11893410
headersSize=684
bodySize=184392
decodedContentSize=11890642
limitKind=decoded
limitBytes=8388608
decision=fail

Selenium exposes the browsing context, request data, status, MIME type, cache indicator, and size fields used by that record. Session and test identifiers usually come from the framework. Keep them beside the BiDi values rather than pretending Selenium created a test id for you.

Several near-misses deserve separate treatment:

EvidenceLikely explanationCountercheck
Small bodySize, large decoded contentCompression hides a large representationCompare getBodySize() with getContent()
Large bytesReceived, acceptable bodyHeaders, framing, or another transfer detail affects the totalInspect headersSize, status, and body size separately
Declared Content-Length is large, no completionThe server declared a body that stalled or was cut offCheck server access logs and whether responseCompleted or fetchError occurred
Completed response is from cacheThe run did not exercise the intended network transferRecord isFromCache() and rerun under an explicit cache policy if network cost matters
Several redirects appearThe listener measured an intermediate responseMatch the final URL, status, and redirect sequence
Size is stable, worker memory growsThe framework or application retains data after transferCompare process memory and artifact behavior with body capture disabled

A cache hit is not automatically invalid. For a decoded-memory budget, cached content still has to be consumed by the page, and decoded size can remain relevant. For a wire-transfer regression, a cached run does not prove what a cold user downloads. State which behavior the test covers. Do not clear caches reflexively in every suite because that adds latency and removes useful real-world coverage.

Redirects can fool exact-URL correlation in the opposite direction. If /api/report redirects to a signed storage URL, filtering only the first address can collect a small redirect response and miss the large final body. Log the request id, redirect count, status, and response URL during investigation. Then decide whether the contract belongs to the redirecting endpoint, the final resource, or their combined journey.

Status codes can change the meaning of the same number. A HEAD request has response headers but no transferred representation body, so it cannot prove the size a later GET will deliver. A 204 response should not be treated as a missing large payload merely because bodySize is null or zero. A 206 Partial Content response measures one range, not the complete object. For range traffic, record the status and Content-Range header, then decide whether the contract applies to each chunk, the requested journey total, or the full resource advertised by the server. Comparing a 100 KB chunk with a 10 MB full-resource limit produces a technically green but useless check.

Repeated calls to one GraphQL URL need more than URL matching. The dashboard query and a tiny notification query may both post to /graphql, and whichever completes first can satisfy a naive future. Bound the listener to the action that triggers the request, retain every matching request id during that window, and classify the completed responses by stable application evidence such as an operation identifier that your system already emits. If no safe identifier exists, assert the aggregate for the journey or add observability at the service boundary. Do not guess from completion order because response timing changes under parallel CI.

An early browser death leaves no final BiDi event because the process that would emit it is gone. Correlate server-side response bytes, Grid or container memory, operating-system termination evidence, and the last BiDi event. A server log showing 28 MB sent plus a worker out-of-memory kill is persuasive, but it is still different from a completed browser measurement. Fix the dangerous fixture or add a lower-layer contract test before repeatedly driving the browser into the same resource.

Do not print response bodies, authorization headers, cookies, or query secrets to make the diagnostic richer. Size metadata normally answers the boundary question. If content must be inspected for a separate defect, redact it under an explicit evidence policy and cap the artifact before it enters memory. A size guard that captures the entire oversized body first has already lost the resource-control argument.

Separate payload growth from duplicate accounting

A second failure mode can produce an almost identical over-budget report even though the service returned no extra bytes. A listener installed twice, or a collector that replays one completion record during retry handling, can add the same response to an aggregate more than once. The job then reports a large route total, the final assertion names the expected endpoint, and a memory-constrained reporter may also struggle because it retained duplicate evidence. Reducing the API payload does not repair that instrumentation defect.

Request identity separates the cases. A genuinely oversized response has one completed request whose bodySize or decoded content size crosses its individual budget. A page that genuinely requested the same resource twice has two request ids and, ordinarily, two corresponding entries at the service or proxy boundary. Duplicate accounting has the same session, browsing context, request id, URL, status, cache indicator, size values, and completion observation repeated in the test collector, while the service-side evidence shows only one request. Do not deduplicate by URL. Polling, retries, and concurrent GraphQL operations can legitimately share an address, so URL-only deduplication hides real transfer cost.

Read a diagnostic record from the policy outward. First inspect decision and limitKind, then compare the field named by that limit with limitBytes. Only after that should bytesReceived, headers, cache state, and content encoding be used to explain the result. The following values are illustrative, not measurements. For an encoded limit of 1,048,576 bytes, a healthy record might have a non-null bodySize of 786,432 and a pass decision. A broken record might have the same expected status and cache state but a bodySize of 1,310,720 and a fail decision. A misleading record might show bytesReceived at 1,310,720 while bodySize remains 786,432. That last record does not prove the body exceeded the encoded-body budget because the larger field answers a different question.

Preventing duplicate accounting costs lifecycle complexity. The collector needs a request-identity ledger scoped to one session and one measurement window. Clearing it while callbacks from the previous window are still being drained can admit a repeated callback into the next total. Retaining it across a replacement session without including session identity in the key can suppress a valid request identifier from the new scope. That state deserves its own controlled test, especially when the suite reuses browsers or retries failed test methods.

Roll the check into CI, and know when to leave it out

Dropping a hard 1 MiB assertion across an established suite creates noise, not governance. Existing payloads will include legitimate exports, legacy endpoints, cache variations, and tests that do not request BiDi capability. Roll out the measurement in stages.

First, inventory the journeys that have an actual payload risk. Good candidates are responses parsed on initial load, repeated polling calls, large GraphQL results, and endpoints whose data ends up in screenshots or reports. Assign each one a wire budget, decoded budget, aggregate journey budget, or a deliberate combination. Put downloads, streaming, media, and third-party calls in separate categories.

Next, run the listener in observation mode. Publish size metadata without failing the job for a week or another representative traffic window. Compare the results across browsers, CI workers, accounts, locales, and seeded data volumes. A threshold based only on today's median merely freezes current behavior. A useful budget comes from a user or platform constraint, with observed distributions showing whether the proposed gate is realistic.

Add controlled boundary tests like the first two examples before enabling product failures. Those tests prove the browser and Selenium versions still expose the fields your helper depends on. If a driver update starts returning null, the framework test should report an instrumentation regression. Product tests should not suddenly interpret every missing value as a zero or an oversized body.

Attribution usually breaks before the byte comparison does. Existing tests may subscribe after an action, reuse a browser whose earlier listeners remain active, navigate through an authentication redirect, or finish as soon as the page looks ready while background responses are incomplete. Shadow reports expose those faults as missing expected request ids, duplicate identities, unexpected response classes, or totals that change between identical retry attempts. Fix those lifecycle problems before choosing a wider threshold. Raising the limit to silence them turns an observation defect into policy.

The change is working when three independent signals agree. The controlled fixture reports its known boundary on every supported lane. An intentionally oversized fixture changes only the expected policy result, rather than timing out or losing correlation. Finally, an unchanged product journey produces the same eligible request set and comparable size records across retry attempts, apart from variations the contract explicitly permits. A green dashboard alone is weak evidence because a collector that stopped receiving events also produces no budget failures.

Then enable hard failures for a small, owned set of endpoints. The assertion message should include URL classification, limit type, limit bytes, observed bytes, cache state, browser, and request id. Upload a compact, sorted contribution report for aggregate failures. Keep every retry's report under a distinct attempt id. A passing retry must not overwrite the only record that shows the oversized response.

Expand by ownership rather than by raw request count. Teams need somewhere to send the failure. A central allowlist with no expiry tends to grow forever, so require a reason, owner, and review date for every temporary exception. Where an immediate reduction is too risky, ratchet the threshold downward in explicit steps and keep the target budget visible.

Ownership follows the failed evidence. The test-framework team owns a controlled fixture that loses events, duplicates one request identity, or changes null handling. The API team owns an individual decoded representation that grew because its records or fields changed. The edge or platform team owns a wire-size jump when decoded size is steady and the encoding path changed. The frontend team owns repeated, distinct request ids or a journey window that now loads more resources. The CI or Grid team enters only when payload metadata remains within contract and independent process evidence points to worker termination or resource limits.

The handoff should contain the test and attempt identifiers, browser, measurement window, URL classification, method, status, session and request ids, cache state, relevant size fields, chosen limit, and the ordered started, completed, redirect, or error observations. Include the smallest reproducible data shape and say whether body capture and rich reporter attachments were disabled. For a suspected duplicate, include both the collector records and the matching service or proxy request count. For a compression change, include the content encoding plus encoded and decoded sizes. This packet lets the receiving team challenge the classification without rerunning an unstable CI failure or requesting sensitive body content.

Each fix has a cost:

  • A lower payload limit can force pagination or extra requests, trading memory for latency and server chatter.
  • Stronger compression saves wire bytes but consumes CPU on the server and browser, and it does nothing for decoded-memory pressure.
  • Lazy loading improves initial transfer size but adds interaction states and more asynchronous test paths.
  • Listener-based browser checks add session setup, event processing, and cross-browser compatibility work.
  • Aggregate budgets create shared ownership when several services contribute to one route.
  • Retaining richer diagnostics increases artifact storage and can expose sensitive data.

Measure the cost you choose. Pagination that turns one 4 MB response into forty sequential 100 KB requests can pass a per-response limit while making the experience slower. Compression that reduces a response from 3 MB to 200 KB can satisfy a wire contract while preserving the same 3 MB parse cost. A good fix makes the intended budget pass and checks the new failure mode it introduces.

Some tests should stay out of this mechanism. Server-sent events and other long-lived streams may never produce responseCompleted during the test window. Measure message rate, bounded window volume, or application backpressure with a purpose-built test instead. Video, large downloads, and user-requested exports need product-specific budgets and often belong in HTTP integration or storage tests, not a browser smoke suite.

Third-party resources are another poor hard gate unless your contract controls them. Report their contribution separately and alert on major shifts, but avoid making every deployment depend on an advertising or analytics vendor's current payload. Static assets are better governed through bundle tooling and CDN checks, while BiDi remains useful for confirming what a real journey actually requested.

This technique does not catch pathological content structure. A response can remain below both byte limits and still trigger excessive parsing work through extreme nesting, an adversarial shape, or application logic that expands a compact value into a large in-memory graph. Size metadata cannot prove that the payload is safe to parse. Parser-focused limits, schema validation, and application memory tests cover that separate risk.

Finally, body size is not a performance test. It is one explanatory variable. A 50 KB response can be slow because the server waits ten seconds, and a 5 MB response can arrive quickly on a local network while still exhausting a small device. Keep latency, concurrency, rendering, and memory checks where they belong. Use BiDi metadata to enforce a clearly named payload contract and to give failures evidence that points to the right owner.

// FIELD DISPATCH

Get the QA Field Notes

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

// LIVE COURSE / THE TESTING ACADEMY

Playwright Automation Mastery

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

From the instructor behind this guide.

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

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

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

Published July 25, 2026 / Reviewed August 7, 2026

PRIMARY REFERENCES

Verify the details at the source

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

  1. 01
    Official selenium.dev reference

    selenium.dev

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

  2. 02
    Official selenium.dev reference

    selenium.dev

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

  3. 03
    Official w3.org reference

    w3.org

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

  4. 04
    Official developer.mozilla.org reference

    developer.mozilla.org

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

FAQ / QUICK ANSWERS

Questions testers ask

Does bodySize include HTTP headers in WebDriver BiDi?

No. The bodySize field describes the encoded response body, while headersSize is reported separately when the browser can provide it. Do not assume bytesReceived equals those two values added together because protocol framing and implementation details can differ.

Why can decoded content be much larger than bodySize?

Compression reduces the bytes transferred over the network, but the browser expands those bytes before JavaScript consumes the content. Use bodySize for a wire budget and content size for a decoded-memory budget.

Should I fetch every response body to enforce a size limit?

Usually not. Response metadata is enough for a size assertion, and retaining bodies adds memory, privacy, and artifact-storage costs. Capture a body only when its content is required to explain a specific failure.

Which BiDi event should I use for a final response size?

Listen for network.responseCompleted because it is emitted after the full response body has been received. The earlier responseStarted event is useful for headers and status, but it is the wrong boundary for a final transfer measurement.

How should I choose a response size threshold?

Start with a product or platform budget tied to a user-visible risk, then compare it with production distributions and known exceptional endpoints. Give exports, media, and streaming traffic separate rules instead of weakening one global threshold.