tunnelfetch 1.9.0 → 1.12.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
@@ -470,18 +470,45 @@ extension **set** that is curl's. A test reads that gap straight out of the comm
470
470
  capture and fails if it changes, so it cannot drift quietly — but it is a real difference and a
471
471
  JA3/JA4 hash sees it.
472
472
 
473
+ `tls.omitExtensions` is the subtractive counterpart, and `status_request` (5) is what it exists for:
474
+ that is the one extension this package sends which curl does not, so an identity matching a sample
475
+ without it had no way to drop it. Dropping it gives up OCSP stapling — the only revocation signal
476
+ this package consumes — so pairing it with `trust.revocation: 'require-staple'` is refused at
477
+ configuration time rather than left to fail every connection on a certificate that was never asked
478
+ to carry a staple.
479
+
473
480
  `tls.extraExtensions` takes pre-encoded extensions and is the only way to close it. They are ordered
474
481
  like any other and reproduced on a HelloRetryRequest retry, because a second hello that changed its
475
482
  extension set would be both malformed (RFC 8446 §4.1.2) and a signal in itself. Encoding them
476
483
  correctly is the caller's job; this package does not parse what it did not build.
477
484
 
478
- **Every offered cipher must be one this package can perform.** `tls.ciphers` used to be taken
479
- verbatim, so a list containing a CBC or RSA-key-exchange suite put a number on the wire that a
480
- server could select after which the AEAD layer had nothing to build and the connection died
481
- mid-handshake. Such a list is now refused at configuration time. An explicit `TLS_CHACHA20_POLY1305_SHA256`
482
- without an injected implementation is refused too, rather than silently dropped: quietly presenting
483
- a different fingerprint from the one you asked for is the worst outcome available to a package like
484
- this one.
485
+ **Every offered cipher must be one this package can perform unless you say otherwise.**
486
+ `tls.ciphers` used to be taken verbatim, so a list containing a CBC or RSA-key-exchange suite put a
487
+ number on the wire that a server could select, after which the AEAD layer had nothing to build and
488
+ the connection died mid-handshake. Such a list is refused at configuration time by default, and an
489
+ explicit `TLS_CHACHA20_POLY1305_SHA256` with no injected implementation is refused rather than
490
+ silently dropped — quietly presenting a different fingerprint from the one you asked for is the
491
+ worst outcome available to a package like this one.
492
+
493
+ But refusing outright is the wrong default to have no escape from, because **the restriction is
494
+ itself a fingerprint**:
495
+
496
+ | | suites offered | performable here |
497
+ | --- | --- | --- |
498
+ | curl 8.21.0 | 30 | 7 |
499
+ | Chromium | 15 | 7 |
500
+
501
+ A hello restricted to what can be honoured carries a cipher list less than half the length of any
502
+ real client's, and list length and contents are exactly what a JA3 hash reads. `tls.allowUnperformableCiphers`
503
+ offers the accurate list. The trade is narrow: the first unperformable suite sits at index 5 of
504
+ curl's list and 7 of Chromium's, behind the TLS 1.3 suites, so a server with 1.3 available never
505
+ reaches one. If a server does select one the handshake fails, and the error names the option rather
506
+ than reading like a defect here.
507
+
508
+ Most of the gap is not a missing feature. Sixteen of curl's twenty-three are CBC — MAC-then-encrypt,
509
+ which cannot be implemented without a Lucky13 padding oracle in JavaScript — and two more are RSA key
510
+ exchange with no forward secrecy. Both are refusals this package intends to keep. The three that
511
+ *are* implementable are the TLS 1.2 ChaCha20-Poly1305 suites, and they are not implemented yet.
485
512
 
486
513
  Extension order matters because JA3 and JA4 hash the extension list **in wire order**, so it is most
487
514
  of what a fingerprinter reads. `pre_shared_key` is forced last whatever you ask for: RFC 8446
@@ -578,6 +605,20 @@ new Client({ connect, proxy, maxBodyBytes: Infinity }); // or any number you h
578
605
  The trade is deliberate: an unasked-for limit is discoverable the first time it bites, and names the
579
606
  option in its error. An unasked-for OOM is neither.
580
607
 
608
+ #### The proxy sees a fingerprint too
609
+
610
+ `Proxy-Connection` is a pre-standard hop header that never reached a spec. The origin never sees it;
611
+ the proxy always does. Clients disagree — some send `keep-alive`, some `close`, some omit it — so for
612
+ anyone matching a client's behaviour *at the proxy* it is part of the fingerprint, and it used to be
613
+ hard-coded.
614
+
615
+ ```js
616
+ new Client({ connect, proxy: { ...cfg, proxyConnection: 'close' } }); // or null to omit it
617
+ ```
618
+
619
+ The default stays `keep-alive`, which avoids a class of proxy that closes the tunnel after one
620
+ request. Omitting the header is not the same as sending `close`.
621
+
581
622
  ### Trust — the `verify=` knob
582
623
 
583
624
  ```js
@@ -742,29 +783,41 @@ the network, which is not billed.
742
783
 
743
784
  ### What a request costs
744
785
 
745
- Fetching a size-controlled origin through a proxy, warm, medians over seven-plus rounds on one
746
- isolate, gzip on the wire. The last column is the same numbers as a rate, which is the form worth
747
- carrying around:
786
+ Fetching `sizeorigin/` through a proxy, gzip on the wire, medians of n>=5 with every size in one
787
+ sweep and one isolate. The method is stated because the last version of this table did not state
788
+ one precisely enough to reproduce: **a warm page is `(reuse=4 - reuse=1) / 3`**, the cost of pages
789
+ two through four down a connection that is already open, and the last column is the fresh-connection
790
+ number the same sweep produced.
748
791
 
749
- | Body | Per request, reusing a connection | Per decompressed MB |
750
- | --- | --- | --- |
751
- | 1 KB | **0.5 ms** | — |
752
- | 16 KB | **3.5 ms** | 224 ms/MB |
753
- | 64 KB | **7.5 ms** | 120 ms/MB |
754
- | 256 KB | **20.5 ms** | 82 ms/MB |
755
- | 1 MB | **51.5 ms** | 51 ms/MB |
756
- | 4 MB | **104 ms** | 26 ms/MB |
757
-
758
- Opening a connection adds **7–12 ms** on top, once, however many requests follow it.
759
-
760
- **Read the per-MB column before doing any arithmetic with this table.** It falls 8.6x from end to
761
- end, so there is no such thing as a per-MB rate for this package. A least-squares line through these
762
- points is `9.17 + 24.86 x MB`, which predicts 9.17 ms for a 1 KB body against a measured 0.5 — wrong
763
- by 18x. Any budget built on a single per-MB figure will be badly wrong at one end or the other.
792
+ | Body | Warm page | Per decompressed MB | First request on a new connection |
793
+ | --- | --- | --- | --- |
794
+ | 1 KB | **1.3 ms** | — | 8 ms |
795
+ | 16 KB | **2.3 ms** | 149 ms/MB | 9 ms |
796
+ | 64 KB | **6.0 ms** | 96 ms/MB | 13 ms |
797
+ | 256 KB | **14.3 ms** | 57 ms/MB | 28 ms |
798
+ | 1 MB | **36.3 ms** | 36 ms/MB | 59 ms |
799
+ | 4 MB | **102 ms** | 26 ms/MB | 135 ms |
800
+
801
+ The last column is not "warm page plus a handshake". At 4 MB it is 33 ms above the warm page while a
802
+ handshake costs single digits, because the *first* body through a connection also runs the decode
803
+ loop before V8 has tiered it up. Budget a new origin at that column, not at the first one.
804
+
805
+ **Re-measured August 2026, and the mid-sizes moved.** The 4 MB row reproduced almost exactly
806
+ (102 against a previously published 104); 64 KB through 1 MB came in 20-30% lower than the figures
807
+ this table used to carry. That is not the socket-view change two sections below — A/B-ing the old
808
+ and new view size in one isolate moved a warm 1 MB page by about 1 ms — so it is either day-to-day
809
+ variance beyond what a single sweep can see, or a difference in how the superseded table was taken.
810
+ The old numbers are not recoverable to check, which is the argument for stating the method here.
811
+
812
+ **Read the per-MB column before doing any arithmetic with this table.** It falls 5.7x from end to
813
+ end, so there is no such thing as a per-MB rate for this package. A least-squares line through the
814
+ 16 KB and larger points is `6.30 + 24.30 x MB`, which predicts 6.33 ms for a 1 KB body against a
815
+ measured 1.3 — wrong by 4.9x. Any budget built on a single per-MB figure will be wrong at one end
816
+ or the other.
764
817
 
765
818
  The reason is that V8 tiers up **inside a single request**. A 1 MB body runs the decode loop
766
- interpreted the whole way; a 4 MB body pays that for its first megabyte and runs the rest optimised.
767
- `51.5 + 3 x 17.5 = 104` fits, so the steady-state cost is about **17.5 ms/MB with a ~51 ms entry fee
819
+ interpreted for much of its length; a 4 MB body pays that once and runs the rest optimised.
820
+ `36.3 + 3 x 21.9 = 102` fits, so the steady-state cost is about **22 ms/MB with a ~36 ms entry fee
768
821
  per request**. For a size not in the table, interpolate within it rather than extrapolating from a
769
822
  rate.
770
823
 
@@ -783,11 +836,6 @@ of excess the *whole* ramp contains. These were measured on a small body; a cold
783
836
  4 MB request has never been measured and is certainly worse, since far more of the decode loop runs
784
837
  interpreted.
785
838
 
786
- Measured through a proxy against a size-controlled origin, eight rounds per size, HTTP/2, gzip on
787
- the wire. Connection and per-request terms were separated by varying the reuse count rather than
788
- assumed — two pages against ten gives **9.8 ms to open a connection** and **2.25 ms per further
789
- request**, and the body cost is what is left.
790
-
791
839
  **These figures replace ones that were measured wrong, and the mistake is worth describing.** The
792
840
  origin they came from tiled a 150-byte HTML fragment, which gzip compressed **220:1** — so a "1 MB
793
841
  body" was four kilobytes on the wire, and every measurement taken against it priced decompression
@@ -799,19 +847,21 @@ The correction is large. Body-heavy rows are **two to three times** what this ta
799
847
  1.4.0, and no amount of care about medians or minimums would have caught it, because the numbers
800
848
  were internally consistent — they were answers to the wrong question.
801
849
 
802
- **Read the two right-hand columns as derived, because they are.** Only the pooled column is measured
803
- per size; "new connection" is the pooled figure plus a flat 7.5 ms and "averaged over 5 pages" is
804
- the pooled figure plus 1.5 ms, which is why the deltas are identical to one decimal across a 4000×
805
- range in body size. That 7.5 ms also does not agree with the 9.8 ms quoted just above it, and the
806
- 2.25 ms per further request is larger than the entire 1.7 ms a pooled 1 KB request costs, which
807
- would make a 1 KB body cost negative. The two came from different sweeps, and combining them is the
808
- cross-sweep comparison this document tells you never to make. **Treat the connection term as
809
- somewhere in 7–10 ms and do not do arithmetic with it.**
850
+ That correction is the reason the table above now measures its fresh-connection column instead of
851
+ deriving one. Through 1.11.0 that column was the warm figure plus a flat 7.5 ms and a third column
852
+ was the warm figure plus 1.5 ms, which is why their deltas were identical to one decimal across a
853
+ 4000x range in body size a derived column cannot disagree with its source, so it cannot check it
854
+ either. Both are gone.
855
+
856
+ The connection term that falls out of the current sweep is **6–7 ms** (1 KB fresh 8 ms against a
857
+ 1.3 ms warm page), consistent with the 7–10 ms this section used to advise treating it as, and still
858
+ not something to do arithmetic with: at 4 MB the gap between fresh and warm is 33 ms, and most of
859
+ that is V8 tiering rather than the handshake.
810
860
 
811
- An independent check was quoted here as agreement and is not: a real 3.6 MB file from a CDN cost
812
- 142 ms against the ~120 ms this table predicts for 4 MB. That is the model under-predicting by
813
- roughly 20%, in the same direction as the error it had just replaced. It belongs here as a caution,
814
- not as corroboration.
861
+ An independent check was once quoted here as agreement and is not: a real 3.6 MB file from a CDN
862
+ cost 142 ms where the model predicts about 94 ms. It was taken in a different sweep against a
863
+ different origin, so it does not refute the table either it belongs here as a reminder that a
864
+ single cross-sweep reading cannot confirm or deny anything in this document.
815
865
 
816
866
  Two further cautions. The 2.76:1 content is slightly *less* compressible than a typical page, so
817
867
  these are mildly conservative rather than optimistic. And CPU on this platform varies by up to ~1.5×
@@ -834,6 +884,11 @@ the edge the same way as the rest — differencing two work counts, minimum of s
834
884
  | `decoders: { zstd }` | **+2.8 ms/MB** (5.5 against 2.75) | per byte, whenever an origin serves zstd |
835
885
  | `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 | |
836
886
 
887
+ **The socket read view moved in 1.12.0**, from 64 KiB to 16 KiB, alongside the proxy tunnel becoming
888
+ a byte stream. No API changed and `tls.pullBytes` still overrides it — but on a proxied connection
889
+ that override did nothing before 1.12.0, so anyone who had tuned it was tuning a value nothing read.
890
+ Both are covered below.
891
+
837
892
  Two defaults moved in 1.4.0 and neither is visible in the table above them: matching curl's cipher
838
893
  order means AES-256-GCM is negotiated where AES-128-GCM used to be, measured at **+4%** per MB
839
894
  (1.50 against 1.45 ms/MB — hardware AES makes the extra rounds cheap), and the ordered header list
@@ -851,19 +906,29 @@ the measurements above, with the charge split out so it is clear what is yours t
851
906
 
852
907
  | Workload | CPU/request | 10M/mo | 1B/mo |
853
908
  | --- | --- | --- | --- |
854
- | Platform `fetch`, 16 KB — reference; it cannot use a proxy | 0.3 ms | $8.06 | $311.00 |
855
- | Platform `fetch`, 4 MB — same reference, measured | 3.2 ms | $8.64 | $369.00 |
856
- | Pooled connection, 16 KB pages | 3.5 ms | $8.70 | $375.00 |
857
- | New connection per request, 16 KB | 13.5 ms | $10.70 | $575.00 |
858
- | Pooled connection, 1 MB pages | 51.5 ms | $18.30 | $1,335.00 |
859
- | New connection per request, 1 MB | 61.5 ms | $20.30 | $1,535.00 |
860
- | Pooled connection, 4 MB pages | 104 ms | $28.80 | $2,385.00 |
861
- | New connection per request, 4 MB | 114 ms | $30.80 | $2,585.00 |
862
-
863
- These follow the CPU table above and nothing else. An earlier version of this section was computed
864
- from a superseded set of measurements and was left behind when that table was replaced, so the
865
- document quoted 118.3 ms and 104 ms for the same row in two places. Any figure here that does not
866
- fall out of the table above is a bug in this README.
909
+ | Platform `fetch`, 16 KB — reference; it cannot use a proxy | 0.3 ms | $5.00 | $307.40 |
910
+ | Platform `fetch`, 4 MB — same reference, measured | 3.2 ms | $5.04 | $365.40 |
911
+ | Pooled connection, 16 KB pages | 2.3 ms | $5.00 | $347.40 |
912
+ | New connection per request, 16 KB | 9 ms | $6.20 | $481.40 |
913
+ | Pooled connection, 1 MB pages | 36.3 ms | $11.66 | $1,027.40 |
914
+ | New connection per request, 1 MB | 59 ms | $16.20 | $1,481.40 |
915
+ | Pooled connection, 4 MB pages | 102 ms | $24.80 | $2,341.40 |
916
+ | New connection per request, 4 MB | 135 ms | $31.40 | $3,001.40 |
917
+ | Pooled 4 MB, `maxBodyBytes: Infinity` | 70 ms | $18.40 | $1,701.40 |
918
+
919
+ The last row is the same workload with the decompression-bomb guard off, measured in its own sweep;
920
+ it is the largest configuration-level saving in this document and it is the caller taking
921
+ responsibility for bounding the body themselves. See "Passing the body through" below.
922
+
923
+ `$5 + max(0, requests - 10M) x $0.30/M + max(0, cpu_ms - 30M) x $0.02/M`, and nothing else. The
924
+ CPU column is the warm-page and fresh-connection columns of the table above; any figure here that
925
+ does not fall out of that table is a bug in this README.
926
+
927
+ **The `max(0, ...)` is new.** The previous version of this table billed every request and every
928
+ CPU millisecond, ignoring the allowance the sentence above it describes — so it overstated the
929
+ 10M/mo column by up to 74% ($8.70 where the bill is $5.00) while being within 0.2% at 1B, where
930
+ the allowance is a rounding error. The overstatement was against this package, not for it, which
931
+ is presumably why it survived several readings.
867
932
 
868
933
  The reference row is given at two sizes because the platform's own `fetch` is **not flat** — it
869
934
  scales at about 0.82 ms per decompressed MB, measured on a size ladder from one CDN so that only the
@@ -888,12 +953,17 @@ pooled 1 MB workload at a billion requests a month, warmed:
888
953
 
889
954
  | Change from the baseline | CPU/request | 1B/mo | Δ | Paid when |
890
955
  | --- | --- | --- | --- | --- |
891
- | baseline — gzip, AES-256-GCM, x25519 | 51.5 ms | $1,335 | — | always |
892
- | origin serves `br` instead of gzip | 55.8 ms | $1,421 | **+$86** | the origin chooses `br` |
893
- | server selects ChaCha20-Poly1305 | 54.5 ms | $1,395 | **+$60** | the server picks it over AES |
894
- | origin serves `zstd` instead of gzip | 54.3 ms | $1,391 | **+$56** | the origin chooses `zstd` |
895
- | X25519MLKEM768, 1 request per connection | 59.2 ms | $1,489 | **+$154** | every handshake |
896
- | X25519MLKEM768, 20 requests per connection | 51.5 ms | $1,335 | **+$0.15** | the same handshake, amortised |
956
+ | baseline — gzip, AES-256-GCM, x25519, warm | 36.3 ms | $1,027 | — | always |
957
+ | origin serves `br` instead of gzip | 40.5 ms | $1,111 | **+$84** | the origin chooses `br` |
958
+ | server selects ChaCha20-Poly1305 | 39.3 ms | $1,086 | **+$59** | the server picks it over AES |
959
+ | origin serves `zstd` instead of gzip | 39.1 ms | $1,083 | **+$56** | the origin chooses `zstd` |
960
+ | X25519MLKEM768, 1 request per connection | 59.2 ms | $1,484 | **+$3** | every handshake |
961
+ | X25519MLKEM768, 20 requests per connection | 36.3 ms | $1,028 | **+$0.15** | the same handshake, amortised |
962
+
963
+ The ML-KEM rows are measured against the **fresh-connection** 1 MB baseline of 59 ms ($1,481), not
964
+ against the warm one at the top; the Δ column reflects that, which is why it is $3 rather than the
965
+ $154 an earlier version showed. That $154 was the cost of not pooling, attributed to post-quantum
966
+ key exchange.
897
967
 
898
968
  The last two rows are the same 0.15 ms of ML-KEM, and the difference between them is entirely
899
969
  connection reuse — which is the point worth taking from this table. Post-quantum key exchange is
@@ -979,7 +1049,7 @@ below roughly the size where a single wire read covers the whole body.
979
1049
  roughly a tenth of the CPU. This package exists because a V8 isolate cannot do that; it is not a
980
1050
  better way to do it.
981
1051
 
982
- The arithmetic is worth being blunt about. A billion 4 MB requests a month costs about **$2,385** of
1052
+ The arithmetic is worth being blunt about. A billion 4 MB requests a month costs about **$2,341** of
983
1053
  Workers CPU. That workload is roughly 386 requests a second and 4.5 Gbps sustained — **three
984
1054
  dedicated boxes** at Hetzner-class pricing carry it for around **$600**. So for large bodies, buying
985
1055
  servers is about **four times cheaper**, and the gap widens with body size.
@@ -1034,7 +1104,8 @@ billed native floor.
1034
1104
  The remaining ~30× is the JS-orchestrated record layer, HTTP/2 demultiplexing and stream pipeline —
1035
1105
  roughly **80% of the per-request cost at 4 MB, against 20% for decode**. An earlier version of this
1036
1106
  section put the emphasis on decoding; that was wrong, and it sent optimisation effort at the smaller
1037
- of the two.
1107
+ of the two. For which of those three layers the 80% belongs to, see "Which layer the receive path
1108
+ actually spends its megabyte on" below — the answer is not the record layer.
1038
1109
 
1039
1110
  What would close it is a primitive that does not exist: a `startTls` that verifies the **origin**
1040
1111
  hostname rather than the `connect()` peer, which would let the platform's own `fetch` run inside the
@@ -1046,9 +1117,12 @@ The runtime's `DecompressionStream` emits 4096-byte chunks and its sockets deliv
1046
1117
  most 4096 bytes, and every chunk that crosses between the runtime and JS costs tens of
1047
1118
  microseconds regardless of size — measured here at about **17 µs per crossing**, from a ladder
1048
1119
  that collects the same 1 MB in 4 KiB chunks (6.0 ms/MB) through 256 KiB chunks (1.67 ms/MB).
1049
- Both hot paths therefore drain their sources with BYOB reads, which hand over everything already
1050
- buffered in one crossing and resolve partially filled the moment any byte exists, so streaming
1051
- latency is unchanged.
1120
+ Both hot paths therefore drain their sources with BYOB reads, which collect several of those
1121
+ chunks into one crossing and resolve partially filled the moment any byte exists, so streaming
1122
+ latency is unchanged. How many they collect is the transport's decision and not the view's — a
1123
+ BYOB read never waits to fill — so a view sized far above what the transport actually hands over
1124
+ buys nothing and costs the allocation. Measured over a 4 MB body: **37 KB average fill on a direct
1125
+ socket, 8 KB through a proxy**.
1052
1126
 
1053
1127
  The view they read into is **16 KiB, and the size was swept rather than assumed**. It matters more
1054
1128
  than it looks. The input is pumped by a JS task on the same event loop as the puller, so the
@@ -1069,14 +1143,299 @@ believed. Correcting it cut the stage **31%**, 18.0 → 12.3 ms/MB, A/B-ed in on
1069
1143
 
1070
1144
  What remains is **not** close to floor, and an earlier version of this section wrongly said it was.
1071
1145
  Native inflate of the same content costs 4.3 ms/MB against the stage's 12.3, so roughly **8 ms/MB
1072
- is this package's own plumbing** — the JS input pump and the output wrapper. Closing that needs a
1073
- redesign rather than a constant, and it is the largest single item left in the body path.
1146
+ is this package's own plumbing** — the JS input pump and the output wrapper.
1147
+
1148
+ Most of that has since been closed by the native `IdentityTransformStream` relay, **but only for
1149
+ `maxBodyBytes: Infinity`**, and the default is 32 MiB. Re-measured on the edge, same op, same
1150
+ fixture, same isolate, differenced between a 1 MB and a 4 MB body:
1151
+
1152
+ | `maxBodyBytes` | decode stage, ms per decoded MB |
1153
+ |---|---|
1154
+ | `Infinity` — native relay, no JS in the byte path | **4.3** |
1155
+ | 32 MiB (the default) — pull-driven JS wrapper | **7.7** |
1156
+
1157
+ Both of those come from an isolated bench, and this document is mostly a record of isolated benches
1158
+ being wrong. This one is not: subtracting `depth=passthru` from `depth=full` on a real proxied
1159
+ request for a 4 MB body — the `Infinity` path — puts decoding at **18–29 ms**, against the 17 ms the
1160
+ 4.3 figure predicts, and at 16–25% of the whole request, which is the "20% for decode" claimed
1161
+ further up. Two instruments, one answer.
1162
+
1163
+ Priced again on a **real proxied 4 MB request** rather than on a fixture, one isolate, n=11, warm
1164
+ page as `(reuse=4 - reuse=1)/3`:
1165
+
1166
+ | `maxBodyBytes` | warm 4 MB page | first request on a new connection |
1167
+ |---|---|---|
1168
+ | 16 MiB — the default | min 87, p50 97 ms | 147 ms |
1169
+ | `Infinity` | **min 68, p50 70 ms** | 130 ms |
1170
+
1171
+ So the bomb guard costs **20-27 ms on a 4 MB body**, 5-7 ms per decoded megabyte — half again what
1172
+ the fixture predicted, and the largest single item left in the body path. At a billion 4 MB requests
1173
+ a month that is about **$540**. It is the one lever in this document that is available by
1174
+ configuration rather than by a release. That is not a bug — the cap is enforced by counting bytes
1175
+ and counting requires seeing them in JS — but the size of it was not known before and it is worth
1176
+ saying out loud rather than leaving inside a comment. The counting itself is free (7.00 with it,
1177
+ 7.33 without); it is the wrapper the counting forces that costs. If you are relaying bodies you
1178
+ already bound some other way, `maxBodyBytes: Infinity` is worth 43% of this stage.
1074
1179
 
1075
1180
  Importing the package is free. The 121 bundled anchors are base64 strings indexed by a hash of the
1076
1181
  subject DN, and only the one anchor a chain lands on is ever decoded, so startup stays at ~2 ms for
1077
1182
  the 380 KB bundle (133 KB gzipped) and a request that imports but does not use the package costs
1078
1183
  0 ms.
1079
1184
 
1185
+ ### Which layer the receive path actually spends its megabyte on
1186
+
1187
+ Every figure above is measured **through a proxy, against a Cloudflare origin**, which is the shape
1188
+ this package exists for and also the shape that makes a per-layer answer impossible: two variables
1189
+ move at once. The `wire` ladder in `live/` removes both — one nginx origin that serves the same file
1190
+ over `http` and `https`, `Range` requests fixing the wire volume exactly, and no proxy — so the
1191
+ rungs differ by exactly one layer each.
1192
+
1193
+ Per megabyte of **wire** (not of decompressed body), differenced between a 1 MB and a 4 MB range on
1194
+ a **single request**, so no per-request work is inside the division. Three independent sweeps, run
1195
+ hours apart:
1196
+
1197
+ | rung | ms per wire MB | added by this layer |
1198
+ |---|---|---|
1199
+ | raw socket, BYOB reads, no TLS | 2.0 – 3.0 | — |
1200
+ | + the TLS record layer | 4.7 – 6.7 | +2.7 – 4.3 |
1201
+ | + HTTP/2 | 9.3 – 12.0 | **+4.7 – 6.0** |
1202
+ | + the `Client`, decoding off | 13.3 – 16.0 | +2.3 – 4.0 |
1203
+
1204
+ The origin serves `.gz` files with no `Content-Encoding`, so nothing decodes on any rung and the
1205
+ decode stage is not in this table — price it separately from the section above. The top rung runs
1206
+ `maxBodyBytes: Infinity`, which also keeps the body cap out of the number.
1207
+
1208
+ The absolute values move about 30% between sweeps, which is the run-to-run variance this document
1209
+ warns about everywhere else; what does not move is the ordering. **HTTP/2 demultiplexing was the
1210
+ largest single layer in all three sweeps** — on its own it costs about as much per wire megabyte as
1211
+ the socket and the entire TLS record layer beneath it cost together. (An earlier draft of this
1212
+ paragraph said "more than", on two sweeps. The third one does not support that, and the claim it
1213
+ does support is strong enough.)
1214
+
1215
+ Everything else in this document points the other way — "42 ms of a 106 ms 4 MB request is socket
1216
+ reads and record decryption" is the figure the `pullBytes` sweep left behind, and it is what sent
1217
+ the last two rounds of optimisation at the record layer. That figure is not wrong for the path it
1218
+ was taken on; it just never separated the socket from the parsing, and the separation is where the
1219
+ answer was.
1220
+
1221
+ ### What the proxy costs, and what the origin costs
1222
+
1223
+ The ladder above runs without a proxy, which is what makes it a per-layer answer and also what
1224
+ makes it unlike the shape this package is for. Run the same rungs three ways — the same nginx origin
1225
+ direct and proxied, then a Cloudflare-fronted origin through the same proxy — and the two variables
1226
+ separate. Per megabyte of wire, differenced between a 1 MB and a 4 MB body at a single request,
1227
+ p50 of n=7-8, all twelve cells in one sweep:
1228
+
1229
+ | | socket | + the TLS record layer | total |
1230
+ |---|---|---|---|
1231
+ | nginx origin, direct | 2.3 | +7.7 | 10.0 |
1232
+ | nginx origin, **proxied** | **17.3** | +6.3 | 23.7 |
1233
+ | Cloudflare origin, **proxied** | **16.0** | +7.7 | 23.7 |
1234
+
1235
+ **The whole proxied-versus-direct gap is the socket rung, and the origin contributes nothing.** The
1236
+ two proxied rows agree to within noise, which kills the standing hypothesis that a Cloudflare
1237
+ origin's dynamic TLS record sizing was inflating these figures — it is not the origin. And the
1238
+ record layer costs the same 6-8 ms per wire megabyte on all three paths: it does not care what is
1239
+ in front of it.
1240
+
1241
+ The socket rung's 7x is two effects multiplying. Measured over a 4 MB body on the same build:
1242
+
1243
+ | | reads | average fill | ms per read |
1244
+ |---|---|---|---|
1245
+ | direct | 110 | 38 KB | 85 µs |
1246
+ | proxied | 494 | 8.5 KB | 140 µs |
1247
+
1248
+ **4.5x as many reads, each 1.6x dearer.** The fill is the proxy's relay pacing, not a view-size
1249
+ choice — `pullBytes` is already at the measured optimum for it, and raising the view does not raise
1250
+ the fill because a BYOB read never waits. There is nothing in this package to change here.
1251
+
1252
+ This is also the number that settles the Wasm question below. A Wasm record layer can only replace
1253
+ the `+6.3 to 7.7` column, minus the 1.67 ms/MB of AEAD that stays in WebCrypto — and it cannot touch
1254
+ the 16-17 ms socket rung at all, because a socket cannot write into linear memory (see below). The
1255
+ dominant term on the real path is the one Wasm has no access to.
1256
+
1257
+ ### A knob that was never connected on the path this package exists for
1258
+
1259
+ Running the same ladder **with** a proxy turned up the reason those earlier figures were so much
1260
+ larger, and it was not the network.
1261
+
1262
+ `openTunnel` finishes a CONNECT (or SOCKS5) handshake holding a buffered reader, because the peer
1263
+ may have sent tunnel payload in the same chunk as the reply, and it handed that onward wrapped in
1264
+ `new ReadableStream({ pull })`. Correct, and a **plain** stream rather than a byte stream. The TLS
1265
+ record layer asks for a BYOB reader and quietly falls back to a default one when it cannot have it,
1266
+ so *every proxied connection* lost BYOB reads — and with them `tls.pullBytes`, whose only job is to
1267
+ size them.
1268
+
1269
+ Same origin, 1 MB through the record layer, n=15 in one isolate, before the fix:
1270
+
1271
+ | | `pullBytes: 16 KiB` | `pullBytes: 1 MiB` |
1272
+ |---|---|---|
1273
+ | direct | min 25, p50 31 | min 73, p50 90 |
1274
+ | **proxied** | min 95, p50 111 | min 101, p50 121 |
1275
+
1276
+ **2.9× on a direct socket and 6% through a proxy** — the knob was not being read. Which also means
1277
+ the sweep that chose the old 64 KiB default, captioned "against a real proxied socket", was four
1278
+ samples of one configuration; the clean U it reported was run-to-run noise.
1279
+
1280
+ `src/proxy/tunnel.js` makes the tunnel a byte stream and, once the handshake's leftovers are
1281
+ drained, hands the caller's own view straight to the socket — so a read through a tunnel costs what
1282
+ a read without one costs. With the knob reaching the code, the sweep is worth having:
1283
+
1284
+ | ms, 1 MB at the record layer, p50 | 8 KiB | **16 KiB** | 32 KiB | 64 KiB | 256 KiB |
1285
+ |---|---|---|---|---|---|
1286
+ | direct | 21 | 20 | 18 | 22 | 40 |
1287
+ | proxy A | 51 | 61 | 61 | 89 | 152 |
1288
+ | proxy B | 68 | 74 | — | 102 | — |
1289
+
1290
+ Monotonic on both proxies, shallow on the direct path, and the mechanism is the average-fill figure
1291
+ above: too *large* is what costs, because the view is a ceiling that a BYOB read never waits to
1292
+ reach. The default is now **16 KiB**, which is within ~20% of the best column on all three paths
1293
+ where 64 KiB was up to 45% off, and allocates a quarter as much.
1294
+
1295
+ End to end through the real `Client` and a proxy, n=13, 16 KiB against the old 64 KiB: **63 against
1296
+ 78 ms** for 1 MB and **145 against 164 ms** for 4 MB on the median, and within noise on the minimum
1297
+ at 4 MB. Smaller than the record-layer sweep alone suggests, because HTTP/2 and the client plumbing
1298
+ above it do not scale with the view — but nothing measured anywhere favours the old value.
1299
+
1300
+ This is also a caution about the instrument rather than only about the code. The first attempt at
1301
+ the end-to-end A/B reported the two view sizes agreeing to the millisecond, which read as "the
1302
+ change does nothing"; the rig was not threading `pullBytes` into the `Client` rung at all, so both
1303
+ columns were the same configuration. Two columns agreeing *exactly* is not a null result, it is a
1304
+ wiring bug.
1305
+
1306
+ The record layer's share is now small enough to break down: of the 2.7–4.3 ms/MB it adds, WebCrypto
1307
+ AES-256-GCM over 16 KiB records is **1.67 ms/MB**, so the JavaScript record parsing itself is
1308
+ somewhere around **1–3 ms/MB**. That number closes a direction rather than opening one — see below.
1309
+
1310
+ The origin here sends 8 KiB DATA frames, so HTTP/2's share works out to roughly 35 µs per frame,
1311
+ which is the same order as the ~25–30 µs per stream-boundary crossing measured elsewhere in this
1312
+ document. It is not concentrated in any one call: coalescing queued DATA payloads into one enqueue
1313
+ was implemented and measured, and it is not the answer. See "Three optimisations that measured as
1314
+ nothing", below.
1315
+
1316
+ **And now the open question this ladder raises, stated rather than buried.** The whole stack here —
1317
+ socket, TLS, HTTP/2, `Client` — comes to 13–16 ms per wire megabyte. The proxied figure for the
1318
+ **record layer alone** is 38.5 ms for a 4 MB body, and that body is about 1.45 MB on the wire, so
1319
+ roughly 27 ms per wire megabyte for one rung. Two to five times the whole direct-socket stack, for
1320
+ a fraction of it.
1321
+
1322
+ Something in the proxied path costs several times what the same code costs on a direct socket, and
1323
+ the candidates have not been separated: the proxy adds a second TCP hop whose delivery pattern this
1324
+ package does not control, and the Cloudflare origin used there sizes its TLS records dynamically and
1325
+ chunks a gzip it generates on the fly. Both would raise the per-record and per-crossing counts
1326
+ without any code being slower.
1327
+
1328
+ **Every dollar figure in this document is derived from the proxied path**, so none of them are
1329
+ invalidated by the ladder — they measure what a real request costs, which is what a bill is made of.
1330
+ But they should not be read as measuring *this package's code*, and until the proxied path is
1331
+ decomposed the same way, "where the cost is" has an answer only for the direct one. Running the
1332
+ `wire` ladder against a proxy is the missing experiment; it needs credentials the rig takes as an
1333
+ `x-proxy` header and nothing else.
1334
+
1335
+ ### Moving TLS into WebAssembly: measured, and closed
1336
+
1337
+ The idea was to put record framing and buffer management in Wasm and leave the AEAD in JavaScript,
1338
+ where `crypto.subtle` reaches AES-NI. The ladders above bound it, and not by finding Wasm slow —
1339
+ Wasm is fast. They bound **the prize**, and separately they make **the cost mandatory**.
1340
+
1341
+ **The prize.** Everything a Wasm record layer could replace is the JavaScript record parsing: the
1342
+ TLS rung minus its AEAD, since a software AES inside Wasm has no AES-NI to reach and would have to
1343
+ cross back to `crypto.subtle` anyway. The TLS rung measured +2.7 to 4.3 ms per wire MB in one sweep
1344
+ and +6.3 to 7.7 in another; the AEAD is 1.67. So the prize is somewhere in **1–6 ms per wire
1345
+ megabyte**, and the spread between sweeps is wider than most of it.
1346
+
1347
+ **The cost is not optional.** A socket cannot write into linear memory: a BYOB read detaches the
1348
+ view's buffer and a `WebAssembly.Memory` buffer is non-detachable, so the read is refused —
1349
+ *"Unable to use non-detachable ArrayBuffer"*, measured on the edge on both the direct and the
1350
+ proxied path. Bytes must land in a JavaScript buffer and be copied in. That copy measured
1351
+ **2.67–4.67 ms per megabyte**.
1352
+
1353
+ Those two ranges overlap. The exercise is somewhere between a wash and a modest win on one rung —
1354
+ in exchange for reimplementing TLS record framing in another language and re-deriving in Rust every
1355
+ byte of the ClientHello-ordering and cipher-offer work this package exists for.
1356
+
1357
+ And it aims at the wrong rung. On the proxied path the socket alone is **16–17 ms per wire MB**,
1358
+ two to three times the entire TLS rung, and it is precisely the part the non-detachable buffer puts
1359
+ out of reach.
1360
+
1361
+ **An earlier version of this analysis reached the opposite conclusion, and the error is worth
1362
+ naming.** It put the prize at 24–28 ms per wire MB — a sixfold win against a 4.67 ms boundary — from
1363
+ the "42 ms per 4 MB body at the record layer" figure that used to sit in `util/bytes.js`. That
1364
+ figure never separated the socket from the parsing on top of it, and it was taken on a proxied path
1365
+ where BYOB was silently disabled. Both errors inflated the denominator. The rule that killed the
1366
+ idea in the end — *a native layer wins when it replaces a JavaScript one and loses when it is added
1367
+ to one* — was stated correctly at the time; the layer being replaced was simply measured at ten
1368
+ times its size.
1369
+
1370
+ Two things the measurements *do* settle, neither of which changes that:
1371
+
1372
+ - The per-crossing cost does not scale with crossings at TLS granularity: splitting the same
1373
+ transfer into 256 crossings of 16 KiB measured identically to one crossing of 4 MB. Whatever a
1374
+ Wasm design cost, it would not be the number of times it crossed.
1375
+ - Getting a megabyte into linear memory and walking record-shaped headers over it is **cheap** — so
1376
+ cheap that the loop building the test fixture dominates the same measurement, which is why no
1377
+ single number for it is quoted here. Wasm is not the problem. There is just nothing on this path
1378
+ for it to win.
1379
+
1380
+ ### Emscripten is not the problem people say it is
1381
+
1382
+ Cloudflare's Kitesurf post warns that with "Emscripten (for example) and its many layers of mocked
1383
+ dependencies, the compiled binary can get bulky and slow", which is a claim about a toolchain and
1384
+ cheap to check. `live/wasm/build.sh` builds the same three functions three ways — all `no_std` /
1385
+ freestanding, all importing nothing, all producing bit-identical output on the same input — and then
1386
+ runs the same comparison on a **real** primitive: the exact C this package already ships as its
1387
+ ChaCha20-Poly1305, recompiled by Emscripten.
1388
+
1389
+ | | module | record walk, 4 MB | ChaCha20-Poly1305 seal |
1390
+ |---|---|---|---|
1391
+ | C, `emcc -O3 -sSTANDALONE_WASM` | **460 B** | 8 ms | **5.00 ms/MB** |
1392
+ | Rust, `wasm32-unknown-unknown`, `no_std` | 632 B | 8 ms | — |
1393
+ | C, `clang --target=wasm32 -nostdlib` + `rust-lld` | 647 B | 8 ms | — |
1394
+ | C, wasi-sdk 25 `-nostdlib` — what ships | 8839 B | — | 5.67 ms/MB |
1395
+ | WebCrypto AES-256-GCM, for scale | — | — | 1.67 ms/MB |
1396
+
1397
+ Emscripten produced the **smallest** module of the three, and the record walk came back at the same
1398
+ millisecond for all three — that column is not a tie broken by rounding, it is three readings that
1399
+ never separated across eight interleaved rounds. On the cipher Emscripten came out slightly ahead of
1400
+ the wasi-sdk build this package ships, which is inside the noise and not a reason to add a second
1401
+ toolchain to the build.
1402
+
1403
+ The warning is about what you compile, not what compiles it: drag in a libc, a filesystem shim or a
1404
+ `main()` and Emscripten will emulate all of it, and the 8839-byte wasi-sdk row above is itself mostly
1405
+ libsodium rather than toolchain. But `-sSTANDALONE_WASM --no-entry` over code that calls nothing
1406
+ emits what LLVM would emit anyway. The shipped ChaCha20 stays on wasi-sdk; there is nothing here
1407
+ worth a build dependency.
1408
+
1409
+ ### Three optimisations that measured as nothing
1410
+
1411
+ Recorded because each looked obviously right, and because what killed two of them was a counter
1412
+ disagreeing with a stopwatch. All three were implemented, measured on the edge, and reverted; none
1413
+ of them is in `src/`.
1414
+
1415
+ **Recycling the BYOB pull buffer.** `ByteReader` allocates a fresh view for every pull, so the
1416
+ `pullBytes` U-curve's right arm is allocation, not boundary crossings. Reusing one store and copying
1417
+ the fill out removes that arm completely — at `pullBytes: 1 MiB`, 89 ms → 20 ms for 4 MB. It is also
1418
+ **a wash at the then-default 64 KiB** (21 against 23) and *worse* below it, because the copy-out
1419
+ stops paying for itself. Since the right answer turned out to be a *smaller* view rather than a
1420
+ cheaper large one (see below), making large views cheap buys nothing, and the change was dropped.
1421
+
1422
+ **A native `pipeTo` for the decode stage's input.** The output side of `decompressionStage` already
1423
+ avoids JavaScript entirely on the uncapped path; the input side is still a JS loop reading the source
1424
+ and writing each chunk into the decompressor. Handing the source to `pipeTo` once the 2-byte deflate
1425
+ sniff is done removes that loop — and measured identically at 64 KiB and 16 KiB input chunks, ~10%
1426
+ better only at 4 KiB. Not worth the change: it moves the source's cancellation from a reader that
1427
+ can be cancelled directly to a pipe that can only be aborted through a signal, which is a real
1428
+ teardown-semantics change on the body path in exchange for nothing.
1429
+
1430
+ **Coalescing HTTP/2 DATA payloads.** One body-stream `pull()` hands over one DATA frame, so a body
1431
+ crosses the ReadableStream boundary once per frame — 128 times per MB at this origin. Merging queued
1432
+ payloads up to 64 KiB looked like a 40% cut to the HTTP/2 rung across one sweep. It is not: the chunk
1433
+ counters came back **identical** with and without it — 512 chunks of 8192 either way — because a
1434
+ consumer that keeps up finds exactly one payload queued at every pull, so the merge never runs. Held
1435
+ open with an artificial delay so the queue could build, 8x fewer enqueues bought 0.3–1.0 ms/MB of the
1436
+ 4.7–6.0 that HTTP/2 costs. The apparent win was the minimum of nine integer-millisecond samples
1437
+ moving while the median did not.
1438
+
1080
1439
  ### Streaming APIs, and what they cost
1081
1440
 
1082
1441
  An SSE response from an LLM API is the opposite shape to everything else measured here: a small body
@@ -1195,7 +1554,9 @@ CI runs a fixed seed on every commit, as a gate; the scheduled workflow runs thr
1195
1554
  iterations with the run id as the seed, which is the half that searches new ground.
1196
1555
 
1197
1556
  `probe/` holds a reproducible capability probe that emits machine-readable JSON, and
1198
- `probe/results/` the measurements this design rests on. `live/` is the edge interop rig.
1557
+ `probe/results/` the measurements this design rests on. `live/` is the edge interop rig, and
1558
+ `sizeorigin/` is the size-controlled origin the cost table is taken against — it lived outside the
1559
+ repository until August 2026, was deleted, and took the table's reproducibility with it.
1199
1560
 
1200
1561
  Credentials are read from the environment only. The live suite fails loudly when it is not
1201
1562
  configured rather than skipping: a green tick that means "we did not check" is worse than a red one.