HTTP server
runtime:http provides an HTTP/1.1 and HTTP/2 server built on the same Request and Response objects that fetch uses. Handlers are plain functions — sync or async — that take a Request and return a Response.
Binding a port requires the NetListen capability. The esrun CLI grants it automatically; embedders must grant it explicitly. See Security model.
Basic server
serve(options, handler) starts the accept loop in the background and returns a Server handle immediately. Await server.addr to learn the bound address once listening has started.
import { serve } from "runtime:http"; const server = serve({ port: 8080 }, (request) => { return new Response("Hello from esrun!"); }); const { hostname, port } = await server.addr; console.log(`listening on http://${hostname}:${port}`);
serve(handler) with no options picks an ephemeral port — useful in tests. Read the port from await server.addr after starting.
Routing
There is no built-in router. Match on request.method and new URL(request.url) inside your handler, or hand requests to a framework (see Hono below).
import { serve } from "runtime:http"; const server = serve({ port: 8080 }, async (request) => { const url = new URL(request.url); if (request.method === "GET" && url.pathname === "/health") { return Response.json({ ok: true }); } if (request.method === "POST" && url.pathname === "/echo") { const body = await request.text(); return new Response(body, { headers: { "content-type": request.headers.get("content-type") ?? "text/plain" }, }); } return new Response("Not found", { status: 404 }); }); await server.addr;
For pathname patterns, pair this with the global URLPattern or a small routing helper.
Request and response bodies
Request and response bodies stream in both directions with backpressure. request.body is a ReadableStream; nothing is held in memory until you call request.text(), request.json(), or read the stream incrementally. On the response side, string and byte bodies use the buffered fast path (Content-Length); a ReadableStream is sent with chunked transfer-encoding as you produce it:
serve({ port: 8080 }, () => { const enc = new TextEncoder(); let n = 0; const body = new ReadableStream({ async pull(c) { if (n >= 10) return c.close(); c.enqueue(enc.encode(`tick ${n++}\n`)); }, }); return new Response(body, { headers: { "content-type": "text/plain" } }); });
new Response(request.body) pipes an upload straight back out without buffering either side.
Client disconnects
request.signal aborts when the client hangs up before your handler produced a response — drop expensive work nobody will read.
serve(async (request) => { const upstream = await fetch(slowUrl, { signal: request.signal }); return new Response(upstream.body); });
Reading request.signal starts the watch, so a handler that never asks costs nothing. It covers the window before the response is handed over; a client that vanishes mid-stream ends the response body stream instead.
Errors
If your handler throws or returns a value that is not a Response, the runtime answers with 500 Internal Server Error. Wrap risky logic in try/catch when you want a specific status or JSON error body.
serve({ port: 8080 }, async (request) => { try { const data = await request.json(); return Response.json({ received: data }); } catch { return Response.json({ error: "invalid JSON" }, { status: 400 }); } });
Invalid Request / Response construction (bad status codes, malformed headers) throws TypeError at construction time — the same Fetch API rules as in browsers.
Lifecycle and shutdown
serve() returns a Server with three members:
| Member | Description |
|---|---|
addr | Promise<{ hostname, port }> — resolves once the server is listening |
stop() | Stop accepting new connections and shut down |
finished | Promise<void> — resolves when the accept loop has ended |
Call await server.stop() for graceful shutdown. In-flight requests are allowed to finish; no new connections are accepted after stop() resolves.
import { serve } from "runtime:http"; const server = serve({ port: 8080 }, (req) => new Response("ok")); const { port } = await server.addr; // … run until a signal or condition … await server.stop(); await server.finished; console.log("server stopped");
Shutdown on ^C / SIGTERM
esrun handles this for you: it stops accepting, lets in-flight requests answer, and exits 130 / 143.
| Situation | What happens |
|---|---|
| A server is running | Drain in flight, then exit 128 + signal |
| No server is running | Exit immediately |
| You installed a handler | esrun stays out of the way |
Second ^C while draining | Exit immediately |
Drain outlasts --shutdown-grace | Exit anyway (default 10000ms) |
For your own cleanup, take it over with onSignal:
import { onSignal, offSignal } from "runtime:process"; const shutdown = async (signal) => { offSignal(signal, shutdown); await server.stop(); await pool.close(); }; onSignal("SIGINT", shutdown); onSignal("SIGTERM", shutdown);
HTTPS
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.
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 scheme | https: — from the listener, not a Host header |
alpn | defaults to ["h2", "http/1.1"] — narrow it to pin a version |
| Bad cert or key | fails serve(), not each handshake |
"on" without cert/key | TypeError |
| Failed handshake | ends 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.
// 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);
What changes on the wire, with the handler untouched:
| Multiplexing | many requests in flight on one connection, answered in any order |
| Handshakes | one TLS handshake per session, not per connection |
| Headers | HPACK-compressed instead of resent in full each request |
request.url | rebuilt from :authority (HTTP/2 has no Host header) |
| Streams per connection | capped at 256 (advertised in SETTINGS), so one peer cannot flood a single-threaded isolate |
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 shape | HTTP/1.1 | HTTP/2 | |
|---|---|---|---|
| 50 connections × 1 stream | 66,939 req/s | 53,080 req/s | 0.79× |
| 1 connection × 50 streams | 20,157 req/s | 73,541 req/s | 3.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.
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.
Timeouts
A connection that is not making progress is closed. Nothing to turn on:
| Option | Default | What it bounds |
|---|---|---|
timeouts.handshake | 10000ms | Accept → ready to carry requests: the TLS handshake, and the wait for the first byte |
timeouts.headerRead | 30000ms | A request head arriving in full — and on HTTP/1.1, the idle keep-alive limit too |
timeouts.h2KeepAlive | 20000ms | PING probes on an idle HTTP/2 connection; a dead peer goes within twice this |
// Milliseconds; `null` disables one; omit it to keep the default. serve({ port: 8080, timeouts: { headerRead: 5000, h2KeepAlive: null } }, handler);
They bound only connections that are idle or stalled. A request in flight, a body still arriving, and a response still streaming are never interrupted, however long they take — a live feed is unaffected by headerRead no matter how far past it runs.
Without them, a peer that completes the TCP handshake and then says nothing holds a task and a file descriptor for as long as it likes, at the cost of one syscall to it. A timeout nobody configures protects nobody, so these ship on and you opt out — null per option — rather than opting in.
On HTTP/1.1 the idle limit and the request-head limit are one timer, because waiting for the next request on a kept-alive connection is waiting for a request head: at the default, an idle connection is closed after 30s and a client that wants another request opens a new one (nginx does this at 75s, Node at 5s). HTTP/2 has no idle limit — its connections are long-lived by design — so it probes instead, which is what keeps a peer that vanished without a FIN from holding its share of the 256-stream budget until the OS TCP keepalive notices, two hours later on Linux.
Usage with Hono
Because esrun uses standard Web APIs, frameworks like Hono work when their package is on disk (install with npm, bun, or pnpm — esrun resolves node_modules but does not install packages for you).
import { serve } from "runtime:http"; import { Hono } from "hono"; const app = new Hono(); app.get("/", (c) => c.text("Hello from Hono on ESRun!")); const server = serve({ port: 8080 }, app.fetch); const { port } = await server.addr; console.log(`Hono listening on http://127.0.0.1:${port}`);
See also
runtime:httpAPI reference — full signatures, errors, and typesSockets — raw TCP/TLS when you need lower-level control
WebSockets — upgrade-friendly real-time connections
Benchmarks — HTTP throughput measurements