DEV Community

Cover image for The Bug Was in My Benchmark: Attaching a Debugger to Chromium Throttles fetch() Uploads 20x
Coffer
Coffer

Posted on

The Bug Was in My Benchmark: Attaching a Debugger to Chromium Throttles fetch() Uploads 20x

We chased an upload running 8x below what the link could do, shipped three fixes that turned out to fix nothing, and found a real one that turned out to be far smaller than it looked — because the thing measuring the bug was also causing it.

The crime

I work on Coffer, a file store that encrypts everything in the browser before it uploads. Files go up in chunks: at the time of this investigation, a 4.8 GB file became about 300 sequential PUTs of 16 MiB, each one AES-256-GCM encrypted client-side before it leaves the tab. The server is ASP.NET Core, sitting on the same gigabit switch as the machine doing the testing, with the CDN entirely out of the path. Exact versions for everything below are in an appendix at the end.

Direction Throughput
Download — one streamed GET ~74 MB/s (~980 Mbps)
Upload — sequential chunk PUTs ~14.6 MB/s (~117 Mbps)

Same machine, same wire, same server process. Downloads essentially saturated the gigabit link; uploads used an eighth of it. Something was eating most of our upload time and leaving no fingerprint on any component we could name — which is the story of a day spent disbelieving three answers we'd already shipped, and a fourth one that turned out to be right for the wrong reason.

Three suspects, three alibis

Too many round trips. ~300 requests instead of 1 — so double the chunk size, 16 MiB → 32 MiB, and halve the count. Shipped it, re-measured the identical file: ~350s, against a ~328s baseline. Slightly worse, not better. A 100 MB file dropping from ~6 chunks to 3 confirmed the change had actually taken effect; it just didn't move the needle. Chunk size wasn't the cost.

Encryption and transfer never overlapping. The upload loop pulled each chunk from an async generator only after the previous PUT resolved, so CPU and network alternated instead of running together — and Windows' network graph showed a clean sawtooth to match: burst, trough, burst. We pipelined it (encrypt N+1 while PUT(N) is in flight) and measured ~305s, a 13% gain. Wrote it up as a win.

That number didn't survive what came next. Direct component measurement — disk read, AES-GCM encrypt, both against a real disk-backed file read at 8 distinct offsets so the OS page cache couldn't flatter it — put the client's entire per-chunk workload at 70ms out of a ~3000ms chunk: a hard ceiling of ~2% on anything overlapping could buy. We'd reported 13% from a single run per configuration on a ~5-minute test, where run-to-run spread is comfortably larger than the effect. The pipelining code stayed (harmless, correct in principle); the number was noise wearing a result's clothing, and got retracted.

(Hold on to that ~3000ms. It is going to turn out not to mean what it says either — and unlike the retraction you just read, I did not catch that one until publication was already being planned.)

The sawtooth wasn't encryption gaps either — a 70ms dip inside a 3000ms chunk is a ~2% notch, invisible at that scale, categorically not a trough falling to near zero. We'd taken a picture, matched it to a theory we already liked, and promoted a correlation to a mechanism. (What actually draws that graph is still the one genuinely open question from this whole investigation.)

No concurrency. Our server enforces strictly sequential chunk indices, so testing this for real would have meant reworking the domain, service and storage layers before writing a single benchmark. Instead: separate upload sessions each restart their chunk index at 0, so parallel sessions don't violate the sequential invariant at all — meaning aggregate throughput could be measured with zero server changes.

Parallel streams 1 2 4 8
Aggregate throughput 143 Mbps 137 Mbps 134 Mbps 135 Mbps

Eight concurrent uploads moved exactly as much total data as one. Not "less than 8x" — the same. A multi-day rework, cancelled by an afternoon of measurement, and easily the highest-value negative result of the day. Whatever the ceiling was, it was global, not per-request.

Three suspects, three alibis, and still no idea where the time went.

The lineup, and the thing nobody had varied

So we stopped varying our code and started varying the thing doing the sending. Same 16 MiB PUT, same LAN-direct endpoint, CDN out of the picture — only the client changes:

Client Protocol Throughput
.NET HttpClient HTTP/1.1 626–725 Mbps
.NET HttpClient HTTP/2 488–717 Mbps
Windows curl (Schannel) HTTP/1.1 435–453 Mbps
Chromium fetch() HTTP/2 131–143 Mbps

The server handed ~700 Mbps to a .NET client over both protocols and ~140 to a browser. That also quietly executed a suspect I'd been nursing — Kestrel's HTTP/2 flow-control window defaults. The .NET HTTP/2 row falsifies it outright: same server, same untouched defaults, five times the throughput.

The obvious read was "the browser's network stack is the ceiling, there's no client-side lever left, go measure your WAN uplink." It's a very comfortable conclusion — it's someone else's bug, and it has a table behind it. It was also wrong, and the correction was one sentence: every number above was LAN-local, across a gigabit switch, CDN out of the path. The WAN cannot explain a LAN measurement. That wasn't an answer to the question; it was a change of subject dressed as a finding.

So: what had never actually been varied, across a full day of varying chunk size, encryption schedule, stream count, client and protocol? The kind of object handed to fetch() as the request body. It had been a Uint8Array since the line was first written, because obviously — it's a buffer of bytes, that's what it is. Nobody audits the obvious thing.

Same 32 MiB chunk, same endpoint, same request, runs interleaved to rule out drift or thermal effects:

Body handed to fetch() Throughput
Uint8Array (what we'd been sending) 134–144 Mbps
ArrayBuffer 122–141 Mbps
Blob 678–740 Mbps
ReadableStream + duplex: 'half' 683–705 Mbps

Wrap the identical bytes in a Blob and the number lands within noise of native .NET against the same endpoint. One-line fix, shipped — with one production detail that matters: build the Blob outside any retry closure. Blobs are re-readable, so a 429 retry re-sends the same object safely, whereas a ReadableStream body scores just as well on throughput and is consumed on the first attempt. Fast until the first retry, then broken.

At the time, this read as the ending: a 5x browser-only bottleneck, invisible to every non-browser client, fixed by changing one word. It shipped. It is also not what was actually happening.

The bug was in the thing doing the measuring

Every number in the two tables above — the client comparison, the body-type sweep, the original ~140 Mbps browser baseline — was collected with a debugger attached to the browser: first an AI coding agent driving Chromium through the Playwright MCP servers, later a standalone Playwright/Puppeteer harness built to isolate the effect further. Re-running the body-type sweep hand-driven, in an ordinary Edge window with nothing attached, LAN-direct against the same server:

Body Median Range
Uint8Array 609 Mbps 446–651
Blob 802 Mbps 588–835

Uint8Array alone was 609 Mbps — over 4x what every automated run had shown, and most of the way to what Blob was supposedly worth. The "gigabit link, using an eighth of it" premise was simply false in an ordinary browser.

Two falsification passes before believing the instrument itself. Was it a browser version fix? No: Chromium 149.0.7827.55 and 151.0.7922.34 (Playwright 1.61.0 and 1.62.1), same harness, same endpoint, measured identically under automation — 125 and 116 Mbps. Was it DevTools? No: a controlled A/B/C on Edge — Network panel recording, Console only, and DevTools fully closed with results painted into the page so no console was needed to read them — came back at 609, 633 and 656 Mbps. Three configurations agreeing within noise is a controlled null, not an eyeballed resemblance.

That left one variable nobody had isolated on purpose: automation itself. A minimal test settled it — same browser process, same page, same sink, same flags, the only variable whether a CDP debugger is attached while the bytes move.

This test runs over loopback, against a small Node sink — no network, no TLS, no real server. That is deliberate: if the ceiling still appears with the network deleted entirely, the network cannot be what causes it. It also means the numbers below are memory-to-memory and are not comparable with the gigabit-LAN figures above — multi-Gbps here is expected, not a typo.

Phase Uint8Array Blob Ratio
CDP attached 113 (108–116) 2835 (2412–3226) 25.1x
CDP detached 2351 (2182–2799) 3125 (2616–3330) 1.33x

Detaching mid-session — same tab, same in-flight code — took the typed-array path from 113 to 2351 Mbps. More than 20x, from nothing but letting go of the debugger.

The effect is specific to typed-array request bodies — but read the Blob column carefully, because it is doing less work than it appears to. This sink's own ceiling is ~3 Gbps, and both Blob figures sit on it. So the 1.33x in the detached row is a lower bound on the remaining gap, measured with its numerator saturated, not a clean measurement of one. The load-bearing column is the typed-array one, where 113 Mbps is nowhere near any ceiling in this rig.

It is not a Playwright bug. Puppeteer (puppeteer-core 25.4.0) reproduces it at 113 Mbps against Playwright's 116–125, so it belongs to CDP/Chromium, and any upstream report goes there rather than to a test framework.

Every earlier result falls out of that one fact. The original ~140 Mbps: measured through the Playwright MCP servers, debugger attached. Chromium 149 and 151 measuring identically: both had a debugger attached — the version was never the variable. Hand-driven Edge and Firefox at 609–656: no debugger, because a human doesn't attach one to browse. The "browser is 5x slower than .NET" finding: an artifact of the one client in the comparison that happened to be under a debugger. And DevTools making no difference means its frontend evidently doesn't take the same path an external CDP client does — noted, not explained.

No mechanism is claimed. Three were proposed in one day and all three were wrong (browser version, DevTools, and an earlier loopback "ceiling" that turned out to be a slow toy sink coincidentally landing near the real figure). What's established is the conditional — debugger attached, typed-array upload bodies crawl. Why, is for someone who can read the Chromium source.

Reproduce it

Self-contained — the sink, the page and the attach/detach test in one file. npm i puppeteer-core, point CHROME_EXE at a Chrome or Edge binary, run it:

// Does an attached CDP debugger throttle typed-array fetch() upload bodies?
// npm i puppeteer-core  &&  CHROME_EXE=/path/to/chrome node repro.mjs
import http from 'node:http';
import puppeteer from 'puppeteer-core';

const PORT = 8099, MIB = 32, REPS = 6;

const PAGE = `<!doctype html><meta charset="utf-8"><pre id="out">ready</pre><script>
window.bodyTypeTest = async (sizeMiB, reps) => {
  const bytes = sizeMiB * 1024 * 1024;
  const chunk = new Uint8Array(bytes);
  // randomise a slice per MiB - content doesn't matter, just don't ship compressible zeros
  for (let o = 0; o < bytes; o += 1 << 20)
    crypto.getRandomValues(chunk.subarray(o, Math.min(o + 4096, bytes)));

  const makeBody = k => k === 'Uint8Array' ? chunk : new Blob([chunk]);
  const results = { Uint8Array: [], Blob: [] };

  // interleaved, so thermal drift can't favour whichever kind ran first
  for (let r = 0; r < reps; r++) {
    for (const kind of ['Uint8Array', 'Blob']) {
      const t0 = performance.now();
      await (await fetch('/sink', { method: 'PUT', body: makeBody(kind) })).json();
      const ms = performance.now() - t0;
      results[kind].push(Math.round(bytes * 8 / (ms / 1000) / 1e6));  // Mbps
      out.textContent = JSON.stringify(results);
    }
  }
  return results;
};
</script>`;

// The sink replies only once it has the whole body, so the in-page timing covers the upload.
const server = http.createServer((req, res) => {
  if (req.method === 'PUT') {
    let n = 0;
    req.on('data', c => { n += c.length; });
    req.on('end', () => res.end(JSON.stringify({ received: n })));
    return;
  }
  res.writeHead(200, { 'content-type': 'text/html' });
  res.end(PAGE);
}).listen(PORT, '127.0.0.1');

const med = a => [...a].sort((x, y) => x - y)[a.length >> 1];
const summarise = r => r && { Uint8Array: med(r.Uint8Array), Blob: med(r.Blob) };

const browser = await puppeteer.launch({
  executablePath: process.env.CHROME_EXE,
  headless: false
});
const ws = browser.wsEndpoint();
const page = (await browser.pages())[0];
await page.goto(`http://127.0.0.1:${PORT}/`);

// Phase A - debugger attached the whole time.
const attached = await page.evaluate((m, r) => window.bodyTypeTest(m, r), MIB, REPS);

// Phase B - same browser, nothing attached. Schedule the run for 15s out, disconnect
// (which leaves the browser alive), let it happen unobserved, reconnect and read it back.
await page.evaluate((m, r) => {
  localStorage.removeItem('res');
  setTimeout(async () => {
    localStorage.setItem('res', JSON.stringify(await window.bodyTypeTest(m, r)));
  }, 15000);
}, MIB, REPS);

await browser.disconnect();
await new Promise(r => setTimeout(r, 120000));

const again = await puppeteer.connect({ browserWSEndpoint: ws });
const detached = await (await again.pages())[0]
  .evaluate(() => JSON.parse(localStorage.getItem('res') || 'null'));

console.log({ attached: summarise(attached), detached: summarise(detached) });
await again.close();
server.close();
Enter fullscreen mode Exit fullscreen mode

Two runs of exactly the file above, unmodified, on Edge 151 while preparing this post:

{ attached: { Uint8Array:  89, Blob: 1797 }, detached: { Uint8Array: 1791, Blob: 2279 } }
{ attached: { Uint8Array: 104, Blob: 1863 }, detached: { Uint8Array: 1748, Blob: 2532 } }
Enter fullscreen mode Exit fullscreen mode

20.1x and 16.8x on the typed-array arm. Worth saying plainly: the multiplier moves between runs. Across every run I have, including the table above, it lands somewhere between about 17x and 21x — so read the "20x" in the title as a round number, not a constant. What doesn't move is the direction and the order of magnitude: the attached arm is always around 100 Mbps and the detached arm is always well past 1500, on the same machine, minutes apart.

About three minutes per run, nearly all of it waiting out the detached measurement. Two things to know before you read your own output: this sink tops out around 3 Gbps on a typical desktop, so an arm sitting near that is limited by the harness rather than the browser — which is exactly what happens to the Blob column. And run a positive control first: a rig that can't reproduce a known effect can't be trusted to rule one out.

What's actually true

Blob is a genuine, free, one-line win in Chromium: ~1.3x — 609→802 Mbps, measured by hand against the real server and reproduced. The detached loopback harness lands at 1.33x too, but that agreement is worth less than it looks, since its Blob arm is pinned at the sink's own ceiling; the hand-driven LAN number is the one carrying this claim.

It's also the only body type in the sweep that is both fast and functional in every engine: hand Firefox a ReadableStream body and it doesn't throw, it stringifies the object and uploads the literal 23-byte text [object ReadableStream] with a 200 response. Silent upload corruption. In Firefox, Blob vs Uint8Array makes no measurable difference at all (655 vs 656).

The shipped fix is still correct. It is not the finding. Not 5x. Not even close to the biggest number in this post. The biggest number here is what a debugger did to the measurement while nobody was watching it happen.

And now that ~3000ms chunk from the pipelining section comes due, because it was measured under the same debugger — which makes the per-chunk time about 9x longer than the same chunk takes unobserved. Redone at each throughput that actually occurs, against the same 70ms of client work:

Per-chunk PUT Ceiling on what pipelining could save
~3000ms — what we originally measured, debugger attached ~2%
~4.5s — a real user over the CDN tunnel, WAN-capped 37–97 Mbps ~1.5%
~335ms — gigabit LAN, Blob body, nothing attached ~17%

So "~2%" was right about real users and wrong about why: they are WAN-bound, which the original arithmetic never established. On a genuinely fast link, overlapping encryption with transfer is worth something — not the 13% we retracted, but not nothing either. The retraction stands (that 13% was single-run variance regardless) and the code stays. What changes is that I no longer get to call it pointless; only that it does nothing for the link our users actually have.

Worth adding, since the obvious guess is wrong: none of this is about JavaScript being single-threaded. crypto.subtle and fetch() both do their work off the main thread, so the overlap is real. The original loop was serial because an async generator is pull-based — it only started encrypting chunk N+1 once the await on chunk N's PUT had resolved. A structural problem wearing a performance problem's clothes.

The actual lesson

None of the three suspects was bad reasoning. Chunk count, overlap and concurrency are the correct first things to check on a slow upload, and ruling them out by measurement rather than by shipping-and-hoping is exactly right. Each theory explained the evidence available at the time, and one of them even predicted the previous null result — which is supposed to be the sign you're onto something.

The failure was narrower and easier to miss: every measurement across a full day shared one silent, unvaried input — the tool doing the measuring — and it never occurred to anyone to put that tool in the suspect lineup until a human ran the same test by hand and got a different universe. Twice this investigation produced a number that was really a property of the harness. The second time, it was the headline.

Two habits are the only reason it broke at all: a null result got chased instead of shrugged off (350 vs 328 looked like nothing), and a confident conclusion got challenged instead of accepted ("it's the browser stack, go measure your WAN" was wrong in the specific way that feels like rigour — plenty of evidence, aimed at the wrong question).

So: if you benchmark browser fetch() uploads — a CI perf gate, a load test, anything driven by Playwright, Puppeteer or raw CDP — and the numbers look like they're leaving most of a fast link on the table, check whether that link is actually your code before you believe it. The fix that took ten minutes was detaching the debugger, not touching the app.

Versions

Everything above is one machine, one LAN, over two days. Pin your own setup against it:

Client OS Windows 11 Pro, build 26200
Hand-driven browsers Edge 151.0.4129.59, Firefox 153.0.1 — ordinary installs, nothing attached
Automated Chromium 151.0.7922.34 (Playwright 1.62.1) and 149.0.7827.55 (Playwright 1.61.0)
CDP attach/detach test puppeteer-core 25.4.0, driving Chromium 151.0.7922.34
Loopback sink Node v24.5.0, plain HTTP/1.1, no dependencies
Server ASP.NET Core net10.0 (SDK 10.0.302), Kestrel, HTTP/2 over TLS, Linux
Link gigabit switch, LAN-direct, CDN out of the path

One trap worth naming: Edge 151 and Chromium 151 are not the same binary. Edge carries its own version line, so the "151" in the hand-driven rows and the "151" in the Playwright rows are different builds that happen to share a major number. That mattered here — the hand-driven and automated numbers differ by 5x, and it would be easy to misread that as a build difference rather than the debugger.


The one-line Blob change is live in Coffer — though, as the numbers above say, it is worth about 30% in one engine rather than the 5x we thought we'd found.

Top comments (0)