<?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>I Read Every Comment in My Codebase. Every Wrong One Had the Same Shape.</title>
      <dc:creator>Coffer</dc:creator>
      <pubDate>Tue, 18 Aug 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/coffer/i-read-every-comment-in-my-codebase-every-wrong-one-had-the-same-shape-22gc</link>
      <guid>https://dev.to/coffer/i-read-every-comment-in-my-codebase-every-wrong-one-had-the-same-shape-22gc</guid>
      <description>&lt;p&gt;&lt;em&gt;I read all ~2,000 lines of comments in a codebase against the code they describe. Sixteen were wrong. Every single one of them was wrong in the same way — and it is not the way I expected.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The audit
&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. It is a heavily commented codebase: roughly 2,000 comment lines against 19,500 lines of C# and TypeScript, about one line in ten, in around 670 distinct comment blocks across 225 files.&lt;/p&gt;

&lt;p&gt;That is a deliberate choice, and it has an obvious failure mode. A comment can be wrong; code can't be wrong in the same way, because code is what runs. A wrong comment is &lt;em&gt;worse&lt;/em&gt; than no comment, because a reader trusts it. So the standing worry with a codebase like this is that at any moment some unknown fraction of those 2,000 lines is quietly lying, and nobody knows which lines.&lt;/p&gt;

&lt;p&gt;So I measured it. Every comment block, read against the code it claims to describe, in one pass. An AI coding agent did the reading — 2,000 lines of prose against 19,500 lines of code is a tedium problem more than a difficulty problem, and this is exactly the shape of tedium a model is good at. Every defect it flagged was confirmed against the referenced source before anything was edited, and two of them needed work outside the editor: one required computing a probability, one required checking the live production host.&lt;/p&gt;

&lt;p&gt;The result, in six commits:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Outcome&lt;/th&gt;
&lt;th&gt;How many&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Comment blocks read&lt;/td&gt;
&lt;td&gt;~670&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Corrected&lt;/strong&gt; — the comment said something the code does not do&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;16&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Deleted&lt;/strong&gt; — the comment said nothing the code doesn't already say&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Verified correct, left untouched&lt;/td&gt;
&lt;td&gt;everything else&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;About 2.4% wrong. Lower than I feared. But the interesting result isn't the rate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every defect had a remote referent
&lt;/h2&gt;

&lt;p&gt;Here is the finding, and it held without a single exception across all sixteen:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every wrong comment was describing something outside its own file.&lt;/strong&gt; Not one comment sitting directly above the code it documents was wrong.&lt;/p&gt;

&lt;p&gt;Four of the sixteen, to show the shape:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A deploy script's header comment&lt;/strong&gt; described its own sync step, 240 lines below. It claimed the script deletes removed files with &lt;code&gt;rsync --delete&lt;/code&gt;. The body of the script explains at length that &lt;code&gt;rsync --delete&lt;/code&gt; is exactly what you must &lt;em&gt;not&lt;/em&gt; do there — it would delete files that live on the server and not in the repository — and the script actually diffs a per-deploy manifest so it can only ever remove what it previously put there. A reader skimming the header would have come away believing the precise opposite of the rule the body exists to enforce.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A server-side &lt;code&gt;Plan.cs&lt;/code&gt;&lt;/strong&gt; described the &lt;em&gt;client's&lt;/em&gt; encryption: multi-GB uploads "won't actually complete in-browser until chunked encryption lands." Chunked encryption had landed. A 4.8 GB upload was verified byte-exact months earlier.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A client-side &lt;code&gt;VaultPage.tsx&lt;/code&gt;&lt;/strong&gt; described the &lt;em&gt;server's&lt;/em&gt; cleanup job as "the daily sweep." The sweeper runs hourly and reclaims sessions older than 24 hours. What's daily is how long an abandoned session survives, not how often the sweep looks — which matters at exactly that call site, since the whole reason to delete eagerly is that otherwise a part file sits on disk for a day.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A comment in &lt;code&gt;Program.cs&lt;/code&gt;&lt;/strong&gt; explained why database migration is wrapped in a retry, by describing a &lt;em&gt;hardware&lt;/em&gt; fault on the production host that made the clock jump at boot. True when written. The fault had since been fixed on the machine. Nothing in the repository could have told me that; I had to go look at the host.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three species, and all sixteen fit one of them:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Species&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Roadmap state that arrived&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;"until X lands", "the future billing webhook", "currently only the welcome email"&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SetPlan&lt;/code&gt; called the billing webhook "future"; it had been live for weeks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;A second case appeared&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The comment names the only case that existed when it was written&lt;/td&gt;
&lt;td&gt;Six comments said "Google" after sign-in became provider-agnostic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;A number drifted&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A measurement, stated once, never re-measured&lt;/td&gt;
&lt;td&gt;A build plugin claimed it strips "roughly 1.2 KB" of HTML comments; it strips 3,202 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every one of them was &lt;strong&gt;true when written&lt;/strong&gt;. Not one was sloppy. This is not a story about people writing bad comments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why that's the shape: the drift is invisible in the diff
&lt;/h2&gt;

&lt;p&gt;Adding a second sign-in provider is a normal feature. It touched the auth controller, the startup configuration, a button component. It did not touch the account page. It did not touch the user manager's logging comment. It did not touch two test files.&lt;/p&gt;

&lt;p&gt;And yet, the moment it merged, six comments in files it never opened became false — because each of them said "Google" where the code underneath had quietly become "any external provider." No diff showed that. No test failed. &lt;code&gt;git blame&lt;/code&gt; on those comments still points at the commit that wrote them, correctly, months earlier.&lt;/p&gt;

&lt;p&gt;That is the whole mechanism. &lt;strong&gt;A comment about local code is checked constantly&lt;/strong&gt; — by everyone who edits the line beneath it, in the diff where the change lives. &lt;strong&gt;A comment about remote code is checked by nobody&lt;/strong&gt;, because the change that falsifies it happens in a file where the comment isn't visible. The referent moves; the comment stays.&lt;/p&gt;

&lt;p&gt;The measurement species is the same failure with a longer fuse. Here's one, in full, from a recovery key generator:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before:&lt;/span&gt;
&lt;span class="c1"&gt;// Refill in batches so we don't call getRandomValues once per character; a few draws&lt;/span&gt;
&lt;span class="c1"&gt;// land in the rejected tail, so oversample slightly to usually finish in one batch.&lt;/span&gt;

&lt;span class="c1"&gt;// After:&lt;/span&gt;
&lt;span class="c1"&gt;// Refill in batches so we don't call getRandomValues once per character. The batch is&lt;/span&gt;
&lt;span class="c1"&gt;// exactly `length` bytes — no oversampling — and 8 of the 256 byte values (3.1%) fall in&lt;/span&gt;
&lt;span class="c1"&gt;// the rejected tail, so one batch yields all 32 characters only about a third of the&lt;/span&gt;
&lt;span class="c1"&gt;// time. The loop just draws another batch until enough survive; correctness never&lt;/span&gt;
&lt;span class="c1"&gt;// depends on a batch sufficing.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both halves of the original were false. There was no oversampling — the batch is exactly &lt;code&gt;length&lt;/code&gt; bytes for &lt;code&gt;length&lt;/code&gt; characters. And "usually finish in one batch" is (248/256)^32 = &lt;strong&gt;36%&lt;/strong&gt;, which is not usually. The loop was always correct; it just draws again. But you cannot catch that by reading. You catch it by doing the arithmetic, which nobody does to a comment they're scrolling past.&lt;/p&gt;

&lt;h2&gt;
  
  
  So: is heavy commenting worth it?
&lt;/h2&gt;

&lt;p&gt;The honest accounting, from the same audit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cost is real, and smaller than the argument against comments assumes.&lt;/strong&gt; Sixteen defects in ~670 blocks, accumulated over the entire life of the project. Fixing them took one evening. That is the true maintenance bill for a decade-dense commenting habit in a codebase this size, and it is not a large number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;But the cost is not evenly distributed, and that's what makes it manageable.&lt;/strong&gt; It is concentrated almost entirely in comments with a remote referent. If you know that, you know where to spend the evening. An audit that reads every comment equally — which is what I did, and I don't recommend repeating it — spends most of its time confirming that comments beside their own code are fine, which they reliably are.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The worst case is genuinely bad, though.&lt;/strong&gt; The deploy script header wasn't merely stale, it was &lt;em&gt;inverted&lt;/em&gt;: it advertised the exact practice the rest of the file exists to forbid. A reader who trusted it and "restored" &lt;code&gt;rsync --delete&lt;/code&gt; would have wiped server-side files that exist nowhere in the repository — on the next deploy, silently. This is the strongest form of the anti-comment argument and it is correct: a wrong comment is worse than no comment, because it converts your reader's diligence into a weapon. Being 97.6% accurate is not much comfort when the 2.4% is load-bearing. This is also the one claim in the post with evidence behind it beyond my own repository: &lt;a href="https://arxiv.org/abs/2409.10781" rel="noopener noreferrer"&gt;a 2024 study&lt;/a&gt; of code-comment inconsistency found that inconsistent changes are around &lt;strong&gt;1.5x more likely&lt;/strong&gt; to produce a bug-introducing commit than consistent ones. Wrong comments don't just mislead readers in the abstract; they show up in the defect record.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And the payoff is concentrated too — in the same place.&lt;/strong&gt; Look at what those 654 correct blocks were doing. The most valuable one in the whole codebase is that same deploy script, which documents three specific things that were tried and &lt;em&gt;broke&lt;/em&gt; — including why &lt;code&gt;rsync --delete&lt;/code&gt; is forbidden. That knowledge exists nowhere else. Not in the code (the code is the fix, not the fault), not in the tests, not in a commit message anyone will find in two years. The comments carrying the highest value are almost always the ones explaining a &lt;strong&gt;rejected&lt;/strong&gt; alternative or a &lt;strong&gt;non-obvious constraint&lt;/strong&gt;, and those are exactly the comments most likely to name something outside their own file — the other layer, the number that was measured, the machine. The maintenance burden and the payoff live at the same address.&lt;/p&gt;

&lt;p&gt;You can see the same distinction in what got deleted. Five blocks went, and all five were the same kind of thing: &lt;code&gt;// Add services to the container.&lt;/code&gt; above the line adding services to the container, &lt;code&gt;// Configure the HTTP request pipeline.&lt;/code&gt; above the pipeline configuration, a scaffolding link to the OpenAPI docs, two commented-out dead declarations. Pure restatement, generated by a project template, surviving purely because nobody ever deleted them.&lt;/p&gt;

&lt;p&gt;Whereas these stayed, all of them one-liners:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// For EF Core.&lt;/span&gt;
&lt;span class="c1"&gt;// Hours; 0 = no expiry.&lt;/span&gt;
&lt;span class="c1"&gt;// OWASP's 2023 minimum&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Delete those and you lose the only record of &lt;em&gt;why&lt;/em&gt;. "Comments should explain why, not what" is advice everybody has heard and it is completely correct; what the audit adds is that the &lt;em&gt;what&lt;/em&gt; comments are the harmless ones. They're useless, they dilute, they should go — but they are never what lies to you. &lt;strong&gt;The comments carrying real information are the same comments that can rot.&lt;/strong&gt; There's no version of this where you get the value without the exposure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The maintenance process that actually falls out of this
&lt;/h2&gt;

&lt;p&gt;I would not run this audit again wholesale. It cost an evening and 94% of the reading confirmed things that were never in danger. What I'd do instead, and what the finding actually supports:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. When writing a comment, notice when it points somewhere else.&lt;/strong&gt; If a comment names another file, another layer, another service, a number, or a machine, it has a referent you don't control and a maintenance cost the local ones don't have. That's not a reason to avoid writing it — those are the valuable ones. It's a reason to write it so it fails loudly: name the file, name the symbol, state the number with its units and its date, so the next reader can check it in ten seconds instead of wondering.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Grep for time-bound language, occasionally.&lt;/strong&gt; &lt;code&gt;currently&lt;/code&gt;, &lt;code&gt;for now&lt;/code&gt;, &lt;code&gt;not yet&lt;/code&gt;, &lt;code&gt;until … lands&lt;/code&gt;, &lt;code&gt;the future&lt;/code&gt;, &lt;code&gt;TODO&lt;/code&gt;. Every "roadmap state that arrived" defect announced itself with one of these words. This is a five-minute sweep with a high hit rate, and it's the highest-value habit in this entire post.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Review comments that make cross-file claims the way you'd review an assertion.&lt;/strong&gt; A comment saying "the sweep runs daily" is a claim about another file's behaviour. In review, that deserves the same "is that still true?" you'd give a magic number. A comment saying "this is case-insensitive to match the server" is checkable in ten seconds, by someone who has the diff open.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. When a feature generalizes something, grep for the old name.&lt;/strong&gt; The single most productive five seconds of the entire audit would have been &lt;code&gt;grep -ri google&lt;/code&gt; on the day the second provider shipped. Six of sixteen defects, all findable, all in files that feature never touched. Every time you turn one case into two — one provider into any provider, one currency into many, one region into several — the old name is sitting in comments somewhere, and it is now a lie.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Verify numbers by computing them, not by reading them.&lt;/strong&gt; The two best finds in the audit were the recovery-key probability and the production host's clock. Neither was catchable by reading. One needed arithmetic, one needed leaving the editor entirely. If a comment states a measurement, it can only be checked by measuring.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't prove
&lt;/h2&gt;

&lt;p&gt;One codebase, one project, 225 files, sixteen defects. That's a small sample from a single habitat, and the habitat matters: this codebase has one primary author, which means no comment ever went stale because two people disagreed about what the code was for. In a larger team I'd expect a whole species this audit couldn't produce — comments that were never true, only believed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And none of this is new territory, which I should be explicit about rather than let you discover in the comments.&lt;/strong&gt; Comment rot is one of the better-studied things in software engineering research. &lt;a href="https://link.springer.com/article/10.1007/s11219-009-9075-x" rel="noopener noreferrer"&gt;Fluri et al.&lt;/a&gt; found that about 90% of comment changes happen in the same revision as the code change — the deferred remainder being exactly where inconsistency comes from. &lt;a href="https://dl.acm.org/doi/10.1109/ICPC.2019.00019" rel="noopener noreferrer"&gt;Wen et al.&lt;/a&gt; mined 1.3 billion AST-level changes across 1,500 systems to build a taxonomy of the inconsistencies developers actually fix. There is a whole detection subfield downstream of that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My central category already has a name, too.&lt;/strong&gt; &lt;a href="https://link.springer.com/article/10.1007/s10664-023-10425-5" rel="noopener noreferrer"&gt;Jabrayilzade et al.'s taxonomy of inline comment smells&lt;/a&gt; lists eleven, and one of them is &lt;strong&gt;non-local&lt;/strong&gt; — comments referencing parts of the code that are not nearby. That is recognisably the thing this whole post is about, named before I got here.&lt;/p&gt;

&lt;p&gt;What I could not find is anyone claiming what this post claims: that locality &lt;em&gt;predicts&lt;/em&gt; drift — that the non-local ones are where essentially all of it lives, and the local ones don't drift at all. In that taxonomy "non-local" is a comprehension smell: harder to read, harder to verify. The &lt;a href="https://arxiv.org/abs/2504.18956" rel="noopener noreferrer"&gt;follow-up detection work&lt;/a&gt; explicitly does not claim those comments go stale more often. And the large inconsistency studies categorise drift by &lt;em&gt;what&lt;/em&gt; went stale — application logic, design, maintenance — not by where its referent lived. Two searches came up empty, which is weak evidence of novelty rather than proof.&lt;/p&gt;

&lt;p&gt;The local/remote split is also partly definitional, and I want to be honest that I noticed it &lt;em&gt;after&lt;/em&gt; the fact rather than predicting it. "Describes something outside its own file" is a category I drew around sixteen defects once I had them in front of me. It's a clean line and it held perfectly, but sixteen is a small number to draw a law from. So the honest positioning is narrow: &lt;strong&gt;they named the smell; I watched one codebase for its whole life and found the drift sitting entirely inside it.&lt;/strong&gt; A hypothesis with a good first result, not a finding.&lt;/p&gt;

&lt;p&gt;And a comment sitting beside its own code can absolutely still be wrong — it can misdescribe intent from the day it was written. This audit couldn't have detected that, because the check for a local comment is "does this match the code beneath it," and a comment that was born wrong matches nothing in particular. What the audit shows is that local comments don't &lt;em&gt;drift&lt;/em&gt;. Not that they're true.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one that got away
&lt;/h2&gt;

&lt;p&gt;There's one defect I found and deliberately did not fix. A test is named:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;Delete_ShouldDeleteAccount_WhenGoogleOnlyUserTypesTheirOwnEmail&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same stale assumption as the six comments — it is not a Google-only path, it's the path every external provider takes. But it's in the &lt;em&gt;name&lt;/em&gt;. Renaming it is a code change, not a comment fix, and it didn't belong in a commit series that touched nothing executable.&lt;/p&gt;

&lt;p&gt;It's the better illustration of the whole point, though. Comment drift is at least confined to comments, where the fix is free and the blast radius is a confused reader. The same drift in an identifier is a refactor, and it's the reason people who identify their symbols after a single case end up living with the wrong word forever. Your comments and your names go stale by exactly the same mechanism — a second case appears somewhere else — and only one of them is cheap to correct afterwards.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Coffer is at &lt;a href="https://getcoffer.org" rel="noopener noreferrer"&gt;getcoffer.org&lt;/a&gt;. The comments are, as of this week, accurate — a claim with a known shelf life, which is roughly the point of the article.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>codequality</category>
      <category>programming</category>
      <category>refactoring</category>
    </item>
    <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>
