tunnelfetch 1.11.0 → 1.13.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
@@ -149,6 +149,42 @@ Two related rules of §5.7 are **not** implemented, and are worth knowing if you
149
149
  security: "Leave Secure Cookies Alone" (step 16), so a plain-named `Secure` cookie set over https
150
150
  can still be overwritten from http, and the 4096-octet name-plus-value cap (step 4).
151
151
 
152
+ ### Obtaining the socket factory
153
+
154
+ `connect` is an argument rather than an import on purpose: it is the one piece this package cannot
155
+ supply itself, and taking it from the caller is what keeps every layer above it testable over an
156
+ in-memory pipe. Resolve one robustly:
157
+
158
+ ```js
159
+ import { resolveConnect } from 'tunnelfetch';
160
+
161
+ const connect = await resolveConnect({
162
+ specifiers: ['cloudflare:sockets'],
163
+ });
164
+ const client = new Client({ connect, proxy: env.PROXY_URL });
165
+ ```
166
+
167
+ The first specifier that yields a callable export wins, and a total failure names every specifier
168
+ tried and why it failed — the mistake surfaces at startup, not as "connect is not a function" from
169
+ inside the TLS layer. Two traps are closed on the way:
170
+
171
+ 1. The import is dynamic and its failure is caught. A specifier that only resolves on one runtime,
172
+ imported statically, makes the whole package unloadable everywhere else; a portable list naming
173
+ several runtimes is safe.
174
+ 2. Bundlers cannot see through a variable specifier. On a bundled deploy pass `connect` straight
175
+ in, or hand `resolveConnect` an `importModule` that closes over a literal the bundler can see:
176
+
177
+ ```js
178
+ const connect = await resolveConnect({
179
+ importModule: () => import('cloudflare:sockets'),
180
+ });
181
+ ```
182
+
183
+ `normaliseSocket` flattens one runtime socket into a plain duplex, and `normalisingConnect` wraps a
184
+ factory so every socket it returns is flattened. They exist because on the edge runtime a socket's
185
+ `readable` and `writable` are prototype accessors, so `{ ...socket }` copies neither and the first
186
+ read fails far away, inside the TLS layer, with a complaint about `getReader`.
187
+
152
188
  ### Replacing the global
153
189
 
154
190
  For libraries that only ever call the bare global:
@@ -783,29 +819,41 @@ the network, which is not billed.
783
819
 
784
820
  ### What a request costs
785
821
 
786
- Fetching a size-controlled origin through a proxy, warm, medians over seven-plus rounds on one
787
- isolate, gzip on the wire. The last column is the same numbers as a rate, which is the form worth
788
- carrying around:
822
+ Fetching `sizeorigin/` through a proxy, gzip on the wire, medians of n>=5 with every size in one
823
+ sweep and one isolate. The method is stated because the last version of this table did not state
824
+ one precisely enough to reproduce: **a warm page is `(reuse=4 - reuse=1) / 3`**, the cost of pages
825
+ two through four down a connection that is already open, and the last column is the fresh-connection
826
+ number the same sweep produced.
789
827
 
790
- | Body | Per request, reusing a connection | Per decompressed MB |
791
- | --- | --- | --- |
792
- | 1 KB | **0.5 ms** | — |
793
- | 16 KB | **3.5 ms** | 224 ms/MB |
794
- | 64 KB | **7.5 ms** | 120 ms/MB |
795
- | 256 KB | **20.5 ms** | 82 ms/MB |
796
- | 1 MB | **51.5 ms** | 51 ms/MB |
797
- | 4 MB | **104 ms** | 26 ms/MB |
798
-
799
- Opening a connection adds **7–12 ms** on top, once, however many requests follow it.
800
-
801
- **Read the per-MB column before doing any arithmetic with this table.** It falls 8.6x from end to
802
- end, so there is no such thing as a per-MB rate for this package. A least-squares line through these
803
- points is `9.17 + 24.86 x MB`, which predicts 9.17 ms for a 1 KB body against a measured 0.5 — wrong
804
- by 18x. Any budget built on a single per-MB figure will be badly wrong at one end or the other.
828
+ | Body | Warm page | Per decompressed MB | First request on a new connection |
829
+ | --- | --- | --- | --- |
830
+ | 1 KB | **1.3 ms** | — | 8 ms |
831
+ | 16 KB | **2.3 ms** | 149 ms/MB | 9 ms |
832
+ | 64 KB | **6.0 ms** | 96 ms/MB | 13 ms |
833
+ | 256 KB | **14.3 ms** | 57 ms/MB | 28 ms |
834
+ | 1 MB | **36.3 ms** | 36 ms/MB | 59 ms |
835
+ | 4 MB | **102 ms** | 26 ms/MB | 135 ms |
836
+
837
+ The last column is not "warm page plus a handshake". At 4 MB it is 33 ms above the warm page while a
838
+ handshake costs single digits, because the *first* body through a connection also runs the decode
839
+ loop before V8 has tiered it up. Budget a new origin at that column, not at the first one.
840
+
841
+ **Re-measured August 2026, and the mid-sizes moved.** The 4 MB row reproduced almost exactly
842
+ (102 against a previously published 104); 64 KB through 1 MB came in 20-30% lower than the figures
843
+ this table used to carry. That is not the socket-view change two sections below — A/B-ing the old
844
+ and new view size in one isolate moved a warm 1 MB page by about 1 ms — so it is either day-to-day
845
+ variance beyond what a single sweep can see, or a difference in how the superseded table was taken.
846
+ The old numbers are not recoverable to check, which is the argument for stating the method here.
847
+
848
+ **Read the per-MB column before doing any arithmetic with this table.** It falls 5.7x from end to
849
+ end, so there is no such thing as a per-MB rate for this package. A least-squares line through the
850
+ 16 KB and larger points is `6.30 + 24.30 x MB`, which predicts 6.33 ms for a 1 KB body against a
851
+ measured 1.3 — wrong by 4.9x. Any budget built on a single per-MB figure will be wrong at one end
852
+ or the other.
805
853
 
806
854
  The reason is that V8 tiers up **inside a single request**. A 1 MB body runs the decode loop
807
- interpreted the whole way; a 4 MB body pays that for its first megabyte and runs the rest optimised.
808
- `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
855
+ interpreted for much of its length; a 4 MB body pays that once and runs the rest optimised.
856
+ `36.3 + 3 x 21.9 = 102` fits, so the steady-state cost is about **22 ms/MB with a ~36 ms entry fee
809
857
  per request**. For a size not in the table, interpolate within it rather than extrapolating from a
810
858
  rate.
811
859
 
@@ -824,11 +872,6 @@ of excess the *whole* ramp contains. These were measured on a small body; a cold
824
872
  4 MB request has never been measured and is certainly worse, since far more of the decode loop runs
825
873
  interpreted.
826
874
 
827
- Measured through a proxy against a size-controlled origin, eight rounds per size, HTTP/2, gzip on
828
- the wire. Connection and per-request terms were separated by varying the reuse count rather than
829
- assumed — two pages against ten gives **9.8 ms to open a connection** and **2.25 ms per further
830
- request**, and the body cost is what is left.
831
-
832
875
  **These figures replace ones that were measured wrong, and the mistake is worth describing.** The
833
876
  origin they came from tiled a 150-byte HTML fragment, which gzip compressed **220:1** — so a "1 MB
834
877
  body" was four kilobytes on the wire, and every measurement taken against it priced decompression
@@ -840,19 +883,21 @@ The correction is large. Body-heavy rows are **two to three times** what this ta
840
883
  1.4.0, and no amount of care about medians or minimums would have caught it, because the numbers
841
884
  were internally consistent — they were answers to the wrong question.
842
885
 
843
- **Read the two right-hand columns as derived, because they are.** Only the pooled column is measured
844
- per size; "new connection" is the pooled figure plus a flat 7.5 ms and "averaged over 5 pages" is
845
- the pooled figure plus 1.5 ms, which is why the deltas are identical to one decimal across a 4000×
846
- range in body size. That 7.5 ms also does not agree with the 9.8 ms quoted just above it, and the
847
- 2.25 ms per further request is larger than the entire 1.7 ms a pooled 1 KB request costs, which
848
- would make a 1 KB body cost negative. The two came from different sweeps, and combining them is the
849
- cross-sweep comparison this document tells you never to make. **Treat the connection term as
850
- somewhere in 7–10 ms and do not do arithmetic with it.**
886
+ That correction is the reason the table above now measures its fresh-connection column instead of
887
+ deriving one. Through 1.11.0 that column was the warm figure plus a flat 7.5 ms and a third column
888
+ was the warm figure plus 1.5 ms, which is why their deltas were identical to one decimal across a
889
+ 4000x range in body size a derived column cannot disagree with its source, so it cannot check it
890
+ either. Both are gone.
891
+
892
+ The connection term that falls out of the current sweep is **6–7 ms** (1 KB fresh 8 ms against a
893
+ 1.3 ms warm page), consistent with the 7–10 ms this section used to advise treating it as, and still
894
+ not something to do arithmetic with: at 4 MB the gap between fresh and warm is 33 ms, and most of
895
+ that is V8 tiering rather than the handshake.
851
896
 
852
- An independent check was quoted here as agreement and is not: a real 3.6 MB file from a CDN cost
853
- 142 ms against the ~120 ms this table predicts for 4 MB. That is the model under-predicting by
854
- roughly 20%, in the same direction as the error it had just replaced. It belongs here as a caution,
855
- not as corroboration.
897
+ An independent check was once quoted here as agreement and is not: a real 3.6 MB file from a CDN
898
+ cost 142 ms where the model predicts about 94 ms. It was taken in a different sweep against a
899
+ different origin, so it does not refute the table either it belongs here as a reminder that a
900
+ single cross-sweep reading cannot confirm or deny anything in this document.
856
901
 
857
902
  Two further cautions. The 2.76:1 content is slightly *less* compressible than a typical page, so
858
903
  these are mildly conservative rather than optimistic. And CPU on this platform varies by up to ~1.5×
@@ -875,6 +920,11 @@ the edge the same way as the rest — differencing two work counts, minimum of s
875
920
  | `decoders: { zstd }` | **+2.8 ms/MB** (5.5 against 2.75) | per byte, whenever an origin serves zstd |
876
921
  | `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 | |
877
922
 
923
+ **The socket read view moved in 1.12.0**, from 64 KiB to 16 KiB, alongside the proxy tunnel becoming
924
+ a byte stream. No API changed and `tls.pullBytes` still overrides it — but on a proxied connection
925
+ that override did nothing before 1.12.0, so anyone who had tuned it was tuning a value nothing read.
926
+ Both are covered below.
927
+
878
928
  Two defaults moved in 1.4.0 and neither is visible in the table above them: matching curl's cipher
879
929
  order means AES-256-GCM is negotiated where AES-128-GCM used to be, measured at **+4%** per MB
880
930
  (1.50 against 1.45 ms/MB — hardware AES makes the extra rounds cheap), and the ordered header list
@@ -892,19 +942,29 @@ the measurements above, with the charge split out so it is clear what is yours t
892
942
 
893
943
  | Workload | CPU/request | 10M/mo | 1B/mo |
894
944
  | --- | --- | --- | --- |
895
- | Platform `fetch`, 16 KB — reference; it cannot use a proxy | 0.3 ms | $8.06 | $311.00 |
896
- | Platform `fetch`, 4 MB — same reference, measured | 3.2 ms | $8.64 | $369.00 |
897
- | Pooled connection, 16 KB pages | 3.5 ms | $8.70 | $375.00 |
898
- | New connection per request, 16 KB | 13.5 ms | $10.70 | $575.00 |
899
- | Pooled connection, 1 MB pages | 51.5 ms | $18.30 | $1,335.00 |
900
- | New connection per request, 1 MB | 61.5 ms | $20.30 | $1,535.00 |
901
- | Pooled connection, 4 MB pages | 104 ms | $28.80 | $2,385.00 |
902
- | New connection per request, 4 MB | 114 ms | $30.80 | $2,585.00 |
903
-
904
- These follow the CPU table above and nothing else. An earlier version of this section was computed
905
- from a superseded set of measurements and was left behind when that table was replaced, so the
906
- document quoted 118.3 ms and 104 ms for the same row in two places. Any figure here that does not
907
- fall out of the table above is a bug in this README.
945
+ | Platform `fetch`, 16 KB — reference; it cannot use a proxy | 0.3 ms | $5.00 | $307.40 |
946
+ | Platform `fetch`, 4 MB — same reference, measured | 3.2 ms | $5.04 | $365.40 |
947
+ | Pooled connection, 16 KB pages | 2.3 ms | $5.00 | $347.40 |
948
+ | New connection per request, 16 KB | 9 ms | $6.20 | $481.40 |
949
+ | Pooled connection, 1 MB pages | 36.3 ms | $11.66 | $1,027.40 |
950
+ | New connection per request, 1 MB | 59 ms | $16.20 | $1,481.40 |
951
+ | Pooled connection, 4 MB pages | 102 ms | $24.80 | $2,341.40 |
952
+ | New connection per request, 4 MB | 135 ms | $31.40 | $3,001.40 |
953
+ | Pooled 4 MB, `maxBodyBytes: Infinity` | 70 ms | $18.40 | $1,701.40 |
954
+
955
+ The last row is the same workload with the decompression-bomb guard off, measured in its own sweep;
956
+ it is the largest configuration-level saving in this document and it is the caller taking
957
+ responsibility for bounding the body themselves. See "Passing the body through" below.
958
+
959
+ `$5 + max(0, requests - 10M) x $0.30/M + max(0, cpu_ms - 30M) x $0.02/M`, and nothing else. The
960
+ CPU column is the warm-page and fresh-connection columns of the table above; any figure here that
961
+ does not fall out of that table is a bug in this README.
962
+
963
+ **The `max(0, ...)` is new.** The previous version of this table billed every request and every
964
+ CPU millisecond, ignoring the allowance the sentence above it describes — so it overstated the
965
+ 10M/mo column by up to 74% ($8.70 where the bill is $5.00) while being within 0.2% at 1B, where
966
+ the allowance is a rounding error. The overstatement was against this package, not for it, which
967
+ is presumably why it survived several readings.
908
968
 
909
969
  The reference row is given at two sizes because the platform's own `fetch` is **not flat** — it
910
970
  scales at about 0.82 ms per decompressed MB, measured on a size ladder from one CDN so that only the
@@ -929,12 +989,17 @@ pooled 1 MB workload at a billion requests a month, warmed:
929
989
 
930
990
  | Change from the baseline | CPU/request | 1B/mo | Δ | Paid when |
931
991
  | --- | --- | --- | --- | --- |
932
- | baseline — gzip, AES-256-GCM, x25519 | 51.5 ms | $1,335 | — | always |
933
- | origin serves `br` instead of gzip | 55.8 ms | $1,421 | **+$86** | the origin chooses `br` |
934
- | server selects ChaCha20-Poly1305 | 54.5 ms | $1,395 | **+$60** | the server picks it over AES |
935
- | origin serves `zstd` instead of gzip | 54.3 ms | $1,391 | **+$56** | the origin chooses `zstd` |
936
- | X25519MLKEM768, 1 request per connection | 59.2 ms | $1,489 | **+$154** | every handshake |
937
- | X25519MLKEM768, 20 requests per connection | 51.5 ms | $1,335 | **+$0.15** | the same handshake, amortised |
992
+ | baseline — gzip, AES-256-GCM, x25519, warm | 36.3 ms | $1,027 | — | always |
993
+ | origin serves `br` instead of gzip | 40.5 ms | $1,111 | **+$84** | the origin chooses `br` |
994
+ | server selects ChaCha20-Poly1305 | 39.3 ms | $1,086 | **+$59** | the server picks it over AES |
995
+ | origin serves `zstd` instead of gzip | 39.1 ms | $1,083 | **+$56** | the origin chooses `zstd` |
996
+ | X25519MLKEM768, 1 request per connection | 59.2 ms | $1,484 | **+$3** | every handshake |
997
+ | X25519MLKEM768, 20 requests per connection | 36.3 ms | $1,028 | **+$0.15** | the same handshake, amortised |
998
+
999
+ The ML-KEM rows are measured against the **fresh-connection** 1 MB baseline of 59 ms ($1,481), not
1000
+ against the warm one at the top; the Δ column reflects that, which is why it is $3 rather than the
1001
+ $154 an earlier version showed. That $154 was the cost of not pooling, attributed to post-quantum
1002
+ key exchange.
938
1003
 
939
1004
  The last two rows are the same 0.15 ms of ML-KEM, and the difference between them is entirely
940
1005
  connection reuse — which is the point worth taking from this table. Post-quantum key exchange is
@@ -1020,7 +1085,7 @@ below roughly the size where a single wire read covers the whole body.
1020
1085
  roughly a tenth of the CPU. This package exists because a V8 isolate cannot do that; it is not a
1021
1086
  better way to do it.
1022
1087
 
1023
- The arithmetic is worth being blunt about. A billion 4 MB requests a month costs about **$2,385** of
1088
+ The arithmetic is worth being blunt about. A billion 4 MB requests a month costs about **$2,341** of
1024
1089
  Workers CPU. That workload is roughly 386 requests a second and 4.5 Gbps sustained — **three
1025
1090
  dedicated boxes** at Hetzner-class pricing carry it for around **$600**. So for large bodies, buying
1026
1091
  servers is about **four times cheaper**, and the gap widens with body size.
@@ -1075,7 +1140,8 @@ billed native floor.
1075
1140
  The remaining ~30× is the JS-orchestrated record layer, HTTP/2 demultiplexing and stream pipeline —
1076
1141
  roughly **80% of the per-request cost at 4 MB, against 20% for decode**. An earlier version of this
1077
1142
  section put the emphasis on decoding; that was wrong, and it sent optimisation effort at the smaller
1078
- of the two.
1143
+ of the two. For which of those three layers the 80% belongs to, see "Which layer the receive path
1144
+ actually spends its megabyte on" below — the answer is not the record layer.
1079
1145
 
1080
1146
  What would close it is a primitive that does not exist: a `startTls` that verifies the **origin**
1081
1147
  hostname rather than the `connect()` peer, which would let the platform's own `fetch` run inside the
@@ -1087,9 +1153,12 @@ The runtime's `DecompressionStream` emits 4096-byte chunks and its sockets deliv
1087
1153
  most 4096 bytes, and every chunk that crosses between the runtime and JS costs tens of
1088
1154
  microseconds regardless of size — measured here at about **17 µs per crossing**, from a ladder
1089
1155
  that collects the same 1 MB in 4 KiB chunks (6.0 ms/MB) through 256 KiB chunks (1.67 ms/MB).
1090
- Both hot paths therefore drain their sources with BYOB reads, which hand over everything already
1091
- buffered in one crossing and resolve partially filled the moment any byte exists, so streaming
1092
- latency is unchanged.
1156
+ Both hot paths therefore drain their sources with BYOB reads, which collect several of those
1157
+ chunks into one crossing and resolve partially filled the moment any byte exists, so streaming
1158
+ latency is unchanged. How many they collect is the transport's decision and not the view's — a
1159
+ BYOB read never waits to fill — so a view sized far above what the transport actually hands over
1160
+ buys nothing and costs the allocation. Measured over a 4 MB body: **37 KB average fill on a direct
1161
+ socket, 8 KB through a proxy**.
1093
1162
 
1094
1163
  The view they read into is **16 KiB, and the size was swept rather than assumed**. It matters more
1095
1164
  than it looks. The input is pumped by a JS task on the same event loop as the puller, so the
@@ -1110,14 +1179,299 @@ believed. Correcting it cut the stage **31%**, 18.0 → 12.3 ms/MB, A/B-ed in on
1110
1179
 
1111
1180
  What remains is **not** close to floor, and an earlier version of this section wrongly said it was.
1112
1181
  Native inflate of the same content costs 4.3 ms/MB against the stage's 12.3, so roughly **8 ms/MB
1113
- is this package's own plumbing** — the JS input pump and the output wrapper. Closing that needs a
1114
- redesign rather than a constant, and it is the largest single item left in the body path.
1182
+ is this package's own plumbing** — the JS input pump and the output wrapper.
1183
+
1184
+ Most of that has since been closed by the native `IdentityTransformStream` relay, **but only for
1185
+ `maxBodyBytes: Infinity`**, and the default is 32 MiB. Re-measured on the edge, same op, same
1186
+ fixture, same isolate, differenced between a 1 MB and a 4 MB body:
1187
+
1188
+ | `maxBodyBytes` | decode stage, ms per decoded MB |
1189
+ |---|---|
1190
+ | `Infinity` — native relay, no JS in the byte path | **4.3** |
1191
+ | 32 MiB (the default) — pull-driven JS wrapper | **7.7** |
1192
+
1193
+ Both of those come from an isolated bench, and this document is mostly a record of isolated benches
1194
+ being wrong. This one is not: subtracting `depth=passthru` from `depth=full` on a real proxied
1195
+ request for a 4 MB body — the `Infinity` path — puts decoding at **18–29 ms**, against the 17 ms the
1196
+ 4.3 figure predicts, and at 16–25% of the whole request, which is the "20% for decode" claimed
1197
+ further up. Two instruments, one answer.
1198
+
1199
+ Priced again on a **real proxied 4 MB request** rather than on a fixture, one isolate, n=11, warm
1200
+ page as `(reuse=4 - reuse=1)/3`:
1201
+
1202
+ | `maxBodyBytes` | warm 4 MB page | first request on a new connection |
1203
+ |---|---|---|
1204
+ | 16 MiB — the default | min 87, p50 97 ms | 147 ms |
1205
+ | `Infinity` | **min 68, p50 70 ms** | 130 ms |
1206
+
1207
+ So the bomb guard costs **20-27 ms on a 4 MB body**, 5-7 ms per decoded megabyte — half again what
1208
+ the fixture predicted, and the largest single item left in the body path. At a billion 4 MB requests
1209
+ a month that is about **$540**. It is the one lever in this document that is available by
1210
+ configuration rather than by a release. That is not a bug — the cap is enforced by counting bytes
1211
+ and counting requires seeing them in JS — but the size of it was not known before and it is worth
1212
+ saying out loud rather than leaving inside a comment. The counting itself is free (7.00 with it,
1213
+ 7.33 without); it is the wrapper the counting forces that costs. If you are relaying bodies you
1214
+ already bound some other way, `maxBodyBytes: Infinity` is worth 43% of this stage.
1115
1215
 
1116
1216
  Importing the package is free. The 121 bundled anchors are base64 strings indexed by a hash of the
1117
1217
  subject DN, and only the one anchor a chain lands on is ever decoded, so startup stays at ~2 ms for
1118
1218
  the 380 KB bundle (133 KB gzipped) and a request that imports but does not use the package costs
1119
1219
  0 ms.
1120
1220
 
1221
+ ### Which layer the receive path actually spends its megabyte on
1222
+
1223
+ Every figure above is measured **through a proxy, against a Cloudflare origin**, which is the shape
1224
+ this package exists for and also the shape that makes a per-layer answer impossible: two variables
1225
+ move at once. The `wire` ladder in `live/` removes both — one nginx origin that serves the same file
1226
+ over `http` and `https`, `Range` requests fixing the wire volume exactly, and no proxy — so the
1227
+ rungs differ by exactly one layer each.
1228
+
1229
+ Per megabyte of **wire** (not of decompressed body), differenced between a 1 MB and a 4 MB range on
1230
+ a **single request**, so no per-request work is inside the division. Three independent sweeps, run
1231
+ hours apart:
1232
+
1233
+ | rung | ms per wire MB | added by this layer |
1234
+ |---|---|---|
1235
+ | raw socket, BYOB reads, no TLS | 2.0 – 3.0 | — |
1236
+ | + the TLS record layer | 4.7 – 6.7 | +2.7 – 4.3 |
1237
+ | + HTTP/2 | 9.3 – 12.0 | **+4.7 – 6.0** |
1238
+ | + the `Client`, decoding off | 13.3 – 16.0 | +2.3 – 4.0 |
1239
+
1240
+ The origin serves `.gz` files with no `Content-Encoding`, so nothing decodes on any rung and the
1241
+ decode stage is not in this table — price it separately from the section above. The top rung runs
1242
+ `maxBodyBytes: Infinity`, which also keeps the body cap out of the number.
1243
+
1244
+ The absolute values move about 30% between sweeps, which is the run-to-run variance this document
1245
+ warns about everywhere else; what does not move is the ordering. **HTTP/2 demultiplexing was the
1246
+ largest single layer in all three sweeps** — on its own it costs about as much per wire megabyte as
1247
+ the socket and the entire TLS record layer beneath it cost together. (An earlier draft of this
1248
+ paragraph said "more than", on two sweeps. The third one does not support that, and the claim it
1249
+ does support is strong enough.)
1250
+
1251
+ Everything else in this document points the other way — "42 ms of a 106 ms 4 MB request is socket
1252
+ reads and record decryption" is the figure the `pullBytes` sweep left behind, and it is what sent
1253
+ the last two rounds of optimisation at the record layer. That figure is not wrong for the path it
1254
+ was taken on; it just never separated the socket from the parsing, and the separation is where the
1255
+ answer was.
1256
+
1257
+ ### What the proxy costs, and what the origin costs
1258
+
1259
+ The ladder above runs without a proxy, which is what makes it a per-layer answer and also what
1260
+ makes it unlike the shape this package is for. Run the same rungs three ways — the same nginx origin
1261
+ direct and proxied, then a Cloudflare-fronted origin through the same proxy — and the two variables
1262
+ separate. Per megabyte of wire, differenced between a 1 MB and a 4 MB body at a single request,
1263
+ p50 of n=7-8, all twelve cells in one sweep:
1264
+
1265
+ | | socket | + the TLS record layer | total |
1266
+ |---|---|---|---|
1267
+ | nginx origin, direct | 2.3 | +7.7 | 10.0 |
1268
+ | nginx origin, **proxied** | **17.3** | +6.3 | 23.7 |
1269
+ | Cloudflare origin, **proxied** | **16.0** | +7.7 | 23.7 |
1270
+
1271
+ **The whole proxied-versus-direct gap is the socket rung, and the origin contributes nothing.** The
1272
+ two proxied rows agree to within noise, which kills the standing hypothesis that a Cloudflare
1273
+ origin's dynamic TLS record sizing was inflating these figures — it is not the origin. And the
1274
+ record layer costs the same 6-8 ms per wire megabyte on all three paths: it does not care what is
1275
+ in front of it.
1276
+
1277
+ The socket rung's 7x is two effects multiplying. Measured over a 4 MB body on the same build:
1278
+
1279
+ | | reads | average fill | ms per read |
1280
+ |---|---|---|---|
1281
+ | direct | 110 | 38 KB | 85 µs |
1282
+ | proxied | 494 | 8.5 KB | 140 µs |
1283
+
1284
+ **4.5x as many reads, each 1.6x dearer.** The fill is the proxy's relay pacing, not a view-size
1285
+ choice — `pullBytes` is already at the measured optimum for it, and raising the view does not raise
1286
+ the fill because a BYOB read never waits. There is nothing in this package to change here.
1287
+
1288
+ This is also the number that settles the Wasm question below. A Wasm record layer can only replace
1289
+ the `+6.3 to 7.7` column, minus the 1.67 ms/MB of AEAD that stays in WebCrypto — and it cannot touch
1290
+ the 16-17 ms socket rung at all, because a socket cannot write into linear memory (see below). The
1291
+ dominant term on the real path is the one Wasm has no access to.
1292
+
1293
+ ### A knob that was never connected on the path this package exists for
1294
+
1295
+ Running the same ladder **with** a proxy turned up the reason those earlier figures were so much
1296
+ larger, and it was not the network.
1297
+
1298
+ `openTunnel` finishes a CONNECT (or SOCKS5) handshake holding a buffered reader, because the peer
1299
+ may have sent tunnel payload in the same chunk as the reply, and it handed that onward wrapped in
1300
+ `new ReadableStream({ pull })`. Correct, and a **plain** stream rather than a byte stream. The TLS
1301
+ record layer asks for a BYOB reader and quietly falls back to a default one when it cannot have it,
1302
+ so *every proxied connection* lost BYOB reads — and with them `tls.pullBytes`, whose only job is to
1303
+ size them.
1304
+
1305
+ Same origin, 1 MB through the record layer, n=15 in one isolate, before the fix:
1306
+
1307
+ | | `pullBytes: 16 KiB` | `pullBytes: 1 MiB` |
1308
+ |---|---|---|
1309
+ | direct | min 25, p50 31 | min 73, p50 90 |
1310
+ | **proxied** | min 95, p50 111 | min 101, p50 121 |
1311
+
1312
+ **2.9× on a direct socket and 6% through a proxy** — the knob was not being read. Which also means
1313
+ the sweep that chose the old 64 KiB default, captioned "against a real proxied socket", was four
1314
+ samples of one configuration; the clean U it reported was run-to-run noise.
1315
+
1316
+ `src/proxy/tunnel.js` makes the tunnel a byte stream and, once the handshake's leftovers are
1317
+ drained, hands the caller's own view straight to the socket — so a read through a tunnel costs what
1318
+ a read without one costs. With the knob reaching the code, the sweep is worth having:
1319
+
1320
+ | ms, 1 MB at the record layer, p50 | 8 KiB | **16 KiB** | 32 KiB | 64 KiB | 256 KiB |
1321
+ |---|---|---|---|---|---|
1322
+ | direct | 21 | 20 | 18 | 22 | 40 |
1323
+ | proxy A | 51 | 61 | 61 | 89 | 152 |
1324
+ | proxy B | 68 | 74 | — | 102 | — |
1325
+
1326
+ Monotonic on both proxies, shallow on the direct path, and the mechanism is the average-fill figure
1327
+ above: too *large* is what costs, because the view is a ceiling that a BYOB read never waits to
1328
+ reach. The default is now **16 KiB**, which is within ~20% of the best column on all three paths
1329
+ where 64 KiB was up to 45% off, and allocates a quarter as much.
1330
+
1331
+ End to end through the real `Client` and a proxy, n=13, 16 KiB against the old 64 KiB: **63 against
1332
+ 78 ms** for 1 MB and **145 against 164 ms** for 4 MB on the median, and within noise on the minimum
1333
+ at 4 MB. Smaller than the record-layer sweep alone suggests, because HTTP/2 and the client plumbing
1334
+ above it do not scale with the view — but nothing measured anywhere favours the old value.
1335
+
1336
+ This is also a caution about the instrument rather than only about the code. The first attempt at
1337
+ the end-to-end A/B reported the two view sizes agreeing to the millisecond, which read as "the
1338
+ change does nothing"; the rig was not threading `pullBytes` into the `Client` rung at all, so both
1339
+ columns were the same configuration. Two columns agreeing *exactly* is not a null result, it is a
1340
+ wiring bug.
1341
+
1342
+ The record layer's share is now small enough to break down: of the 2.7–4.3 ms/MB it adds, WebCrypto
1343
+ AES-256-GCM over 16 KiB records is **1.67 ms/MB**, so the JavaScript record parsing itself is
1344
+ somewhere around **1–3 ms/MB**. That number closes a direction rather than opening one — see below.
1345
+
1346
+ The origin here sends 8 KiB DATA frames, so HTTP/2's share works out to roughly 35 µs per frame,
1347
+ which is the same order as the ~25–30 µs per stream-boundary crossing measured elsewhere in this
1348
+ document. It is not concentrated in any one call: coalescing queued DATA payloads into one enqueue
1349
+ was implemented and measured, and it is not the answer. See "Three optimisations that measured as
1350
+ nothing", below.
1351
+
1352
+ **And now the open question this ladder raises, stated rather than buried.** The whole stack here —
1353
+ socket, TLS, HTTP/2, `Client` — comes to 13–16 ms per wire megabyte. The proxied figure for the
1354
+ **record layer alone** is 38.5 ms for a 4 MB body, and that body is about 1.45 MB on the wire, so
1355
+ roughly 27 ms per wire megabyte for one rung. Two to five times the whole direct-socket stack, for
1356
+ a fraction of it.
1357
+
1358
+ Something in the proxied path costs several times what the same code costs on a direct socket, and
1359
+ the candidates have not been separated: the proxy adds a second TCP hop whose delivery pattern this
1360
+ package does not control, and the Cloudflare origin used there sizes its TLS records dynamically and
1361
+ chunks a gzip it generates on the fly. Both would raise the per-record and per-crossing counts
1362
+ without any code being slower.
1363
+
1364
+ **Every dollar figure in this document is derived from the proxied path**, so none of them are
1365
+ invalidated by the ladder — they measure what a real request costs, which is what a bill is made of.
1366
+ But they should not be read as measuring *this package's code*, and until the proxied path is
1367
+ decomposed the same way, "where the cost is" has an answer only for the direct one. Running the
1368
+ `wire` ladder against a proxy is the missing experiment; it needs credentials the rig takes as an
1369
+ `x-proxy` header and nothing else.
1370
+
1371
+ ### Moving TLS into WebAssembly: measured, and closed
1372
+
1373
+ The idea was to put record framing and buffer management in Wasm and leave the AEAD in JavaScript,
1374
+ where `crypto.subtle` reaches AES-NI. The ladders above bound it, and not by finding Wasm slow —
1375
+ Wasm is fast. They bound **the prize**, and separately they make **the cost mandatory**.
1376
+
1377
+ **The prize.** Everything a Wasm record layer could replace is the JavaScript record parsing: the
1378
+ TLS rung minus its AEAD, since a software AES inside Wasm has no AES-NI to reach and would have to
1379
+ cross back to `crypto.subtle` anyway. The TLS rung measured +2.7 to 4.3 ms per wire MB in one sweep
1380
+ and +6.3 to 7.7 in another; the AEAD is 1.67. So the prize is somewhere in **1–6 ms per wire
1381
+ megabyte**, and the spread between sweeps is wider than most of it.
1382
+
1383
+ **The cost is not optional.** A socket cannot write into linear memory: a BYOB read detaches the
1384
+ view's buffer and a `WebAssembly.Memory` buffer is non-detachable, so the read is refused —
1385
+ *"Unable to use non-detachable ArrayBuffer"*, measured on the edge on both the direct and the
1386
+ proxied path. Bytes must land in a JavaScript buffer and be copied in. That copy measured
1387
+ **2.67–4.67 ms per megabyte**.
1388
+
1389
+ Those two ranges overlap. The exercise is somewhere between a wash and a modest win on one rung —
1390
+ in exchange for reimplementing TLS record framing in another language and re-deriving in Rust every
1391
+ byte of the ClientHello-ordering and cipher-offer work this package exists for.
1392
+
1393
+ And it aims at the wrong rung. On the proxied path the socket alone is **16–17 ms per wire MB**,
1394
+ two to three times the entire TLS rung, and it is precisely the part the non-detachable buffer puts
1395
+ out of reach.
1396
+
1397
+ **An earlier version of this analysis reached the opposite conclusion, and the error is worth
1398
+ naming.** It put the prize at 24–28 ms per wire MB — a sixfold win against a 4.67 ms boundary — from
1399
+ the "42 ms per 4 MB body at the record layer" figure that used to sit in `util/bytes.js`. That
1400
+ figure never separated the socket from the parsing on top of it, and it was taken on a proxied path
1401
+ where BYOB was silently disabled. Both errors inflated the denominator. The rule that killed the
1402
+ idea in the end — *a native layer wins when it replaces a JavaScript one and loses when it is added
1403
+ to one* — was stated correctly at the time; the layer being replaced was simply measured at ten
1404
+ times its size.
1405
+
1406
+ Two things the measurements *do* settle, neither of which changes that:
1407
+
1408
+ - The per-crossing cost does not scale with crossings at TLS granularity: splitting the same
1409
+ transfer into 256 crossings of 16 KiB measured identically to one crossing of 4 MB. Whatever a
1410
+ Wasm design cost, it would not be the number of times it crossed.
1411
+ - Getting a megabyte into linear memory and walking record-shaped headers over it is **cheap** — so
1412
+ cheap that the loop building the test fixture dominates the same measurement, which is why no
1413
+ single number for it is quoted here. Wasm is not the problem. There is just nothing on this path
1414
+ for it to win.
1415
+
1416
+ ### Emscripten is not the problem people say it is
1417
+
1418
+ Cloudflare's Kitesurf post warns that with "Emscripten (for example) and its many layers of mocked
1419
+ dependencies, the compiled binary can get bulky and slow", which is a claim about a toolchain and
1420
+ cheap to check. `live/wasm/build.sh` builds the same three functions three ways — all `no_std` /
1421
+ freestanding, all importing nothing, all producing bit-identical output on the same input — and then
1422
+ runs the same comparison on a **real** primitive: the exact C this package already ships as its
1423
+ ChaCha20-Poly1305, recompiled by Emscripten.
1424
+
1425
+ | | module | record walk, 4 MB | ChaCha20-Poly1305 seal |
1426
+ |---|---|---|---|
1427
+ | C, `emcc -O3 -sSTANDALONE_WASM` | **460 B** | 8 ms | **5.00 ms/MB** |
1428
+ | Rust, `wasm32-unknown-unknown`, `no_std` | 632 B | 8 ms | — |
1429
+ | C, `clang --target=wasm32 -nostdlib` + `rust-lld` | 647 B | 8 ms | — |
1430
+ | C, wasi-sdk 25 `-nostdlib` — what ships | 8839 B | — | 5.67 ms/MB |
1431
+ | WebCrypto AES-256-GCM, for scale | — | — | 1.67 ms/MB |
1432
+
1433
+ Emscripten produced the **smallest** module of the three, and the record walk came back at the same
1434
+ millisecond for all three — that column is not a tie broken by rounding, it is three readings that
1435
+ never separated across eight interleaved rounds. On the cipher Emscripten came out slightly ahead of
1436
+ the wasi-sdk build this package ships, which is inside the noise and not a reason to add a second
1437
+ toolchain to the build.
1438
+
1439
+ The warning is about what you compile, not what compiles it: drag in a libc, a filesystem shim or a
1440
+ `main()` and Emscripten will emulate all of it, and the 8839-byte wasi-sdk row above is itself mostly
1441
+ libsodium rather than toolchain. But `-sSTANDALONE_WASM --no-entry` over code that calls nothing
1442
+ emits what LLVM would emit anyway. The shipped ChaCha20 stays on wasi-sdk; there is nothing here
1443
+ worth a build dependency.
1444
+
1445
+ ### Three optimisations that measured as nothing
1446
+
1447
+ Recorded because each looked obviously right, and because what killed two of them was a counter
1448
+ disagreeing with a stopwatch. All three were implemented, measured on the edge, and reverted; none
1449
+ of them is in `src/`.
1450
+
1451
+ **Recycling the BYOB pull buffer.** `ByteReader` allocates a fresh view for every pull, so the
1452
+ `pullBytes` U-curve's right arm is allocation, not boundary crossings. Reusing one store and copying
1453
+ the fill out removes that arm completely — at `pullBytes: 1 MiB`, 89 ms → 20 ms for 4 MB. It is also
1454
+ **a wash at the then-default 64 KiB** (21 against 23) and *worse* below it, because the copy-out
1455
+ stops paying for itself. Since the right answer turned out to be a *smaller* view rather than a
1456
+ cheaper large one (see below), making large views cheap buys nothing, and the change was dropped.
1457
+
1458
+ **A native `pipeTo` for the decode stage's input.** The output side of `decompressionStage` already
1459
+ avoids JavaScript entirely on the uncapped path; the input side is still a JS loop reading the source
1460
+ and writing each chunk into the decompressor. Handing the source to `pipeTo` once the 2-byte deflate
1461
+ sniff is done removes that loop — and measured identically at 64 KiB and 16 KiB input chunks, ~10%
1462
+ better only at 4 KiB. Not worth the change: it moves the source's cancellation from a reader that
1463
+ can be cancelled directly to a pipe that can only be aborted through a signal, which is a real
1464
+ teardown-semantics change on the body path in exchange for nothing.
1465
+
1466
+ **Coalescing HTTP/2 DATA payloads.** One body-stream `pull()` hands over one DATA frame, so a body
1467
+ crosses the ReadableStream boundary once per frame — 128 times per MB at this origin. Merging queued
1468
+ payloads up to 64 KiB looked like a 40% cut to the HTTP/2 rung across one sweep. It is not: the chunk
1469
+ counters came back **identical** with and without it — 512 chunks of 8192 either way — because a
1470
+ consumer that keeps up finds exactly one payload queued at every pull, so the merge never runs. Held
1471
+ open with an artificial delay so the queue could build, 8x fewer enqueues bought 0.3–1.0 ms/MB of the
1472
+ 4.7–6.0 that HTTP/2 costs. The apparent win was the minimum of nine integer-millisecond samples
1473
+ moving while the median did not.
1474
+
1121
1475
  ### Streaming APIs, and what they cost
1122
1476
 
1123
1477
  An SSE response from an LLM API is the opposite shape to everything else measured here: a small body
@@ -1236,7 +1590,9 @@ CI runs a fixed seed on every commit, as a gate; the scheduled workflow runs thr
1236
1590
  iterations with the run id as the seed, which is the half that searches new ground.
1237
1591
 
1238
1592
  `probe/` holds a reproducible capability probe that emits machine-readable JSON, and
1239
- `probe/results/` the measurements this design rests on. `live/` is the edge interop rig.
1593
+ `probe/results/` the measurements this design rests on. `live/` is the edge interop rig, and
1594
+ `sizeorigin/` is the size-controlled origin the cost table is taken against — it lived outside the
1595
+ repository until August 2026, was deleted, and took the table's reproducibility with it.
1240
1596
 
1241
1597
  Credentials are read from the environment only. The live suite fails loudly when it is not
1242
1598
  configured rather than skipping: a green tick that means "we did not check" is worse than a red one.