Internals: the fetch client

What fetch() does between the call and the response: which client it uses, what it negotiates, where the allowlist applies, and the one deadline it imposes on you.

This is the outbound side. The inbound server has its own page, and raw sockets another. For signatures, see the Web APIs reference.

The wire is borrowed, the policy is owned

fetch runs on the runtime's own HTTP client. The parts that are dangerous to write — HTTP/1.1 and HTTP/2 framing, connection pooling, HTTP/2 multiplexing, DNS and Happy Eyeballs — are hyper and hyper-util, the most-reviewed implementations in Rust. TLS is rustls over the process trust store. What the client decides rather than parses — redirects, content codings, deadlines — is written here, each part to the specification that defines it.

The same client serves fetch, the --otel exporter, upgrade and esdev create's registry lookups, so there is one set of defaults to get right. It is built on the first fetch, not at startup: assembling a TLS configuration costs time a script that never fetches should not pay.

What goes out

User-AgentES-Runtime/<version>, matching navigator.userAgent so a server sees the identity the guest reports. A request that sets its own overrides it.
Accept-Encodinggzip, br, deflate
HTTP version1.1 or 2, negotiated by ALPN over TLS
TEtrailers, so an HTTP/1.1 server may send them
Connect timeout30s, DNS, TCP and TLS together
TCP keepalive60s on pooled connections
TCP_NODELAYOn
ProxyOnly with --proxy: http: forwarded, https: tunnelled with CONNECT. esrun reads no variable; esdev reads HTTP(S)_PROXY

Accept-Encoding is left out of a request that has a Range, since a range of a compressed representation is not a range of the resource. TCP_NODELAY is set explicitly: hyper-util's connector leaves Nagle's algorithm on, and a request written in pieces then waits on the peer's delayed ACK.

Connections are pooled and reused across requests. The keepalive is what stops a peer that vanished without a FIN from leaving a dead socket in the pool to be handed to a later request — which would surface as a mysterious failure on a request that did nothing wrong.

The one timeout, and why it is only on connect

The 30s bound covers DNS, TCP and TLS — getting the connection up — and nothing after it. That asymmetry is deliberate.

Fetch defines no timeout, and a response body may be long-lived by design: server-sent events, a log tail, a large download. A total deadline would break correct programs, and the specification already hands the decision to the caller:

JavaScript
await fetch(url, { signal: AbortSignal.timeout(5000) });

What is never the caller's intent is waiting forever on a peer that never completes a handshake, which is exactly what an unbounded connect does. So the phase with no legitimate reason to be slow is bounded, and the phase that has one is left to the caller.

Content codings

Accept-Encoding: gzip, br, deflate goes out; a response in any of those arrives decoded, with Content-Encoding and Content-Length dropped from the headers — because those would otherwise describe bytes the guest never sees. This is Fetch's "decode" step.

Two details that are easy to get wrong and are not:

  • Decoding keys off the response, not off who asked. A server that compresses without being asked is still handled.

  • An unknown coding passes through untouched, headers intact. If a server answers zstd, the guest gets the compressed bytes and the header that says so — the honest answer, since pretending to have decoded it would be a lie.

Decoding happens as the body streams, at most 64 KiB at a time, so a small compressed body that expands enormously is handed over in bounded pieces rather than in one allocation. A HEAD, a 204, a 304 or an empty body is not decoded; a corrupt one fails as a body error rather than producing garbage.

zstd is deliberately not negotiated. The three codings offered are exactly the set CompressionStream implements, which is what Fetch's decode step needs a client to understand; advertising a fourth would mean carrying a codec present for no other reason.

Redirects

Following is capped at 20 hops, the Fetch specification's own limit, and each hop follows its HTTP-redirect fetch:

  • A 303, and a 301 or 302 for a POST, becomes a GET. The body goes, and so do the headers that described it (Content-Type, Content-Length, Content-Encoding, Content-Language, Content-Location).

  • A 307 or 308 resends the method and the body. A buffered body is sent again; a ReadableStream has already been consumed, so the fetch fails rather than sending an empty body in its place, which would be a different request.

  • A hop to another origin — scheme, host or port — drops Authorization, Cookie, Cookie2, Proxy-Authorization and WWW-Authenticate. A token meant for one service is never handed to whatever it redirects to.

  • A Location that is not http: or https: fails the fetch. A relative one resolves against the current URL, and one without a fragment keeps the request's.

When a --allow-net list is in force, every hop is checked before it is sent, not just the URL the guest wrote. Otherwise a 302 from an allowed host to a denied one would be followed and its body handed back — the allowlist enforced at the front door and nowhere else. A refused hop fails the request with ERR_PERMISSION_DENIED, distinguishable from an exhausted redirect budget (ERR_TOO_MANY_REDIRECTS).

Request bodies stream

A ReadableStream request body is sent with chunked transfer-encoding, pulled as the connection drains, so an upload is never fully buffered in memory. A buffered body (string, bytes) crosses inline.

Capability

fetch is gated on Net — the same capability as runtime:net's connect(), because they are the same privilege: reaching out. The allowlist is checked against the host as written, before DNS resolution, for the reasons set out in the sockets page.

Failures name the origin and path

A request that does not complete rejects with a TypeError whose code says why (ERR_CONNECTION_REFUSED, ERR_DNS, ERR_TLS, …). Its message names the URL by origin and path only. Credentials, the query string and the fragment are left out of the message and of its cause, because that is where a program's tokens travel, and error text is what logs and error collectors keep. The program still has the full URL; it built it. See D141.

See also

Last updated on
Edit this page