HTTPS, HTTP/2, and trailers

secureTransport: "on" terminates TLS on accept. Cert and key are passed inline, not as paths — reading a file is the filesystem's privilege, so you read it yourself and serving needs nothing beyond NetListen.

JavaScript
import { serve } from "runtime:http";
import { file } from "runtime:fs";

serve(
  {
    port: 443,
    secureTransport: "on",
    cert: await file("/etc/certs/fullchain.pem").text(),
    key: await file("/etc/certs/privkey.pem").text(),
  },
  (request) => new Response(request.url), // https://…
);
request.url schemehttps: — from the listener, not a Host header
request.url hostthe client's Host / :authority, if it is a host and optional port — anything else is 400
alpndefaults to ["h2", "http/1.1"] — narrow it to pin a version
Bad cert or keyfails serve(), not each handshake
"on" without cert/keyTypeError
Failed handshakeends that connection only

wss: WebSocket servers are still a follow-up — see WebSockets.

HTTP/2

Nothing to turn on: the client picks the version, and the handler is the same function either way — one Request in, one Response out.

Over TLS the choice is ALPN, where serve() offers ["h2", "http/1.1"] and the client takes the first it speaks. On a cleartext port, a client that opens with the HTTP/2 connection preface is served h2c by prior knowledge — which is what a reverse proxy or a gRPC client that terminates TLS in front of the runtime sends. Everything else is served HTTP/1.1, exactly as before.

JavaScript
// Same server, both versions. Pin to HTTP/1.1 only if a client needs it:
serve({ port: 443, secureTransport: "on", cert, key, alpn: ["http/1.1"] }, handler);

Multiplexing, one handshake per session, HPACK-compressed headers, and a request.url rebuilt from :authority all come with it, with the handler untouched — see Internals: the HTTP server for what changes on the wire and what it costs.

Is it faster?

Only in the shape it is designed for — and it is worth measuring rather than assuming. Same server, same request count, only the version changed (bench/http2.sh, best of 3 interleaved repetitions):

client shapeHTTP/1.1HTTP/2
50 connections × 1 stream66,939 req/s53,080 req/s0.79×
1 connection × 50 streams20,157 req/s73,541 req/s3.65×

With 50 sockets already open there is nothing to multiplex, so HTTP/2 is pure framing overhead and loses. On a single connection — where HTTP/1.1 can only send the next request after reading the previous response — it wins by 3.65×. The second row is the shape a reverse proxy, an API gateway, or a gRPC client is in, which is why it is the interesting one.

Against other runtimes

On that single-connection shape esrun serves 73,541 req/s over HTTP/2, the fastest of the four measured — ahead of Bun (49,142), Node (39,700) and Deno (39,209) — while being the slowest of them over HTTP/1.1 on the same shape. Node and Bun serve cleartext h2 through node:http2 rather than their default server (node:http and Bun.serve are HTTP/1.1-only), so their own h2-vs-h1 ratios mix in an implementation change; compare the absolute numbers instead. Full table and method: bench/README.md.

Trailers

Some things are not known until the body has been produced — the status of a gRPC call, a checksum, a row count. Those travel after the body, as trailers.

JavaScript
import { serve, withTrailers } from "runtime:http";

serve({ port: 8080 }, () =>
  withTrailers(new Response(body), { "grpc-status": "0" }));

The value may be a promise, which is the shape that matters for a streaming response: the head goes out, the body streams, and the trailers follow whenever they are known. Reading them from the other side takes trailersOf, after the body has been consumed:

JavaScript
import { trailersOf } from "runtime:http";

const response = await fetch(url);
await response.text();
const status = (await trailersOf(response)).get("grpc-status");
Why these are imports, not Response options

Trailers are not part of the Fetch API, and no runtime exposes them there — Deno.serve and Bun.serve cannot send them at all, and no runtime's fetch can read them. A trailers option on Response would look standard and silently do nothing everywhere else; an import shows the dependency. It also makes esrun the only Fetch-API server that can serve native gRPC.

They go out on both versions. On HTTP/1.1 only the fields named in a Trailer header travel, and that header is added for you whenever the names are known before the head is sent — so the one case to remember is a promise on a streaming body, where you should declare Trailer yourself.

Last updated on
Edit this page