tunnelfetch 1.4.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -256,24 +256,34 @@ presents curl's TLS and HTTP/2 fingerprints by default, and `gzip, deflate` is w
256
256
  the default is already consistent. It stops being consistent the moment you dress the handshake up
257
257
  as a browser and leave the header behind.
258
258
 
259
- It is not a saving. Measured on the edge, decoding the same 256 KB page to the same bytes:
260
-
261
- | Implementation | Algorithm | ms/MB |
262
- |---|---|---|
263
- | `DecompressionStream` — the runtime's own C++ | inflate | **2.5** |
264
- | WASM brotli (`brotli-dec-wasm`) | brotli | 4.7 |
265
- | JS inflate (`fflate` / `pako`) | inflate | 7.5 / 8.2 |
266
- | JS brotli (`brotli`) | brotli | 19.7 |
267
-
268
- Two things fall out, and both are worth knowing before reaching for WebAssembly anywhere else in a
269
- Worker. **WASM is about 4x faster than JavaScript at the same algorithm** (brotli: 4.7 against
259
+ It is not a saving. Measured on the edge, decoding the same 256 KB page to the same bytes, **all
260
+ through the same `ReadableStream -> ReadableStream` shape a `decoders` entry actually has**:
261
+
262
+ | Implementation | Algorithm | ms/MB | vs native gzip |
263
+ |---|---|---|---|
264
+ | `DecompressionStream` — the runtime's own C++ | inflate | **2.75** | 1.0x |
265
+ | WASM zstd (bundled, decode-only build of facebook/zstd) | zstd | 5.5 | 2.0x |
266
+ | WASM brotli (bundled, decode-only build of google/brotli) | brotli | 7.0 | 2.5x |
267
+ | WASM brotli (`brotli-dec-wasm` from npm) | brotli | 10.5 | 3.8x |
268
+ | JS inflate (`fflate` / `pako`) | inflate | 7.5 / 8.2 | 2.7x / 3.0x |
269
+ | JS brotli (`brotli`) | brotli | 19.7 | 7.2x |
270
+
271
+ An earlier version of this table said 4.7 ms/MB for `brotli-dec-wasm`, and that figure was wrong in
272
+ a way worth naming: it was measured by driving the decoder in a bare loop, while the README's own
273
+ example wires it up as a `decoders` entry, which is a stream. The same decoder costs **4.7 in a
274
+ loop and 10.5 behind a `TransformStream` — the stream machinery is 121% on top**, and the number
275
+ that belongs here is the one matching the documented usage. Measured and used must be the same
276
+ thing.
277
+
278
+ Two things still fall out, both worth knowing before reaching for WebAssembly anywhere else in a
279
+ Worker. **WASM is roughly 3x faster than JavaScript at the same algorithm** (brotli: 7.0 against
270
280
  19.7) — so if a coding has no native path, WASM is the right way to add one. And **native is about
271
- 3x faster than JavaScript at the same algorithm** (inflate: 2.5 against 7.5–8.2) so where a
272
- native path already exists, nothing in userland improves on it. That is why `gzip` and `deflate`
273
- are not overridable: replacing them could only ever be slower, and doing it silently is the kind of
274
- quiet downgrade this package refuses everywhere else.
281
+ 3x faster than JavaScript** (inflate: 2.75 against 7.5–8.2), while WASM lands 2–2.5x above native —
282
+ so where a native path already exists, nothing in userland improves on it. That is why `gzip` and
283
+ `deflate` are not overridable: replacing them could only ever be slower, and doing it silently is
284
+ the kind of quiet downgrade this package refuses everywhere else.
275
285
 
276
- Brotli itself lands at 1.9x native inflate. That gap is the price of the coding, and the wire bytes
286
+ Brotli itself lands at 2.5x native inflate. That gap is the price of the coding, and the wire bytes
277
287
  it saves do not pay it back — see [What this cannot do](#what-this-cannot-do-and-why). Decoder names
278
288
  are validated as HTTP tokens, a decoder that throws fails the body closed rather than truncating it,
279
289
  and an unregistered coding is still refused.
@@ -293,9 +303,70 @@ freestanding WASM and both with known-answer tests in this repository. **Importi
293
303
  opt-in:** a bundler pulls them in only for code on this path, so the default identity carries none
294
304
  of it.
295
305
 
296
- `br` and `zstd` stay yours. They are not cryptography and there is no single right implementation,
297
- so the profile keeps refusing until you supply them a Chrome that advertises `br` and cannot read
298
- it is worse than one that says so.
306
+ Nothing else to supply: `br` and `zstd` are bundled too. They were held back at first, on the
307
+ grounds that there is no single right implementationmeasurement dissolved that. A decode-only
308
+ build of the reference C is 1.5x faster than the npm alternative at the interface this package
309
+ actually uses, and half the size.
310
+
311
+ The whole cost of the four blobs is **3 ms once per isolate** — module-scope instantiation lands in
312
+ startup, which this runtime does not bill, so only the first request in a fresh isolate sees
313
+ anything and every request after it sees nothing. Measured against an otherwise identical
314
+ deployment that imports none of them:
315
+
316
+ | | with all four WASM modules | importing none |
317
+ |---|---|---|
318
+ | first request in a fresh isolate | 3 ms | 0 ms |
319
+ | requests 2–5 | 0 ms | 0 ms |
320
+ | request 6 onward | 0 ms | 0 ms |
321
+
322
+ Per-byte decoding is separate and conditional: you pay it only when an origin actually serves `br`
323
+ or `zstd`. See the codec table above.
324
+
325
+ ### Customising an identity
326
+
327
+ Three levels, in the order you are likely to want them.
328
+
329
+ **Override one field.** `tls` merges per-field, so naming one thing keeps the rest of the profile:
330
+
331
+ ```js
332
+ new Client({ profile: chrome, tls: { alpn: ['http/1.1'] } });
333
+ // alpn replaced; extensionOrder, grease, ciphers, groups all still Chrome's
334
+ ```
335
+
336
+ Top-level fields (`headerOrder`, `http2Settings`, `http2PseudoHeaderOrder`, `http2HpackIndexing`)
337
+ replace wholesale, since a half-merged order is not an order.
338
+
339
+ **Derive a profile.** A profile is a plain frozen object, so spreading one is the whole mechanism —
340
+ no API to learn. This is the right way to change a User-Agent for every request:
341
+
342
+ ```js
343
+ const mine = { ...chrome, name: 'chrome+mine',
344
+ headers: [['User-Agent', 'mybot/1.0'], ['X-Tag', 'a']] };
345
+ new Client({ profile: mine, connect, proxy });
346
+ ```
347
+
348
+ **Write one from scratch.** Nothing about the built-ins is privileged:
349
+
350
+ ```js
351
+ const firefox = {
352
+ name: 'my-firefox/130',
353
+ tls: { alpn: ['h2', 'http/1.1'], ciphers: [0x1302, 0x1301],
354
+ extensionOrder: [0, 10, 11, 13, 16, 23, 43, 45, 51, 0xff01], grease: false },
355
+ headerOrder: ['host', 'user-agent', 'accept', 'accept-language', 'accept-encoding', '*', 'connection'],
356
+ headers: [['User-Agent', 'Mozilla/5.0 Firefox/130.0']],
357
+ http2Settings: [[1, 65536], [4, 131072], [5, 16384]],
358
+ http2PseudoHeaderOrder: [':method', ':path', ':authority', ':scheme'],
359
+ requires: [],
360
+ };
361
+ ```
362
+
363
+ `requires` applies to your profile exactly as it does to the built-ins: name a capability the
364
+ Client has not been given and construction is refused, naming what is missing. A custom identity
365
+ gets the same guard against advertising what it cannot perform.
366
+
367
+ Profile `headers` are defaults — a per-request header of the same name wins — while explicit
368
+ `Client` options win over the profile. So the precedence runs: per-request, then Client options,
369
+ then the profile.
299
370
 
300
371
  Verified end to end, not merely constructed:
301
372
 
@@ -416,7 +487,7 @@ res.tunnelfetch.httpVersion; // '2' if the server chose h2, '1.1' otherwise
416
487
  | `timeouts` | see below | `connectMs`, `handshakeMs`, `headersMs`, `idleMs`, `totalMs`. |
417
488
  | `cookies` | `false` | Enable a per-Client cookie jar. |
418
489
  | `maxRedirects` | `20` | |
419
- | `maxBodyBytes` | `Infinity` | Enforced from `Content-Length` before a byte is read. |
490
+ | `maxBodyBytes` | **`32 MiB`** | Enforced from `Content-Length` before a byte is read, on the raw stream, and on the DECODED output — including a decoder you registered. `Infinity` opts out; see [the note on the default](#the-body-cap-has-a-default-now). |
420
491
  | `decompress` | `true` | Decode `Content-Encoding` at all. gzip and deflate are built in. |
421
492
  | `decoders` | `{}` | Extra codings, e.g. `{ br: fn }`. Each is added to `Accept-Encoding`. See [`br`, `zstd`](#br-zstd-and-other-codings). |
422
493
  | `keepAlive` | `true` | |
@@ -430,6 +501,30 @@ carries a non-standard `tunnelfetch` property with `{proxied, proxy, tls, httpVe
430
501
  `client.close()` releases every pooled socket. A `Client` that is not closed leaks sockets for the
431
502
  lifetime of the isolate.
432
503
 
504
+ #### The body cap has a default now
505
+
506
+ **`maxBodyBytes` defaulted to `Infinity` through 1.5.0. From 1.6.0 it is 32 MiB, and that is a
507
+ breaking change** — a download larger than 32 MiB now needs an explicit `maxBodyBytes`, compressed
508
+ or not, because this option bounds the wire body as well as the decoded one.
509
+
510
+ The reason is that "no limit" is not a freedom on a runtime with a hard memory ceiling; it is a way
511
+ to be killed by a peer. A 53-byte brotli body decoding to 32 MB is measured here, not hypothetical,
512
+ and the bundled `br`/`zstd` decoders self-limit at 256 MiB — **twice the 128 MB a Workers isolate
513
+ gets**, so that fallback cannot fire before the isolate is already dead. A client whose entire
514
+ purpose is fetching URLs you do not control should not ship "unbounded" as the setting you get for
515
+ not having read this table.
516
+
517
+ 32 MiB is a quarter of the ceiling, so a body buffered to the cap by `.arrayBuffer()` still leaves
518
+ the isolate room to survive and report it, and it is two orders of magnitude above any page or API
519
+ response. If you are deliberately moving large files, say so:
520
+
521
+ ```js
522
+ new Client({ connect, proxy, maxBodyBytes: Infinity }); // or any number you have thought about
523
+ ```
524
+
525
+ The trade is deliberate: an unasked-for limit is discoverable the first time it bites, and names the
526
+ option in its error. An unasked-for OOM is neither.
527
+
433
528
  ### Trust — the `verify=` knob
434
529
 
435
530
  ```js
@@ -549,24 +644,20 @@ Not implemented, and not planned:
549
644
  - **A public-suffix list for cookies.** Only the "no dot in the domain" guard is implemented, so
550
645
  `Domain=com` is refused but `Domain=co.uk` is not. Documented rather than faked.
551
646
  - **IDNA.** Pass A-labels (punycode); a non-ASCII hostname is rejected with a message saying so.
552
- - **`br` and `zstd` out of the box.** The runtime's `DecompressionStream` accepts gzip, deflate and
553
- deflate-raw only — measured, not assumed. Neither is *unreachable*, though: register a decoder
554
- with [`decoders`](#br-zstd-and-other-codings) and the coding is advertised and decoded. Nothing
555
- ships built in, because the only way to get Brotli here is WebAssembly, and a 208 KB binary blob
556
- would cost this package both its zero dependencies and its ability to be imported without a
557
- bundler. Bringing your own makes that cost, and that supply chain, yours and visible.
558
-
559
- Leaving it off is safe rather than lossy: content negotiation means a server never sends what was
560
- not asked for, so a Brotli-serving origin simply returns gzip. What it costs is bandwidth — the
561
- same page measured 290 KB as gzip against 99 KB as `br` — and bandwidth is not what this platform
562
- bills. Measured on the edge, the trade runs the wrong way: WASM Brotli decodes at about
563
- **1.9x** the CPU of the runtime's native inflate, and even on the page with the largest wire
564
- saving in a 14-site survey, the 186 KB saved bought back ~1.2 ms while the extra decoding cost
565
- several times that. Harder compression makes it worse, not better — brotli quality 11, which is
566
- what a CDN serves from cache, is 16% smaller on the wire than quality 5 and **46% more expensive
567
- to decode**, because decompression work scales with the OUTPUT bytes and a denser encoding means
568
- more work per byte produced. The reason to turn `br` on is matching a browser's
569
- `Accept-Encoding`, not saving CPU.
647
+ - **`br` and `zstd` in the default identity.** The runtime's `DecompressionStream` accepts gzip,
648
+ deflate and deflate-raw only — measured, not assumed so both come from WebAssembly, and the
649
+ default entry point carries neither. Import
650
+ [`tunnelfetch/profile/chrome`](#the-chrome-identity-in-one-import) and they arrive wired in, or
651
+ register your own through [`decoders`](#br-zstd-and-other-codings). What the main entry will not
652
+ do is pull ~140 KB of compiled C into every bundle for a coding most callers never meet.
653
+
654
+ Leaving them off is safe rather than lossy: content negotiation means a server never sends what
655
+ was not asked for, so a Brotli-serving origin simply returns gzip. What it costs is bandwidth —
656
+ the same page measured 290 KB as gzip against 99 KB as `br` — and bandwidth is not what this
657
+ platform bills. On CPU the trade runs the other way: brotli decodes at 2.5x native inflate, and
658
+ harder compression is worse rather than better, because decompression work scales with the OUTPUT
659
+ bytes. The reason to turn `br` on is matching a browser's `Accept-Encoding`, not saving CPU.
660
+
570
661
  - **Streaming request bodies.** A request body is read fully into memory before the request is
571
662
  sent, because the framing has to be declared in a `Content-Length` this client can stand behind
572
663
  and because a body may have to be replayed on a redirect. Fine for the JSON an SDK sends; wrong
@@ -602,52 +693,63 @@ Fetching a size-controlled origin through a proxy, warm, medians over seven-plus
602
693
  isolate, gzip on the wire. The last column is the same numbers as a rate, which is the form worth
603
694
  carrying around:
604
695
 
605
- | | New connection (first request included) | Each further request, same connection |
696
+ | Body | Averaged over 5 pages | Reusing a connection | New connection |
697
+ | --- | --- | --- | --- |
698
+ | 1 KB | 3.2 ms | 1.7 ms | 9.2 ms |
699
+ | 16 KB | 4.6 ms | 3.1 ms | 10.6 ms |
700
+ | 64 KB | 8.2 ms | 6.7 ms | 14.2 ms |
701
+ | 256 KB | 18.2 ms | 16.7 ms | 24.2 ms |
702
+ | 1 MB | 54.8 ms | 53.3 ms | 60.8 ms |
703
+ | 4 MB | 119.8 ms | 118.3 ms | 125.8 ms |
704
+
705
+ The cold-start cost is a **total**, not something to add to a row above:
706
+
707
+ | First request in a fresh isolate | cost of that request | excess over the warm floor, whole ramp |
606
708
  | --- | --- | --- |
607
- | 1 KB body | 6.4 ms | 1.6 ms |
608
- | 16 KB body | 6.5 ms | 1.7 ms |
609
- | 64 KB body | 6.7 ms | 1.9 ms |
610
- | 256 KB body | 7.4 ms | 2.6 ms |
611
- | 1 MB body | 10.4 ms | 5.6 ms |
612
- | 4 MB body | 22.4 ms | 17.6 ms |
613
- | **First request in a fresh isolate** | 46 ms | |
614
- | **…after `warmup({ iterations: 5 })`** | 16 ms | |
615
-
616
- Re-measured for 1.4.0 against a size-controlled origin through a proxy, ten rounds per size, HTTP/2
617
- negotiated, gzip on the wire. The rows are not independent measurements — they are one model, fitted
618
- to the sweep and then checked back against it:
619
-
620
- > **≈ 6.4 ms to open a connection + 1.6 ms per further request + ~4 ms per MB of body**
621
-
622
- Each sweep request fetches five pages — one on a fresh connection and four reusing it — so the two
623
- terms were separated by varying the reuse count rather than assumed: two pages against ten gives
624
- 1.63 ms per further request, and the connection term falls out of the remainder. Fitted on 1 KB and
625
- 4 MB, the model predicts 32.9 ms for the 1 MB row against 35 measured, and 92.9 for the 4 MB row
626
- against 94.
627
-
628
- The ranges are real, not imprecision: absolute CPU on this platform varies by up to ~1.5× between
629
- isolates and runs — the same sweep repeated lands on faster and slower machines — so the values are
630
- medians and the spread is what repeated same-isolate measurement actually shows.
631
-
632
- **Reuse is the lever.** Thirty 16 KB pages from one host cost about 103 ms down one connection and
633
- about 300 ms opening thirty. That gap is the entire argument for holding a `Client` rather than
634
- calling `createFetch` per request, and it widens as pages get smaller.
635
-
636
- **HTTP/2 is more expensive in every cell and cheaper in none** 12 ms against 8 ms for one page on
637
- a new connection, 76 ms against 67 ms for thirty pages on one connection, changing only the offered
638
- ALPN against the same origin and proxy. The overhead is HPACK plus frame and stream bookkeeping,
639
- concentrated at connection setup: the preface, the `SETTINGS` exchange, and the first header block.
640
- Multiplexing, the thing HTTP/2 is *for* in a browser, buys latency a one-request-per-handler Worker
641
- cannot spend. Reach for it when a site refuses HTTP/1.1, and set `http2: false` on paths that do
642
- not need it. (These two rows came from the Workers GraphQL analytics API rather than `wrangler
643
- tail`, which would not survive the measurement network here; same edge CPU-time metric, quantiled
644
- per minute.)
645
-
646
- **The fresh-isolate rows are a ramp, not a step.** V8 tiers up per function per isolate, so the
647
- first executions run interpreted and the excess decays over roughly six requests: 61 ms of total
648
- excess above the warm floor without `warmup()`, 15 ms with it at five iterations — about 4.4 ms and
649
- 1.1 ms per request respectively, amortised over an isolate's early life. Warming costs 10 ms of
650
- startup at one iteration and 22 ms at five, against a 1 s budget, and does not lower the warm floor.
709
+ | without `warmup()` | **46 ms** | 61 ms (≈ 4.4 ms/request over an isolate's early life) |
710
+ | `warmup()` once | 22 ms | 40 ms (≈ 2.8 ms/request) |
711
+ | `warmup({ iterations: 5 })` | **16 ms** | 15 ms (≈ 1.1 ms/request) |
712
+
713
+ Both figures used to appear in the table above as "+46 ms" and "+16 ms", which turned a total into
714
+ an increment and doubled the documented cold start. The `+` is refutable from the numbers alone: a
715
+ first request that cost 9.2 + 16 would carry 16 ms of excess by itself, which is more than the 15 ms
716
+ of excess the *whole* ramp contains. These were measured on a small body; a cold isolate's first
717
+ 4 MB request has never been measured and is certainly worse, since far more of the decode loop runs
718
+ interpreted.
719
+
720
+ Measured through a proxy against a size-controlled origin, eight rounds per size, HTTP/2, gzip on
721
+ the wire. Connection and per-request terms were separated by varying the reuse count rather than
722
+ assumed two pages against ten gives **9.8 ms to open a connection** and **2.25 ms per further
723
+ request**, and the body cost is what is left.
724
+
725
+ **These figures replace ones that were measured wrong, and the mistake is worth describing.** The
726
+ origin they came from tiled a 150-byte HTML fragment, which gzip compressed **220:1** so a "1 MB
727
+ body" was four kilobytes on the wire, and every measurement taken against it priced decompression
728
+ while erasing the per-wire-byte cost of TLS records and streaming entirely. Real pages compress
729
+ around 4:1. The origin now tiles 154 KiB of real minified JavaScript, which lands at 2.76:1: gzip's
730
+ window is 32 KiB, so a repeat period that large does not compress away.
731
+
732
+ The correction is large. Body-heavy rows are **two to three times** what this table said through
733
+ 1.4.0, and no amount of care about medians or minimums would have caught it, because the numbers
734
+ were internally consistent they were answers to the wrong question.
735
+
736
+ **Read the two right-hand columns as derived, because they are.** Only the pooled column is measured
737
+ per size; "new connection" is the pooled figure plus a flat 7.5 ms and "averaged over 5 pages" is
738
+ the pooled figure plus 1.5 ms, which is why the deltas are identical to one decimal across a 4000×
739
+ range in body size. That 7.5 ms also does not agree with the 9.8 ms quoted just above it, and the
740
+ 2.25 ms per further request is larger than the entire 1.7 ms a pooled 1 KB request costs, which
741
+ would make a 1 KB body cost negative. The two came from different sweeps, and combining them is the
742
+ cross-sweep comparison this document tells you never to make. **Treat the connection term as
743
+ somewhere in 7–10 ms and do not do arithmetic with it.**
744
+
745
+ An independent check was quoted here as agreement and is not: a real 3.6 MB file from a CDN cost
746
+ 142 ms against the ~120 ms this table predicts for 4 MB. That is the model under-predicting by
747
+ roughly 20%, in the same direction as the error it had just replaced. It belongs here as a caution,
748
+ not as corroboration.
749
+
750
+ Two further cautions. The 2.76:1 content is slightly *less* compressible than a typical page, so
751
+ these are mildly conservative rather than optimistic. And CPU on this platform varies by up to ~1.5×
752
+ between isolates, so the shape matters more than any single figure.
651
753
 
652
754
  ### What the optional switches cost
653
755
 
@@ -661,9 +763,10 @@ the edge the same way as the rest — differencing two work counts, minimum of s
661
763
  | `tls.extensionOrder: 'shuffle'` | not measurable | shuffling ~11 items, once per handshake |
662
764
  | `headerOrder` | not measurable — the ordered list is *faster* than the platform `Headers` (1.6 µs against 3.8 µs) | per request |
663
765
  | `groups: { x25519mlkem768 }` | **+0.15 ms** with the bundled WASM, **+1.35 ms** with a pure-JS ML-KEM | per **connection**, not per request — amortised across every request that reuses it |
664
- | `ciphers: { chacha20 }` | **+2.0 ms/MB**, and only if the server *selects* it | per byte. Servers with AES hardware generally prefer AES-GCM, so the usual cost is zero and the offer is what matters |
665
- | `decoders: { br }` | **+4.4 ms/MB** | per byte, whenever an origin serves brotli. Harder compression is worse, not better: quality 11 is 16% smaller on the wire and 46% dearer to decode |
666
- | `profile: chrome` | the sum of the three above | |
766
+ | `ciphers: { chacha20 }` | **+2.95 ms/MB** (bundled WASM AEAD 4.89 against AES-GCM 1.95), and only if the server *selects* it | per byte. Servers with AES hardware generally prefer AES-GCM, so the usual cost is zero and the offer is what matters |
767
+ | `decoders: { br }` | **+4.2 ms/MB** over the native gzip path (7.0 against 2.75) | per byte, whenever an origin serves brotli |
768
+ | `decoders: { zstd }` | **+2.8 ms/MB** (5.5 against 2.75) | per byte, whenever an origin serves zstd |
769
+ | `profile: chrome` via `tunnelfetch/profile/chrome` | **+3 ms once per isolate** for four WASM modules, then the per-byte rows above as origins use them | |
667
770
 
668
771
  Two defaults moved in 1.4.0 and neither is visible in the table above them: matching curl's cipher
669
772
  order means AES-256-GCM is negotiated where AES-128-GCM used to be, measured at **+4%** per MB
@@ -682,31 +785,76 @@ the measurements above, with the charge split out so it is clear what is yours t
682
785
 
683
786
  | Workload | CPU/request | 10M/mo, cold | 10M/mo, warmed | 1B/mo, cold | 1B/mo, warmed |
684
787
  | --- | --- | --- | --- | --- | --- |
685
- | Platform `fetch` — reference; it cannot use a proxy | 0.3 ms | $5.00 | $5.00 | $307.40 | $307.40 |
686
- | Pooled connection, 16 KB pages | 3.3 ms | $5.93 | $5.28 | $454.60 | $389.60 |
687
- | Pooled connection, 1 MB pages | 9.2 ms | $7.11 | $6.46 | $572.60 | $507.60 |
688
- | New connection per request, 16 KB | 11 ms | $7.47 | $6.82 | $608.60 | $543.60 |
689
- | New connection per request, 1 MB | 14.5 ms | $8.17 | $7.52 | $678.60 | $613.60 |
690
- | New connection per request, 4 MB | 30 ms | $11.27 | $10.62 | $988.60 | $923.60 |
788
+ | Platform `fetch`, 16 KB — reference; it cannot use a proxy | 0.3 ms | $5.00 | $5.00 | $307.40 | $307.40 |
789
+ | Platform `fetch`, 4 MB same reference, measured | 3.2 ms | $5.04 | $5.04 | $365.40 | $365.40 |
790
+ | Pooled connection, 16 KB pages | 3.1 ms | $5.90 | $5.24 | $451.20 | $385.20 |
791
+ | New connection per request, 16 KB | 10.6 ms | $7.41 | $6.75 | $602.20 | $536.20 |
792
+ | Pooled connection, 1 MB pages | 53.3 ms | $15.94 | $15.28 | $1455.20 | $1389.20 |
793
+ | New connection per request, 1 MB | 60.8 ms | $17.45 | $16.79 | $1606.20 | $1540.20 |
794
+ | Pooled connection, 4 MB pages | 118.3 ms | $28.94 | $28.28 | $2755.20 | $2689.20 |
795
+ | New connection per request, 4 MB | 125.8 ms | $30.45 | $29.79 | $2906.20 | $2840.20 |
796
+
797
+ The reference row is given at two sizes because the platform's own `fetch` is **not flat** — it
798
+ scales at about 0.82 ms per decompressed MB, measured on a size ladder from one CDN so that only the
799
+ size changes. Quoting it as a single 0.3 ms and comparing that against a 4 MB row was a like-for-
800
+ unlike comparison, and it flattered this package's competition rather than this package.
801
+
802
+ These dollar figures follow the corrected CPU measurements above, so the body-heavy rows are **two
803
+ to three times** what this table said through 1.4.0. That correction is not a regression in the
804
+ package; it is the removal of an origin whose content compressed 220:1.
691
805
 
692
806
  "Cold" carries the measured fresh-isolate ramp of +4.4 ms per request amortised; "warmed" is the
693
- same workload with `warmup({ iterations: 5 })`, which brings the ramp down to +1.1 ms. The saving is
694
- $0.65/month at ten million requests and $65/month at a billion, identical across every row because
695
- the ramp is a property of the isolate rather than of the request. The reference row carries no ramp
696
- because the platform's own `fetch` has no JavaScript protocol stack to tier up.
807
+ same workload with `warmup({ iterations: 5 })`, which brings it to +1.1 ms. The saving is $0.66/month
808
+ at ten million requests and $66/month at a billion, identical across every row because the ramp is a
809
+ property of the isolate rather than of the request. The reference rows carry no ramp: the platform's
810
+ `fetch` has no JavaScript protocol stack to tier up.
811
+
812
+ #### What each Chrome-identity option costs
813
+
814
+ The rows above are the default identity: gzip on the wire, AES-256-GCM, x25519. The Chrome row
815
+ bundles every change together, which is not much use for deciding. Priced one at a time against a
816
+ pooled 1 MB workload at a billion requests a month, warmed:
817
+
818
+ | Change from the baseline | CPU/request | 1B/mo, warmed | Δ | Paid when |
819
+ | --- | --- | --- | --- | --- |
820
+ | baseline — gzip, AES-256-GCM, x25519 | 53.3 ms | $1,389 | — | always |
821
+ | origin serves `br` instead of gzip | 57.6 ms | $1,474 | **+$85** | the origin chooses `br` |
822
+ | server selects ChaCha20-Poly1305 | 56.3 ms | $1,448 | **+$59** | the server picks it over AES |
823
+ | origin serves `zstd` instead of gzip | 56.1 ms | $1,444 | **+$55** | the origin chooses `zstd` |
824
+ | X25519MLKEM768, 1 request per connection | 61.0 ms | $1,542 | **+$153** | every handshake |
825
+ | X25519MLKEM768, 20 requests per connection | 53.3 ms | $1,390 | **+$0.15** | the same handshake, amortised |
826
+
827
+ The last two rows are the same 0.15 ms of ML-KEM, and the difference between them is entirely
828
+ connection reuse — which is the point worth taking from this table. Post-quantum key exchange is
829
+ the cheapest thing here if you keep a `Client` alive and the most expensive if you do not, because
830
+ it is per **handshake** while everything else is per byte.
831
+
832
+ Three of the five are also **conditional and not yours to decide**. `br` and `zstd` cost nothing
833
+ until an origin chooses to serve them, and ChaCha20 costs nothing until a server prefers it over
834
+ AES-GCM — which servers with AES hardware generally do not. Offering them is what buys the
835
+ fingerprint; paying for them happens only when the other end takes you up on it.
836
+
837
+ The ChaCha20 figure is the **bundled WASM** AEAD measured against the WebCrypto AES-256-GCM it
838
+ replaces (4.89 against 1.95 ms/MB). An earlier version of this section quoted +2.0 ms/MB, which was
839
+ `node:crypto`'s ChaCha20 — a path this package does not use, because taking it would require
840
+ `nodejs_compat`.
697
841
 
698
842
  Four things fall out of it.
699
843
 
700
- **At ten million requests a month, none of this matters.** Every row lands between $5 and $11
701
- because the included quotas swallow it the included CPU works out to 3.0 ms per request at that
702
- volume, so anything that reuses connections is inside the base fee entirely, cold starts included.
844
+ **At ten million requests a month, small pages are free and big ones are not.** A pooled 16 KB
845
+ workload sits inside the base fee; a pooled 1 MB workload is $15/month. The included CPU works out
846
+ to 3.0 ms per request at that volume, which a 16 KB page fits into and a 1 MB page does not.
703
847
 
704
848
  **At a billion, $297 of every row is the request charge**, identical across all of them and
705
- unchangeable by anything this package does. Only the CPU is left to optimise, and there the
706
- difference between reusing connections and not is $154/month on 16 KB pages.
849
+ unchangeable by anything this package does. Only the CPU is left, and there the largest lever is
850
+ not connection reuse it is body size. Reuse saves $151/month on 1 MB pages; fetching 16 KB pages
851
+ instead of 1 MB ones saves $1,004.
707
852
 
708
- **Pooled and warmed, the whole userland stack costs about 27% more than the platform's own
709
- `fetch`** $390 against $307 for something the platform's `fetch` cannot do at all.
853
+ **Body-heavy work is where the userland stack actually costs something.** Pooled and warmed on
854
+ 16 KB pages it is 25% above the platform's own `fetch` $385 against $307. On 1 MB pages it is
855
+ **3.8x** — $1,389 against $365 — because every byte is decrypted, reassembled and decompressed in
856
+ JavaScript, and the platform does all three in the runtime where none of it is billed. If your
857
+ workload is large bodies, that ratio is the number to plan around, not the 16 KB one.
710
858
 
711
859
  That reference row is measured, not assumed, and it is not flat. Fetching real pages of different
712
860
  sizes from the same Worker, marginal cost per request on a reused connection:
@@ -727,7 +875,7 @@ these are small numbers, so the ratios bounce between 3× and 13× and are not m
727
875
  not worth quoting to one decimal place.
728
876
 
729
877
  **`warmup()` is free on Standard and usually worth it elsewhere.** Its own cost is startup CPU,
730
- which Workers Standard does not bill, so the $65/month the "warmed" columns save at a billion
878
+ which Workers Standard does not bill, so the $66/month the "warmed" columns save at a billion
731
879
  requests is a pure saving.
732
880
  Where startup CPU *is* billed — dynamic Worker loading, for instance — the 22 ms is charged once
733
881
  per isolate and spread across the requests that isolate serves: $25/month at a billion requests and
@@ -742,18 +890,73 @@ EC chain carries two P-384 links, so **an all-ECDSA chain validates in ~3.5 ms a
742
890
  an RSA one**. If you control the origin, its certificate's key type is worth a thought.
743
891
 
744
892
  For small responses, decoding is dominated by constructing the `DecompressionStream`, not by the
745
- bytes: ~2 ms for a 559-byte body, so for small JSON `decompress: false` can be cheaper than gzip.
893
+ bytes: ~2 ms for a 559-byte body. That is a real fixed cost, but the advice this README used to
894
+ draw from it — that `decompress: false` can be cheaper for small JSON — **is backwards for anything
895
+ that is not tiny, and it was never measured against the alternative.**
896
+
897
+ Cost here scales with **wire** bytes, not decoded bytes, because every wire byte is decrypted,
898
+ reframed and moved across JS stream boundaries before the decompressor ever sees it. Measured on
899
+ the edge: receiving a 4 MB body **uncompressed** costs the same as receiving the 1.5 MB gzip of it
900
+ *and inflating that*. Turning compression off trades a decompression you would have paid for 2.7×
901
+ the bytes through the entire receive pipeline. **Leave compression on.** The fixed ~2 ms only wins
902
+ below roughly the size where a single wire read covers the whole body.
903
+
904
+ ### Cost parity with the platform's `fetch` is not reachable, and here is the floor
905
+
906
+ Two independent investigations reached this separately, which is the main reason it is stated this
907
+ flatly.
908
+
909
+ `gz-native` — the runtime's own `DecompressionStream` inflating 1.5 MB of gzip into 4 MB, collected
910
+ natively, with **no JS drain and no receive stack whatsoever** — costs **16 ms**, reproduced across
911
+ five independent sweeps. The platform's entire 4 MB `fetch`, TLS and HTTP and inflate included,
912
+ costs about **3.6 ms**.
913
+
914
+ So the cheapest way this package could possibly turn that gzip into bytes is already **4.4× the
915
+ platform's whole request**, before one byte of TLS or HTTP/2 is touched. The asymmetry is not about
916
+ code quality: **Cloudflare bills the CPU of a `DecompressionStream` running in your isolate and does
917
+ not bill the equivalent gunzip inside its own `fetch`.** Nothing written in JavaScript goes below a
918
+ billed native floor.
919
+
920
+ The remaining ~30× is the JS-orchestrated record layer, HTTP/2 demultiplexing and stream pipeline —
921
+ roughly **80% of the per-request cost at 4 MB, against 20% for decode**. An earlier version of this
922
+ section put the emphasis on decoding; that was wrong, and it sent optimisation effort at the smaller
923
+ of the two.
924
+
925
+ What would close it is a primitive that does not exist: a `startTls` that verifies the **origin**
926
+ hostname rather than the `connect()` peer, which would let the platform's own `fetch` run inside the
927
+ tunnel. That is the missing piece this whole package exists to work around, and it is worth
928
+ understanding as a **capability gap in the runtime, not a performance bug here**.
746
929
 
747
930
  For large bodies the per-byte cost is not really per byte — it is per stream-boundary crossing.
748
931
  The runtime's `DecompressionStream` emits 4096-byte chunks and its sockets deliver reads of at
749
932
  most 4096 bytes, and every chunk that crosses between the runtime and JS costs tens of
750
- microseconds regardless of size. Both hot paths therefore drain their sources with BYOB reads
751
- into 64 KiB views (a BYOB read hands over everything already buffered in one crossing, and
752
- resolves partially filled the moment any byte exists, so streaming latency is unchanged). That
753
- rebuild took the decode stage from ~28 ms to ~6 ms per MB of decompressed output — measured by
754
- A/B-ing both implementations inside one isolate: 110 ms against 23 ms for the same 4 MB body.
755
- What remains is close to floor: inflate itself (~2 ms/MB) plus materialising the body into a JS
756
- string (~1.7 ms/MB), and that last term is the one cost the platform's own `fetch` also bills.
933
+ microseconds regardless of size measured here at about **17 µs per crossing**, from a ladder
934
+ that collects the same 1 MB in 4 KiB chunks (6.0 ms/MB) through 256 KiB chunks (1.67 ms/MB).
935
+ Both hot paths therefore drain their sources with BYOB reads, which hand over everything already
936
+ buffered in one crossing and resolve partially filled the moment any byte exists, so streaming
937
+ latency is unchanged.
938
+
939
+ The view they read into is **16 KiB, and the size was swept rather than assumed**. It matters more
940
+ than it looks. The input is pumped by a JS task on the same event loop as the puller, so the
941
+ decompressor usually holds only a chunk or two when a read arrives and the read comes back
942
+ partially filled — measured over a 1 MB body: 93 reads, *all 93 partial*, average fill 11.3 KiB.
943
+ A 64 KiB view therefore allocates 5.8 MB of throwaway buffer to carry 1 MB of data. Swept on the
944
+ edge, CPU per MB of decompressed output, all five interleaved inside one isolate:
945
+
946
+ | BYOB view | 4 KiB | 8 KiB | **16 KiB** | 32 KiB | 64 KiB |
947
+ |---|---|---|---|---|---|
948
+ | decode stage, ms/MB | 19.33 | 16.00 | **13.00** | 15.33 | 17.67 |
949
+
950
+ A clean U: too small pays per-read overhead, too large pays for allocation it never fills. The
951
+ 64 KiB that used to sit here was chosen from a probe that fed the decompressor through a native
952
+ `pipeTo` — which runs ahead and *does* fill a 64 KiB view (16 reads, none partial). That is a
953
+ regime the shipped wiring never enters. The probe and the product disagreed and the probe was
954
+ believed. Correcting it cut the stage **31%**, 18.0 → 12.3 ms/MB, A/B-ed in one isolate.
955
+
956
+ What remains is **not** close to floor, and an earlier version of this section wrongly said it was.
957
+ Native inflate of the same content costs 4.3 ms/MB against the stage's 12.3, so roughly **8 ms/MB
958
+ is this package's own plumbing** — the JS input pump and the output wrapper. Closing that needs a
959
+ redesign rather than a constant, and it is the largest single item left in the body path.
757
960
 
758
961
  Importing the package is free. The 121 bundled anchors are base64 strings indexed by a hash of the
759
962
  subject DN, and only the one anchor a chain lands on is ever decoded, so startup stays at ~2 ms for