<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Coffer</title>
    <description>The latest articles on DEV Community by Coffer (@coffer).</description>
    <link>https://dev.to/coffer</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4064651%2Fe9376236-789f-4fea-8294-f3c38a56d3f6.png</url>
      <title>DEV Community: Coffer</title>
      <link>https://dev.to/coffer</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/coffer"/>
    <language>en</language>
    <item>
      <title>The Bug Was in My Benchmark: Attaching a Debugger to Chromium Throttles fetch() Uploads 20x</title>
      <dc:creator>Coffer</dc:creator>
      <pubDate>Thu, 06 Aug 2026 11:07:41 +0000</pubDate>
      <link>https://dev.to/coffer/the-bug-was-in-my-benchmark-attaching-a-debugger-to-chromium-throttles-fetch-uploads-20x-38db</link>
      <guid>https://dev.to/coffer/the-bug-was-in-my-benchmark-attaching-a-debugger-to-chromium-throttles-fetch-uploads-20x-38db</guid>
      <description>&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The crime
&lt;/h2&gt;

&lt;p&gt;I work on &lt;a href="https://getcoffer.org" rel="noopener noreferrer"&gt;Coffer&lt;/a&gt;, 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.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Direction&lt;/th&gt;
&lt;th&gt;Throughput&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Download&lt;/strong&gt; — one streamed GET&lt;/td&gt;
&lt;td&gt;~74 MB/s (&lt;strong&gt;~980 Mbps&lt;/strong&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Upload&lt;/strong&gt; — sequential chunk PUTs&lt;/td&gt;
&lt;td&gt;~14.6 MB/s (&lt;strong&gt;~117 Mbps&lt;/strong&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three suspects, three alibis
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Too many round trips.&lt;/strong&gt; ~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: &lt;strong&gt;~350s, against a ~328s baseline.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Encryption and transfer never overlapping.&lt;/strong&gt; 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 &lt;strong&gt;~305s, a 13% gain.&lt;/strong&gt; Wrote it up as a win.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;entire&lt;/em&gt; per-chunk workload at &lt;strong&gt;70ms out of a ~3000ms chunk: a hard ceiling of ~2% on anything overlapping could buy.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(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.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;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. &lt;em&gt;(What actually draws that graph is still the one genuinely open question from this whole investigation.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No concurrency.&lt;/strong&gt; 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 &lt;strong&gt;zero server changes.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parallel streams&lt;/th&gt;
&lt;th&gt;1&lt;/th&gt;
&lt;th&gt;2&lt;/th&gt;
&lt;th&gt;4&lt;/th&gt;
&lt;th&gt;8&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Aggregate throughput&lt;/td&gt;
&lt;td&gt;143 Mbps&lt;/td&gt;
&lt;td&gt;137 Mbps&lt;/td&gt;
&lt;td&gt;134 Mbps&lt;/td&gt;
&lt;td&gt;135 Mbps&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Eight concurrent uploads moved exactly as much total data as one. Not "less than 8x" — &lt;em&gt;the same&lt;/em&gt;. 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.&lt;/p&gt;

&lt;p&gt;Three suspects, three alibis, and still no idea where the time went.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lineup, and the thing nobody had varied
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Client&lt;/th&gt;
&lt;th&gt;Protocol&lt;/th&gt;
&lt;th&gt;Throughput&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;.NET &lt;code&gt;HttpClient&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;HTTP/1.1&lt;/td&gt;
&lt;td&gt;626–725 Mbps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;.NET &lt;code&gt;HttpClient&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;HTTP/2&lt;/td&gt;
&lt;td&gt;488–717 Mbps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Windows &lt;code&gt;curl&lt;/code&gt; (Schannel)&lt;/td&gt;
&lt;td&gt;HTTP/1.1&lt;/td&gt;
&lt;td&gt;435–453 Mbps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chromium &lt;code&gt;fetch()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;HTTP/2&lt;/td&gt;
&lt;td&gt;131–143 Mbps&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;very&lt;/em&gt; 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.&lt;/p&gt;

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

&lt;p&gt;Same 32 MiB chunk, same endpoint, same request, runs interleaved to rule out drift or thermal effects:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Body handed to &lt;code&gt;fetch()&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;Throughput&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Uint8Array&lt;/code&gt; (what we'd been sending)&lt;/td&gt;
&lt;td&gt;134–144 Mbps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ArrayBuffer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;122–141 Mbps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;Blob&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;678–740 Mbps&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ReadableStream&lt;/code&gt; + &lt;code&gt;duplex: 'half'&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;683–705 Mbps&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Wrap the identical bytes in a &lt;code&gt;Blob&lt;/code&gt; 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 &lt;code&gt;Blob&lt;/code&gt; &lt;strong&gt;outside&lt;/strong&gt; any retry closure. Blobs are re-readable, so a 429 retry re-sends the same object safely, whereas a &lt;code&gt;ReadableStream&lt;/code&gt; body scores just as well on throughput and is consumed on the first attempt. Fast until the first retry, then broken.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  The bug was in the thing doing the measuring
&lt;/h2&gt;

&lt;p&gt;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 &lt;strong&gt;hand-driven, in an ordinary Edge window with nothing attached, LAN-direct against the same server:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Body&lt;/th&gt;
&lt;th&gt;Median&lt;/th&gt;
&lt;th&gt;Range&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Uint8Array&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;609 Mbps&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;446–651&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Blob&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;802 Mbps&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;588–835&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;p&gt;Two falsification passes before believing the instrument itself. &lt;strong&gt;Was it a browser version fix?&lt;/strong&gt; No: Chromium &lt;strong&gt;149.0.7827.55&lt;/strong&gt; and &lt;strong&gt;151.0.7922.34&lt;/strong&gt; (Playwright 1.61.0 and 1.62.1), same harness, same endpoint, measured identically under automation — 125 and 116 Mbps. &lt;strong&gt;Was it DevTools?&lt;/strong&gt; 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 &lt;em&gt;controlled&lt;/em&gt; null, not an eyeballed resemblance.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This test runs over loopback, against a small Node sink — no network, no TLS, no real server.&lt;/strong&gt; 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 &lt;strong&gt;are not comparable with the gigabit-LAN figures above&lt;/strong&gt; — multi-Gbps here is expected, not a typo.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Phase&lt;/th&gt;
&lt;th&gt;&lt;code&gt;Uint8Array&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;Blob&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Ratio&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CDP attached&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;113&lt;/strong&gt; (108–116)&lt;/td&gt;
&lt;td&gt;2835 (2412–3226)&lt;/td&gt;
&lt;td&gt;25.1x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CDP detached&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;2351&lt;/strong&gt; (2182–2799)&lt;/td&gt;
&lt;td&gt;3125 (2616–3330)&lt;/td&gt;
&lt;td&gt;1.33x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;p&gt;The effect is specific to typed-array request bodies — but read the &lt;code&gt;Blob&lt;/code&gt; column carefully, because it is doing less work than it appears to. This sink's own ceiling is ~3 Gbps, and both &lt;code&gt;Blob&lt;/code&gt; figures sit on it. So the 1.33x in the detached row is a &lt;em&gt;lower bound&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is not a Playwright bug.&lt;/strong&gt; Puppeteer (&lt;code&gt;puppeteer-core&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No mechanism is claimed.&lt;/strong&gt; 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 &lt;em&gt;conditional&lt;/em&gt; — debugger attached, typed-array upload bodies crawl. Why, is for someone who can read the Chromium source.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduce it
&lt;/h2&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Does an attached CDP debugger throttle typed-array fetch() upload bodies?&lt;/span&gt;
&lt;span class="c1"&gt;// npm i puppeteer-core  &amp;amp;&amp;amp;  CHROME_EXE=/path/to/chrome node repro.mjs&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:http&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;puppeteer&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;puppeteer-core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PORT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8099&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;MIB&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;REPS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PAGE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;!doctype html&amp;gt;&amp;lt;meta charset="utf-8"&amp;gt;&amp;lt;pre id="out"&amp;gt;ready&amp;lt;/pre&amp;gt;&amp;lt;script&amp;gt;
window.bodyTypeTest = async (sizeMiB, reps) =&amp;gt; {
  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 &amp;lt; bytes; o += 1 &amp;lt;&amp;lt; 20)
    crypto.getRandomValues(chunk.subarray(o, Math.min(o + 4096, bytes)));

  const makeBody = k =&amp;gt; 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 &amp;lt; 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;
};
&amp;lt;/script&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// The sink replies only once it has the whole body, so the in-page timing covers the upload.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createServer&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PUT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;end&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;received&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="p"&gt;})));&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text/html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PAGE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PORT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;127.0.0.1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;med&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;summarise&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;med&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;Blob&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;med&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Blob&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;puppeteer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;executablePath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CHROME_EXE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headless&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ws&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wsEndpoint&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;())[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`http://127.0.0.1:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;PORT&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Phase A - debugger attached the whole time.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;attached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bodyTypeTest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;MIB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;REPS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Phase B - same browser, nothing attached. Schedule the run for 15s out, disconnect&lt;/span&gt;
&lt;span class="c1"&gt;// (which leaves the browser alive), let it happen unobserved, reconnect and read it back.&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;res&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;res&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bodyTypeTest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;15000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;MIB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;REPS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disconnect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;120000&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;again&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;puppeteer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;browserWSEndpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ws&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;detached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;again&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;())[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;res&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;null&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;attached&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;summarise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;attached&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;detached&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;summarise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;detached&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;again&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two runs of exactly the file above, unmodified, on Edge 151 while preparing this post:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;attached:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Uint&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="err"&gt;Array:&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="mi"&gt;89&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Blob:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1797&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;detached:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Uint&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="err"&gt;Array:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1791&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Blob:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2279&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;attached:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Uint&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="err"&gt;Array:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;104&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Blob:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1863&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;detached:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Uint&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="err"&gt;Array:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1748&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Blob:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2532&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;20.1x and 16.8x&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;Blob&lt;/code&gt; column. And run a positive control first: a rig that can't reproduce a known effect can't be trusted to rule one out.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's actually true
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Blob&lt;/code&gt; is a genuine, free, one-line win in Chromium: &lt;strong&gt;~1.3x&lt;/strong&gt; — 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 &lt;code&gt;Blob&lt;/code&gt; arm is pinned at the sink's own ceiling; the hand-driven LAN number is the one carrying this claim.&lt;/p&gt;

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

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

&lt;p&gt;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 &lt;strong&gt;9x longer&lt;/strong&gt; than the same chunk takes unobserved. Redone at each throughput that actually occurs, against the same 70ms of client work:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Per-chunk PUT&lt;/th&gt;
&lt;th&gt;Ceiling on what pipelining could save&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;~3000ms — what we originally measured, debugger attached&lt;/td&gt;
&lt;td&gt;~2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;~4.5s — a real user over the CDN tunnel, WAN-capped 37–97 Mbps&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~1.5%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;~335ms — gigabit LAN, &lt;code&gt;Blob&lt;/code&gt; body, nothing attached&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~17%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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 &lt;em&gt;for the link our users actually have&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Worth adding, since the obvious guess is wrong: none of this is about JavaScript being single-threaded. &lt;code&gt;crypto.subtle&lt;/code&gt; and &lt;code&gt;fetch()&lt;/code&gt; 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 &lt;code&gt;await&lt;/code&gt; on chunk N's PUT had resolved. A structural problem wearing a performance problem's clothes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual lesson
&lt;/h2&gt;

&lt;p&gt;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 &lt;em&gt;predicted&lt;/em&gt; the previous null result — which is supposed to be the sign you're onto something.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Two habits are the only reason it broke at all: &lt;strong&gt;a null result got chased instead of shrugged off&lt;/strong&gt; (350 vs 328 looked like nothing), and &lt;strong&gt;a confident conclusion got challenged instead of accepted&lt;/strong&gt; ("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).&lt;/p&gt;

&lt;p&gt;So: if you benchmark browser &lt;code&gt;fetch()&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Versions
&lt;/h2&gt;

&lt;p&gt;Everything above is one machine, one LAN, over two days. Pin your own setup against it:&lt;/p&gt;

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

&lt;p&gt;One trap worth naming: &lt;strong&gt;Edge 151 and Chromium 151 are not the same binary.&lt;/strong&gt; 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.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The one-line &lt;code&gt;Blob&lt;/code&gt; change is live in &lt;a href="https://getcoffer.org" rel="noopener noreferrer"&gt;Coffer&lt;/a&gt; — though, as the numbers above say, it is worth about 30% in one engine rather than the 5x we thought we'd found.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>performance</category>
      <category>testing</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
