> ## Documentation Index
> Fetch the complete documentation index at: https://docs.brightdata.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Understanding your bandwidth

> See what your Browser API bandwidth was spent on, read the bandwidth breakdown, and reconcile your own CDP measurements with what you are billed.

This page explains how Browser API measures and bills bandwidth, what each field in the bandwidth breakdown means, and why the number you measure in your own script can differ from the billed total.

## Overview

You are billed for one number: `total`. It is every byte that crosses the proxy tunnel for your session, measured on the wire. Everything else in the bandwidth breakdown explains where that `total` came from, so you can match it against your own measurements.

If you count traffic yourself with Chrome DevTools Protocol (CDP) `Network.*` events, your number will differ from `total`, and is usually lower. That is expected. Three things make up the difference:

* **Work done before your session starts.** For some sites, the Browser API warms up the browser before your target page loads, for example by loading the site's homepage first. This is one of the ways we unblock sites with a high success rate. It happens before your session starts, so your script cannot see this traffic in its CDP events. It is reported as `before_session`.
* **Protocol overhead.** Encryption (TLS) handshakes and framing add bytes to every connection. They are reported as `overhead_bytes`.
* **Traffic Chrome does not report to your script.** Some requests cross the network without producing the CDP events your script listens to. They are reported as `client_hidden_bytes`.

After you account for these three, a small difference (the residual) usually remains. A few possible reasons for the residual are explained in [Why your number differs from what we bill](#why-your-number-differs-from-what-we-bill).

> [!WARNING]
>
> **Nothing about pricing changes.** The breakdown adds visibility and transparency only. How bandwidth is measured and billed is the same as before.

## Where to find it

The same breakdown object is available in two places. Only the field name differs.

| Where | Billed total | Breakdown object |
| - | - | - |
| Session Logs API | `bandwidth` (a number, unchanged) | `bandwidth_breakdown` |
| CDP command `Browser.getBandwidth` | Not returned separately. Use `bandwidth.total` | `bandwidth` |

**1. Session Logs API, after the session ends.** Each session record keeps its existing `bandwidth` field, the billed total as a number. The breakdown is in a new field, `bandwidth_breakdown`. `bandwidth` always equals `bandwidth_breakdown.total`.

```bash theme={null}
curl "https://api.brightdata.com/browser_sessions/{session_id}" \
  -H "Authorization: Bearer $API_KEY"
```

```json theme={null}
{
  "session": {
    "session_id": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
    "api_name": "scraping_browser1",
    "status": "finished",
    "target_url": "https://www.example.com",
    "bandwidth": 9685211,
    "bandwidth_breakdown": {
      "total": 9685211,
      "payload_bytes": 9667573,
      "overhead_bytes": 17638,
      "...": "..."
    },
    "error": null
  }
}
```

`GET /browser_sessions` returns the same two fields for each session in the list.

**2. From inside your session, with a CDP command.** `Browser.getBandwidth` returns only the breakdown object, under the key `bandwidth`. It has the same fields as `bandwidth_breakdown` in the API. There is no separate number; the billed total is `bandwidth.total`. Call it at any point to read the bandwidth used so far, for example right before you close the browser.

```js theme={null}
const client = await page.createCDPSession();
const { bandwidth } = await client.send('Browser.getBandwidth');
console.log(bandwidth.total);
```

This works like other Browser API custom CDP commands, such as `Browser.getSessionId`. Values read mid-session cover traffic up to that moment; the final values are in the Session Logs API.

## The bandwidth breakdown object

This is `bandwidth_breakdown` in the Session Logs API and `bandwidth` in the CDP command. The object has two groups of fields. **Billing fields** describe what you pay for. **Reconciliation fields** describe traffic the way Chrome sees it, so you can match your own CDP numbers. Reconciliation fields are never charged on top of `total`.

All values are in bytes.

```json theme={null}
{
  "total": 9685211,
  "payload_bytes": 9667573,
  "overhead_bytes": 17638,
  "session_bytes": 2973,
  "client_hidden_bytes": 14201,
  "before_session": 9567573,
  "session_breakdown": {
    "document": 2520, "script": 453, "stylesheet": 0, "image": 0,
    "font": 0, "media": 0, "other": 0
  },
  "before_session_breakdown": {
    "document": 412000, "script": 3105573, "stylesheet": 150000, "image": 5600000,
    "font": 300000, "media": 0, "other": 0
  }
}
```

The tables below explain each field.

### Billing fields

| Field | Billed? | What it is |
| - | - | - |
| `total` | **Yes. This is the only billed number.** | All encrypted bytes sent and received over the proxy tunnel for your session, measured at the network socket. |
| `payload_bytes` | Part of `total` | The encrypted content of requests and responses (TLS application data). Already compressed, the way it travels on the network. |
| `overhead_bytes` | Part of `total` | `total` minus `payload_bytes`. TLS handshakes, the small header on every encrypted record, and other protocol messages. Never negative. |

`total` always equals `payload_bytes + overhead_bytes` exactly.

### Reconciliation fields

| Field | Billed? | What it is |
| - | - | - |
| `session_bytes` | No, not on top of `total` | Traffic during your session, counted from Chrome's CDP `Network.*` events. This is the number to compare with your own CDP sum. |
| `client_hidden_bytes` | No, not on top of `total` | Bytes Chrome received during your session for requests it never announced with `Network.requestWillBeSent`. Your script cannot see these as requests, but they are real network traffic. Often `0`. |
| `before_session` | No, not on top of `total` | Traffic used to prepare the browser before your session started, for example a homepage visit, as part of unblocking the site. Counted the same way as `session_bytes`. `0` when no warm-up ran. |
| `session_breakdown` | No | `session_bytes` split by resource type. |
| `before_session_breakdown` | No | `before_session` split by resource type. |

Resource types in both breakdowns: `document`, `script`, `stylesheet`, `image`, `font`, `media`, `other`.

`other` includes every resource type that does not have its own field: API calls (XHR and Fetch), downloads, WebSocket and EventSource connections, web app manifests, text tracks, prefetch and preflight requests, pings, CSP violation reports and signed exchanges. It also includes resources Chrome reports as "Other" or with no type, and any new types Chrome adds in the future.

### How `session_bytes` is counted

`session_bytes` follows this definition, which matches what you get from standard CDP events:

* **Request:** the request line, the request headers, and any request body (POST data).
* **Response:** the status line, the response headers, and the response body as transferred (`encodedDataLength`). A gzip or Brotli response counts at its compressed size.
* **Headers are counted uncompressed**, as plain `Name: value` text. On the network, HTTP/2 compresses them, so this part is slightly larger than the wire bytes.

## Why your number differs from what we bill

Your script and our meter count at two different points. Your script hears about traffic through Chrome's CDP events, inside the browser. Our meter counts the encrypted bytes on the network connection between the browser and the proxy, after Chrome has done its work. Some traffic only exists at the second point, and some is counted differently at each.

```mermaid theme={null}
%%{init: {"theme": "base", "themeVariables": {"fontFamily": "Inter, ui-sans-serif, system-ui, sans-serif", "fontSize": "15px", "lineColor": "#818CF8", "clusterBkg": "#FAFAFF", "clusterBorder": "#C4B5FD", "titleColor": "#4C1D95", "edgeLabelBackground": "#FFFFFF"}, "flowchart": {"curve": "basis", "nodeSpacing": 24, "rankSpacing": 80, "padding": 18, "subGraphTitleMargin": {"top": 8, "bottom": 14}}}}%%
flowchart LR
  subgraph Chrome["Inside Chrome (Browser API)"]
    direction TB
    A("Page requests in your session<br/><code>session_bytes</code>"):::bwSeen
    B("Warm-up before your session starts<br/><code>before_session</code>"):::bwUnseen
    C("Requests Chrome does not announce<br/><code>client_hidden_bytes</code>"):::bwUnseen
    D("Small remaining difference (residual)<br/><code>not in any field</code>"):::bwUnseen
  end
  S("Your script<br/>CDP Network.* events"):::bwScript
  M("Proxy tunnel meter<br/><b>Billed: total</b><br/>payload_bytes + overhead_bytes"):::bwBilled
  A -- "CDP events" --> S
  A ==> M
  B ==> M
  C ==> M
  D ==> M

  classDef bwSeen fill:#ECFDF5,stroke:#10B981,stroke-width:2px,color:#064E3B
  classDef bwUnseen fill:#F5F3FF,stroke:#A78BFA,stroke-width:1.5px,color:#2E1065
  classDef bwScript fill:#ECFEFF,stroke:#06B6D4,stroke-width:2px,color:#164E63
  classDef bwBilled fill:#4F46E5,stroke:#312E81,stroke-width:2px,color:#FFFFFF
  style Chrome fill:#FAFAFF,stroke:#C4B5FD,stroke-width:1.5px,stroke-dasharray:6 4,color:#4C1D95
  linkStyle 0 stroke:#06B6D4,stroke-width:2px
  linkStyle 1,2,3,4 stroke:#6366F1,stroke-width:2.5px
```

All four kinds of traffic cross the tunnel and are billed in `total`. Only the first one reaches your script as CDP events.

### Where the difference comes from

| Source | Effect on your CDP sum vs. `total` | Where it shows in the breakdown |
| - | - | - |
| **Warm-up before your session.** For some sites, the Browser API loads another page first (often the homepage) so the target page loads successfully. This is part of how we unblock sites with a high success rate. It runs before your target page loads. Its size can vary a lot from one session to the next. | Your sum is lower | `before_session`, by resource type in `before_session_breakdown` |
| **Encryption overhead.** TLS handshakes for each new connection, plus a small header on every encrypted record. | Your sum is lower | `overhead_bytes` |
| **Requests Chrome does not announce.** Chrome sometimes receives data for a request without sending `Network.requestWillBeSent`, for example some redirects, favicons and service-worker fetches. | Your sum is lower | `client_hidden_bytes` |
| **Small remaining difference (residual).** A few possible reasons for the residual are explained below. | Usually small when positive; can be several percent negative | Not a separate field. Calculate it as shown below |

### What adds up

* **Exact:** `total = payload_bytes + overhead_bytes`. Always.
* **Exact:** the values in `session_breakdown` add up to `session_bytes`, and the values in `before_session_breakdown` add up to `before_session`.
* **Close, within a small residual:** `payload_bytes ≈ session_bytes + before_session + client_hidden_bytes`.

Always include `before_session` when you compare. Warm-up can be large and can change a lot between sessions, but it is fully reported in `before_session`.

### The small remaining difference (residual)

Calculate the residual like this:

```text theme={null}
residual = payload_bytes − session_bytes − before_session − client_hidden_bytes
```

With the example object above:

| | Bytes |
| - | -: |
| `payload_bytes` | 9,667,573 |
| − `session_bytes` | − 2,973 |
| − `before_session` | − 9,567,573 |
| − `client_hidden_bytes` | − 14,201 |
| **= residual** | **82,826 (about 0.9%)** |

**What to expect:**

* **Positive residual** (`payload_bytes` is higher than the fields explain): small in most cases.
* **Negative residual** (your CDP-based fields add up to more than `payload_bytes`): can reach about 10% on pages with many headers. This comes from header compression, and you are billed the smaller, compressed size.

**On small pages, compare in bytes, not percent.** A few KB of residual per session is normal. On a page of only a few dozen KB, that can look like a large percentage.

**What it is:** real network traffic that is billed in `total`. It is not a metering error: `total = payload_bytes + overhead_bytes` always holds. A few possible reasons for the residual:

* **Header compression.** HTTP/2 compresses headers on the network, while CDP reports them uncompressed. This makes your CDP sum *higher*, which is why the residual can be negative.
* **Encryption framing and connection messages.** TLS record headers and HTTP/2 control messages (SETTINGS, PING, WINDOW\_UPDATE) belong to the connection, not to any one request.
* **Streaming, long-lived or cancelled requests.** Chrome's reported size for these can differ from what actually crossed the network.
* **Request bodies.** Uploaded data is measured differently by CDP and on the wire.
* **Retransmitted encrypted data**, and TLS handshakes inside the tunnel.

These are possible reasons. We do not measure the exact split for each session.

**If the residual is positive and much larger than a few percent**, contact support with the `session_id`. We investigate those cases individually.

### Common counting mistakes on the customer side

These mistakes make a CDP sum too low, and the residual look larger than it really is:

* **Using decoded sizes.** `Network.dataReceived.dataLength` counts the body after decompression, which is not what travels on the network. Use `encodedDataLength` from `Network.loadingFinished` instead.
* **Counting responses only.** Request headers and POST bodies are traffic too.
* **Attaching the listener late.** Requests sent before `Network.enable` runs are not reported to you.
* **Watching one page only.** Iframes, popups and service workers can run in separate targets with their own events.
* **Skipping failed requests.** A request that is cancelled or fails part way still transferred bytes. Check `Network.loadingFailed` too.

## Reconcile your own numbers

Follow these steps to check a session yourself.

1. **Count with the same definition we use.** Attach your listener before the first navigation and sum request line + request headers + POST data + status line + response headers + `encodedDataLength`. The example below does this.
2. **Compare your sum with `session_bytes`.** They should be close. Small differences come from timing and from requests in other targets.
3. **Account for the rest of `payload_bytes`.** Add `before_session` (warm-up) and `client_hidden_bytes` (requests Chrome did not announce) to `session_bytes`. The result should be close to `payload_bytes`: usually only slightly below it, and up to about 10% above it on pages with many headers. Then add `overhead_bytes` to get `total`.
4. **Positive residual much larger than a few percent ?** Contact support with the `session_id`. We can look at the session in detail.

### Example: count bytes with Puppeteer

This script counts your session's traffic from CDP events, the way your own code would, then prints it next to the bandwidth breakdown from `Browser.getBandwidth` so you can compare the two.

```js theme={null}
'use strict';
const puppeteer = require('puppeteer-core');
const {
    // Replace with your Browser API zone credentials
    AUTH = 'USER:PASS',
    TARGET_URL = 'https://en.wikipedia.org/wiki/Web_scraping',
} = process.env;

const header_bytes = headers=>{
    let size = 0;
    for (const [name, value] of Object.entries(headers||{}))
        size += Buffer.byteLength(`${name}: ${value}\r\n`);
    return size+2;
};

const request_line_bytes = request=>
    Buffer.byteLength(`${request.method} ${request.url} HTTP/1.1\r\n`);

const status_line_bytes = response=>
    Buffer.byteLength(
        `HTTP/1.1 ${response.status} ${response.statusText||''}\r\n`);

const request_body_bytes = request=>{
    if (!request.hasPostData)
        return 0;
    if (Array.isArray(request.postDataEntries))
    {
        return request.postDataEntries.reduce(
            (n, e)=>n+Buffer.from(e.bytes||'', 'base64').length, 0);
    }
    if (request.postData)
        return Buffer.byteLength(request.postData);
    return 0;
};

async function measure(url = TARGET_URL) {
    if (AUTH == 'USER:PASS') {
        throw new Error(`Provide Browser API credentials in AUTH`
            + ` environment variable or update the script.`);
    }
    console.log(`Connecting to Browser...`);
    const browserWSEndpoint = `wss://${AUTH}@brd.superproxy.io:9222`;
    const browser = await puppeteer.connect({browserWSEndpoint});
    try {
        const page = await browser.newPage();
        const client = await page.createCDPSession();

        let sent = 0;
        let received = 0;
        const cached = new Set();
        const req_head_extra = new Set(); // ids with real request headers seen
        const res_head_extra = new Set(); // ids with real response headers seen
        const data_seen = {}; // id -> summed dataReceived bytes
        const finished = new Set(); // ids with a loadingFinished/Failed

        // real on-wire request headers (cookies + browser-added). Prefer this
        // over the cooked requestWillBeSent.headers; may arrive before OR after it.
        client.on('Network.requestWillBeSentExtraInfo', p=>{
            req_head_extra.add(p.requestId);
            sent += header_bytes(p.headers);
        });

        client.on('Network.requestWillBeSent', p=>{
            // a redirect hop folds the previous response into this event
            if (p.redirectResponse && !cached.has(p.requestId))
            {
                received += status_line_bytes(p.redirectResponse)
                    +header_bytes(p.redirectResponse.headers);
            }
            sent += request_line_bytes(p.request)+request_body_bytes(p.request);
            if (!req_head_extra.has(p.requestId))
                sent += header_bytes(p.request.headers); // fallback: cooked
        });

        // raw wire response headers (headersText is the exact blob)
        client.on('Network.responseReceivedExtraInfo', p=>{
            if (cached.has(p.requestId))
                return;
            res_head_extra.add(p.requestId);
            received += p.headersText
                ? Buffer.byteLength(p.headersText) : header_bytes(p.headers);
        });

        client.on('Network.responseReceived', p=>{
            const r = p.response;
            if (r.fromDiskCache || r.fromServiceWorker || r.fromPrefetchCache)
            {
                cached.add(p.requestId);
                return;
            }
            received += status_line_bytes(r);
            if (!res_head_extra.has(p.requestId))
                received += header_bytes(r.headers); // fallback: parsed headers
        });

        // track streamed chunks so an aborted/never-finished body still counts
        client.on('Network.dataReceived', p=>{
            data_seen[p.requestId] =
                (data_seen[p.requestId]||0)+(p.encodedDataLength||0);
        });

        // encodedDataLength = compressed on-wire body size (0 for cache hits)
        client.on('Network.loadingFinished', p=>{
            finished.add(p.requestId);
            if (!cached.has(p.requestId))
                received += p.encodedDataLength||0;
        });

        client.on('Network.loadingFailed', p=>{
            finished.add(p.requestId);
            if (!cached.has(p.requestId))
                received += p.encodedDataLength||0;
        });

        client.on('Network.webSocketFrameSent', p=>{
            sent += p.response?.payloadData
                ? Buffer.byteLength(p.response.payloadData) : 0;
        });

        client.on('Network.webSocketFrameReceived', p=>{
            received += p.response?.payloadData
                ? Buffer.byteLength(p.response.payloadData) : 0;
        });

        await client.send('Network.enable'); // must be before the first navigation

        console.log(`Navigating to ${url}...`);
        await page.goto(url, {waitUntil: 'networkidle2'});

        // add bodies that streamed but never got a loadingFinished/Failed
        for (const [id, bytes] of Object.entries(data_seen))
        {
            if (!finished.has(id) && !cached.has(id))
                received += bytes;
        }

        const my_bytes = sent+received;
        const {bandwidth} = await client.send('Browser.getBandwidth');

        console.log({
            my_sent_bytes: sent,
            my_received_bytes: received, // 0 when page served from warm-up cache
            my_bytes,
            ...bandwidth,
        });
    } finally {
        await browser.close();
    }
}

measure().catch(err=>{
    console.error(err.stack || err);
    process.exit(1);
});
```

This gives a close match, not an exact one. For billing, `total` is always the number that counts.

## Monitor and control your bandwidth

### Track cost per site

The breakdown is per session, so you can build per-site monitoring on top of it:

1. After each session, read `bandwidth_breakdown` from the Session Logs API (or `bandwidth` from the CDP command before you close the browser).
2. Store `total`, `before_session` and `session_bytes` with the target domain.
3. Alert when a domain's typical `total` per session rises sharply. A jump driven by `before_session` means a warm-up step started running for that site. A jump in `session_breakdown` shows which resource type grew, for example `image` or `media`.

### Set a spending limit

In the zone's **Advanced settings**, set a consumption limit. When the zone reaches it, new requests stop. This protects you from unexpected spikes across all sites in the zone.

### Reduce in-session bandwidth

You control what loads during your session. Block resource types you do not need, such as images, media and fonts. See [Optimizing bandwidth usage with Browser API](https://docs.brightdata.com/products/scraping-browser/code-examples.md#optimizing-bandwidth-usage-with-browser-api). Use `session_breakdown` to see which types use the most. Be careful with traffic in `other`: it includes the XHR and Fetch calls that usually load the data you need, so blocking it often breaks the page.

### About warm-up (`before_session`)

Warm-up is one of the ways the Browser API unblocks sites with a high success rate. It runs automatically, only for sites where it is needed to load the target page successfully. It is decided per site and can change over time as sites change their protection. You cannot see it in CDP and you cannot turn it off from your script. Disabling CAPTCHA solving does not turn it off either.

If warm-up starts on a site that did not have it before, or `before_session` grows over time, that can be expected: warm-up adapts as sites change their protection.
