Serverless-first. Built for cold starts, tight memory, and one binary.Deploy guide →

http API

The http module starts servers. It has one function, http.serve, plus the response helpers you return from your handler.

Quick look

// server.js
http.serve({ port: 3000 }, (url, method, body) => {
  return new Response("hello");
});
ff server.js
curl http://127.0.0.1:3000/
# hello

http.serve(signatures)

Three call shapes are accepted:

http.serve(handler);                 // default port 3000
http.serve(4242, handler);           // port shorthand
http.serve({ port: 4242 }, handler); // options object
SignaturePort used
http.serve(handler)3000
http.serve(port, handler)port
http.serve(options, handler)options.port, or 3000 when omitted

The server binds to 0.0.0.0. There is no host option.

Options reference

http.serve(
  {
    port: 3000,                       // default 3000
    tls: { cert: "...", key: "..." }, // optional — enables HTTPS/WSS on the same port
    websocket: {                      // optional — enables WebSocket upgrades
      open: (socket) => {},
      message: (socket, data) => {},
      close: (socket) => {},
    },
  },
  handler,
);
OptionTypeDefaultDescription
portnumber3000Listen port
tls{ cert, key }nonePEM certificate + private key. Each value is either inline PEM (starts with -----BEGIN) or a file path. Cert max 512KB, key max 256KB. Enables HTTPS and WSS alongside plain HTTP/WS
websocket.open(socket) => voidnoneFires when a client completes the upgrade
websocket.message(socket, data) => voidnoneFires per frame; data is a string (text) or Uint8Array (binary)
websocket.close(socket) => voidnoneFires when a client disconnects

When the websocket block is present, any request carrying Connection: Upgrade, Upgrade: websocket, and Sec-WebSocket-Key is upgraded before your handler runs — the handler never sees that request, and no { status: 101 } branch is needed (a handler that would return 404 still gets the 101). The handler serves plain HTTP only:

// server.js
http.serve(
  {
    port: 3000,
    websocket: {
      open: (socket) => socket.send("welcome"),
      message: (socket, data) => socket.send(data),
      close: (socket) => console.log("bye"),
    },
  },
  (url, method, body) => new Response("http here, ws upgrades on this port"),
);

Server sockets expose send / sendBinary only — track peers by object reference in a Set (see the WebSocket guide).

Handler reference

The handler is called as (url, method, body) — all three are strings:

ArgumentTypeExample
urlstring"/greet?name=ada" (path + query)
methodstring"GET", "POST" — exactly as the client sent it (curl -X post yields "post"); normalize case before comparing
bodystringRequest text, "" when none

Return one of:

Return valueEffect
ResponseStatus, headers, and body sent as-is
{ status, body } (string or ArrayBuffer body)Sent with that status code
Promise of either shapeConnection parks until it settles
Anything else (incl. bare strings)Empty 200 response

Async handlers work — the 30s watchdog still applies:

http.serve({ port: 3000 }, async (url, method, body) => {
  const res = await fetch("https://example.com/");
  return new Response(await res.text());
});

Response helpers

Build responses with the global Response class:

return new Response("text");                          // 200 text/plain by default
return new Response("missing", { status: 404 });      // custom status
return Response.json({ hello: "world" });             // JSON + content-type, optional init
return Response.json({ id: 1 }, { status: 201 });     // with status
return Response.redirect("/new-path");                // 302 status only — no Location header
return Response.redirect("/moved", 301);              // 301 status only — no Location header
HelperSignatureNotes
new Response(body, init?)body: string | ArrayBuffer | null; init: { status?, headers? }Default content-type is text/plain when the body has none
Response.json(data, init?)any JSON-serializable dataSets content-type: application/json
Response.redirect(url, status?)redirect target + status (default 302)Known issue: sets the status but no Location header, so no client follows it — send Location yourself (see fetch Gotchas)
Response.error()—Status 0, type "error" (fetch-network-error shape)

Read bodies back with text() / json() / arrayBuffer() / bytes() / blob() / formData() — all work on Request and Response. Each body reads once (bodyUsed goes true).

Headers are case-insensitive and lowercased internally:

const h = new Headers({ "Content-Type": "text/html" });
h.get("content-type"); // "text/html"
h.set("x-id", "1");
h.append("x-id", "2");
h.getAll("x-id");      // ["1", "2"]
h.has("x-id");         // true
h.delete("x-id");
h.entries(); h.keys(); h.values();
h.forEach((value, key) => console.log(key, value));

Full example

// server.js
http.serve({ port: 3000 }, (url, method, body) => {
  const path = new URL(url, "http://localhost").pathname;

  if (path === "/json") return Response.json({ ok: true });
  if (path === "/old") return Response.redirect("/json", 301);
  if (path === "/echo" && method === "POST") {
    return new Response(body, {
      headers: { "content-type": "text/plain" },
    });
  }
  return new Response("not found", { status: 404 });
});
ff server.js
curl http://127.0.0.1:3000/json        # {"ok":true}
curl -i http://127.0.0.1:3000/old      # 301 without Location (clients won't follow)
curl -X POST http://127.0.0.1:3000/echo -d hi  # hi

Limits (summary)

Full table lives in the Limitations reference; the ones that bite here: 512 concurrent connections, 10 MB max response body (the 64 KB staging buffer spills to the heap), 30s handler watchdog (→ 504), plain listeners speak HTTP/1.1 (HTTP/2 needs TLS).

See also