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.

Capability: NetListen

Binding a port requires the NetListen capability. The esrun CLI grants it automatically; embedders must grant it explicitly. See Security model.

Current limits

HTTP/1.1 and HTTP/2 — no HTTP/3 (QUIC). The version is negotiated per connection and never reaches your handler; see HTTP/2. TLS terminates in-process; see HTTPS.

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.

JavaScript
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).

JavaScript
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:

JavaScript
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.

JavaScript
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.

JavaScript
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:

MemberDescription
addrPromise<{ hostname, port }> — resolves once the server is listening
stop()Stop accepting new connections and shut down
finishedPromise<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.

JavaScript
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.

SituationWhat happens
A server is runningDrain in flight, then exit 128 + signal
No server is runningExit immediately
You installed a handleresrun stays out of the way
Second ^C while drainingExit immediately
Drain outlasts --shutdown-graceExit anyway (default 10000ms)

For your own cleanup, take it over with onSignal:

JavaScript
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.

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
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);

What changes on the wire, with the handler untouched:

Multiplexingmany requests in flight on one connection, answered in any order
Handshakesone TLS handshake per session, not per connection
HeadersHPACK-compressed instead of resent in full each request
request.urlrebuilt from :authority (HTTP/2 has no Host header)
Streams per connectioncapped 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 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.

Timeouts

A connection that is not making progress is closed. Nothing to turn on:

OptionDefaultWhat it bounds
timeouts.handshake10000msAccept → ready to carry requests: the TLS handshake, and the wait for the first byte
timeouts.headerRead30000msA request head arriving in full — and on HTTP/1.1, the idle keep-alive limit too
timeouts.h2KeepAlive20000msPING probes on an idle HTTP/2 connection; a dead peer goes within twice this
JavaScript
// 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.

Why these are on by default

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).

JavaScript
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

Last updated on
Edit this page