runtime:http

An HTTP/1.1 and HTTP/2 server: serve((request) => response). The handler takes a web Request and returns a web Response — the same Fetch API objects fetch uses.

Capability: NetListen

Exposed as an ES module under the runtime: scheme. Status: Available.

All I/O is async

A handler error or a non-Response return becomes a 500. Request and response bodies stream with backpressure; string/byte bodies use the buffered fast path (Content-Length). HTTP/1.1 and HTTP/2 are both served on one port — ALPN over TLS, h2c by prior knowledge on cleartext — and the version never reaches the handler. TLS terminates in-process with secureTransport: "on". See the HTTP server guide for patterns and examples.

Import

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

Functions

FunctionTypeDescriptionExample
serve(handler)(Handler) => ServerStart a server on an ephemeral port (read it from server.addr). handler is (request) => response.serve((req) => new Response("hi"))
serve(options, handler)({ hostname?, port?, secureTransport?, cert?, key?, alpn?, timeouts?, maxConnections?, maxConnectionsPerIp?, reusePort?, trustTraceHeaders? }, Handler) => ServerStart a server bound to options. hostname defaults to 0.0.0.0 (all interfaces — pass 127.0.0.1 for a loopback-only server; a locked-down host may refuse a wildcard bind). port 0 picks an ephemeral port. secureTransport: "on" terminates TLS (needs inline PEM cert + key; alpn defaults to ["h2", "http/1.1"]). timeouts bounds connections that stall — see below.serve({ port: 443, secureTransport: "on", cert, key }, handler)

Serve

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

// The handler takes a web Request and returns a web Response —
// the same Fetch API objects fetch() uses.
const server = serve({ port: 8080 }, async (request) => {
  const url = new URL(request.url);
  if (url.pathname === "/echo") {
    return new Response(await request.text(), { status: 200 });
  }
  return Response.json({ method: request.method, path: url.pathname });
});

const { hostname, port } = await server.addr;
console.log(`listening on http://${hostname}:${port}`);

Trailers

Header fields that follow the body — where gRPC carries the status of a call.

ExportSignatureNotes
withTrailers(response, trailers)(Response, HeadersInit | Promise<HeadersInit>) => ResponseSends them after the body. Returns the same Response.
trailersOf(response)(Response) => Promise<Headers>Reads them, once the body has been consumed. Empty when there are none.
JavaScript
import { serve, withTrailers, trailersOf } from "runtime:http";

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

const response = await fetch(url);
await response.text();                      // trailers follow the body
(await trailersOf(response)).get("grpc-status");

Exports rather than members of Response: trailers are not part of the Fetch API, and no runtime exposes them there. Sent on both HTTP versions. On HTTP/1.1 the response must be chunked and only fields named in a Trailer header travel — that header is added automatically when the names are known before the head goes out.

Connection info

The handler's second argument describes the connection. Taking it is optional — a one-parameter handler is unaffected.

JavaScript
serve({ port: 8080 }, (request, info) => {
  // info.remoteAddr = { transport: "tcp", hostname: "203.0.113.7", port: 54321 }
  return new Response(`hello ${info.remoteAddr.hostname}`);
});
FieldTypeNotes
remoteAddr{ transport, hostname, port } | nullThe socket peer. null when the host has no peer to report. Same shape Deno.serve passes.

remoteAddr is the socket peer and only that: behind a reverse proxy it is the proxy, and X-Forwarded-For is never consulted — a header anyone can send is not an identity until something says which hop to trust. It is delivered untouched, so a deployment that knows can resolve it itself. On HTTP/2 every stream of one connection reports the same peer.

Timeouts

timeouts bounds connections that are idle or stalled. A request in flight, a body still arriving, and a response still streaming are never interrupted.

KeyDefaultBounds
handshake10000Accept → ready to carry requests: the TLS handshake, and the wait for the first byte
headerRead30000A request head arriving in full; on HTTP/1.1 also the idle keep-alive limit
h2KeepAlive20000PING probes on an idle HTTP/2 connection; a dead peer goes within twice this
bodyRead30000A request body arriving, before the allowance bodyMinRate earns it
bodyMinRate1024Bytes per second that extend bodyRead; 0 makes it a flat cap

Each is a number of milliseconds (bodyMinRate, bytes per second). null disables one, omitting it keeps the default, and a non-number is a TypeError at the call rather than a bound port.

A body's deadline is bodyRead + received / bodyMinRate: an upload extends its own by arriving, a peer dribbling a byte a minute does not. It reaches the handler as the body stream erroring with ERR_TIMED_OUT.

JavaScript
serve({ port: 8080, timeouts: { headerRead: 5000, h2KeepAlive: null } }, handler);

Connection limit

Trace ids

Every request runs in its own runtime:context root scope with a freshly minted W3C trace id.

JavaScript
import { currentTask } from "runtime:context";
serve({ port: 8080 }, () => new Response(currentTask().traceId));

trustTraceHeaders: true adopts the id from an inbound traceparent instead. Off by default — the header comes from whoever opened the connection, so believing it lets any client stitch its requests into another tenant's trace. Set it only behind a proxy that overwrites the header. A malformed or all-zero id is never adopted.

JavaScript
serve({ port: 8080, trustTraceHeaders: true }, handler);

Nothing is minted until the program imports runtime:context.

Sharing a port

reusePort: true lets several processes bind the same address; the kernel balances new connections across them. Unix only.

JavaScript
// Each of N processes; every one must set it.
serve({ port: 8080, reusePort: true }, handler);

A plain bind on a held port is still ERR_ADDRESS_IN_USE.

maxConnections caps how many connections are served at once. Unlimited by default — the right number follows from your file-descriptor budget and the memory a connection costs, neither of which the runtime can read.

JavaScript
serve({ port: 8080, maxConnections: 10_000 }, handler);

A connection over the cap is held, not refused: the server stops accepting, so it waits in the kernel's backlog and is served as soon as a slot frees. Nothing is spent on it while it waits.

maxConnectionsPerIp caps how many of those one peer address may hold. Without it, maxConnections says nothing about whose connections fill it — one peer opening every slot fills the server exactly as a thousand peers opening one each do.

JavaScript
serve({ port: 8080, maxConnections: 10_000, maxConnectionsPerIp: 64 }, handler);

A connection over this one is refused, not held: an excess against the whole-server cap is legitimate traffic queueing for a slot, while an excess here is one client past its share, already accepted and holding a descriptor it decides when to release.

Behind a proxy, leave this off

The count is per address, and every connection through a load balancer or NAT gateway carries the same source — so a cap here caps the whole service. Use the proxy's own per-client limits. That is why it is off by default.

The per-connection limits it multiplies against are fixed:

LimitValue
HTTP/1.1 header fields100
HTTP/1.1 read buffer~408KB
HTTP/2 header list16KB (advertised in SETTINGS)
HTTP/2 concurrent streams256 (advertised in SETTINGS)

Bodies

DirectionReadableStreamstring / bytes
Requestrequest.body pulls chunks as they arriverequest.text() / .json() / .arrayBuffer() materialize the full body
ResponseChunked transfer-encoding, backpressuredContent-Length buffered fast path

Lifecycle

serve() returns a Server immediately; the accept loop runs in the background.

JavaScript
const server = serve(handler);          // ephemeral port
const { port } = await server.addr;     // resolved once listening
// … handle requests …
await server.stop();                    // stop accepting; finished resolves

Server

MemberTypeDescription
addrPromise<{ hostname, port }>The bound address; resolves once the server is listening.
finishedPromise<void>Resolves when the accept loop has ended (after stop()).
stop()Promise<void>Stop accepting and shut down; resolves once stopped.

Errors

ErrorWhen
TypeErrorThe serve() handler isn't a function; secureTransport is "on" without both cert and key, or is a value other than "on"/"off"; or a Request / Response is built with an invalid init (the Fetch API's own checks).
Errorcode "ERR_TLS" — the cert or key could not be parsed. Raised by serve() itself, so a bad cert never becomes a port that rejects every handshake.
DOMExceptionname "NotAllowedError" — the NetListen capability is not granted.

See also

Last updated on
Edit this page