Skip to main content
Bun ships a built-in HTTP server powered by uWebSockets. It handles ~160,000 requests/second on Linux — roughly 2.5x Node.js — with zero dependencies.

Basic server

const server = Bun.serve({
  port: 3000,
  fetch(req: Request): Response {
    return new Response("Hello!");
  },
});

console.log(`Listening on ${server.url}`);
The fetch handler receives a standard Request and must return a Response or Promise<Response>.
Port defaults to $BUN_PORT, $PORT, or $NODE_PORT when the port option is omitted. You can also set it via bun --port=4000 server.ts.

Routing

Use the routes option (requires Bun v1.2.3+) to define path-based handlers with static paths, route parameters, and wildcards.
const server = Bun.serve({
  routes: {
    // Static routes
    "/api/status": new Response("OK"),

    // Dynamic route parameters
    "/users/:id": req => {
      return new Response(`Hello, user ${req.params.id}!`);
    },

    // Per-method handlers
    "/api/posts": {
      GET: () => new Response("List posts"),
      POST: async req => {
        const body = await req.json();
        return Response.json({ created: true, ...body });
      },
    },

    // Wildcard catch-all
    "/api/*": Response.json({ message: "Not found" }, { status: 404 }),

    // Redirect
    "/blog/hello": Response.redirect("/blog/hello/world"),

    // Serve a file
    "/favicon.ico": Bun.file("./favicon.ico"),
  },

  // Fallback for routes not matched above
  fetch(req) {
    return new Response("Not Found", { status: 404 });
  },
});

Route precedence

Routes are matched from most to least specific:
  1. Exact routes (/users/all)
  2. Parameter routes (/users/:id)
  3. Wildcard routes (/users/*)
  4. Global catch-all (/*)

Type-safe route parameters

TypeScript infers parameter names directly from the route string literal:
import type { BunRequest } from "bun";

Bun.serve({
  routes: {
    "/orgs/:orgId/repos/:repoId": req => {
      const { orgId, repoId } = req.params; // fully typed
      return Response.json({ orgId, repoId });
    },
  },
});

Query parameters

Parse query parameters from the request URL:
Bun.serve({
  fetch(req) {
    const url = new URL(req.url);
    const name = url.searchParams.get("name") ?? "world";
    return new Response(`Hello, ${name}!`);
  },
});

Reading request bodies

Bun.serve({
  routes: {
    "/api/echo": {
      POST: async req => {
        const data = await req.json();
        return Response.json(data);
      },
    },
  },
});

JSON responses

Use Response.json() to serialize data with the correct Content-Type header:
Bun.serve({
  routes: {
    "/api/user": () =>
      Response.json({
        id: 1,
        name: "Alice",
        role: "admin",
      }),
  },
});

Streaming responses

Return a ReadableStream or an async generator to stream data to the client:
Bun.serve({
  fetch(req) {
    const stream = new ReadableStream({
      async start(controller) {
        controller.enqueue("Hello");
        await Bun.sleep(500);
        controller.enqueue(", world!");
        controller.close();
      },
    });

    return new Response(stream, {
      headers: { "Content-Type": "text/plain" },
    });
  },
});
The idle timeout (default: 10 seconds) applies to streaming responses. If your stream goes quiet for longer than idleTimeout, the connection closes mid-stream. Disable it per-request with server.timeout(req, 0).

Static file serving

Return a BunFile as the response body to serve files from disk. Bun uses the sendfile(2) syscall for zero-copy transfers when possible.
Bun.serve({
  routes: {
    // Served from filesystem on every request; supports range requests and 304s
    "/download/:file": req => {
      return new Response(Bun.file(`./public/${req.params.file}`));
    },

    // Buffered in memory at startup; zero allocation per request
    "/logo.png": new Response(await Bun.file("./public/logo.png").bytes()),
  },
  fetch(req) {
    return new Response("Not Found", { status: 404 });
  },
});

TLS / HTTPS

Enable HTTPS by providing key and cert in the tls option. Bun uses BoringSSL under the hood.
Bun.serve({
  port: 443,
  tls: {
    key: Bun.file("./key.pem"),
    cert: Bun.file("./cert.pem"),
  },
  fetch(req) {
    return new Response("Secure!");
  },
});
key and cert accept a string, BunFile, Buffer, TypedArray, or an array of any of those.
Bun.serve({
  tls: {
    key: Bun.file("./key.pem"),
    cert: Bun.file("./cert.pem"),
    passphrase: "my-secret-passphrase",
  },
  fetch(req) {
    return new Response("OK");
  },
});
Bun.serve({
  tls: {
    key: Bun.file("./key.pem"),
    cert: Bun.file("./cert.pem"),
    ca: Bun.file("./ca.pem"),
  },
  fetch(req) {
    return new Response("OK");
  },
});
Bun.serve({
  tls: [
    {
      key: Bun.file("./key1.pem"),
      cert: Bun.file("./cert1.pem"),
      serverName: "api.example.com",
    },
    {
      key: Bun.file("./key2.pem"),
      cert: Bun.file("./cert2.pem"),
      serverName: "www.example.com",
    },
  ],
  fetch(req) {
    return new Response("OK");
  },
});

Error handling

Use the error handler to catch unhandled errors thrown inside fetch or route handlers and return a custom Response:
Bun.serve({
  fetch(req) {
    throw new Error("Something went wrong");
  },
  error(error) {
    console.error(error);
    return new Response(`Internal Server Error: ${error.message}`, {
      status: 500,
      headers: { "Content-Type": "text/plain" },
    });
  },
});
Enable development: true to display a full error page with stack traces in the browser during development:
Bun.serve({
  development: true,
  fetch(req) {
    throw new Error("woops!");
  },
});

Server lifecycle

server.stop()

Stop the server from accepting new connections. By default it waits for in-flight requests to finish; pass true to close all connections immediately.
const server = Bun.serve({
  fetch(req) {
    return new Response("Hello!");
  },
});

// Graceful shutdown
await server.stop();

// Force shutdown
await server.stop(true);

server.reload()

Hot-swap fetch, error, and routes handlers without restarting the process or dropping connections:
const server = Bun.serve({
  routes: {
    "/api/version": () => Response.json({ version: "1.0.0" }),
  },
  fetch(req) {
    return new Response("v1");
  },
});

server.reload({
  routes: {
    "/api/version": () => Response.json({ version: "2.0.0" }),
  },
  fetch(req) {
    return new Response("v2");
  },
});

Per-request controls

server.timeout(req, seconds)

Override the idle timeout for a single request. Pass 0 to disable it entirely — useful for Server-Sent Events and long-running streams.
Bun.serve({
  async fetch(req, server) {
    server.timeout(req, 60); // allow 60 s of inactivity for this request
    const body = await req.text();
    return new Response(`Got: ${body}`);
  },
});

server.requestIP(req)

Get the client’s IP address and port:
Bun.serve({
  fetch(req, server) {
    const addr = server.requestIP(req);
    return new Response(`Client: ${addr?.address}:${addr?.port}`);
  },
});
Returns null for Unix socket connections or closed requests.

Unix domain sockets

Listen on a Unix socket instead of a TCP port:
Bun.serve({
  unix: "/tmp/my-app.sock",
  fetch(req) {
    return new Response("Hello from Unix socket!");
  },
});

export default syntax

Instead of calling Bun.serve() explicitly, you can export default a server config object. Bun detects the fetch property and starts the server automatically:
import type { Serve } from "bun";

export default {
  port: 3000,
  fetch(req) {
    return new Response("Bun!");
  },
} satisfies Serve.Options<undefined>;

Practical example: REST API

import { Database } from "bun:sqlite";

const db = new Database("posts.db");
db.exec(`
  CREATE TABLE IF NOT EXISTS posts (
    id TEXT PRIMARY KEY,
    title TEXT NOT NULL,
    content TEXT NOT NULL,
    created_at TEXT NOT NULL
  )
`);

Bun.serve({
  routes: {
    "/api/posts": {
      GET: () => {
        const posts = db.query("SELECT * FROM posts").all();
        return Response.json(posts);
      },
      POST: async req => {
        const body = await req.json();
        const id = crypto.randomUUID();
        db.query(
          "INSERT INTO posts (id, title, content, created_at) VALUES (?, ?, ?, ?)",
        ).run(id, body.title, body.content, new Date().toISOString());
        return Response.json({ id, ...body }, { status: 201 });
      },
    },
    "/api/posts/:id": req => {
      const post = db
        .query("SELECT * FROM posts WHERE id = ?")
        .get(req.params.id);
      if (!post) return new Response("Not Found", { status: 404 });
      return Response.json(post);
    },
  },
  error(error) {
    console.error(error);
    return new Response("Internal Server Error", { status: 500 });
  },
});

Reference

interface Server extends Disposable {
  stop(closeActiveConnections?: boolean): Promise<void>;
  reload(options: Serve): void;
  fetch(request: Request | string): Response | Promise<Response>;
  upgrade<T = undefined>(
    request: Request,
    options?: { headers?: Bun.HeadersInit; data?: T },
  ): boolean;
  publish(
    topic: string,
    data: string | ArrayBufferView | ArrayBuffer | SharedArrayBuffer,
    compress?: boolean,
  ): ServerWebSocketSendStatus;
  subscriberCount(topic: string): number;
  requestIP(request: Request): SocketAddress | null;
  timeout(request: Request, seconds: number): void;
  ref(): void;
  unref(): void;
  readonly pendingRequests: number;
  readonly pendingWebSockets: number;
  readonly url: URL;
  readonly port: number;
  readonly hostname: string;
  readonly development: boolean;
  readonly id: string;
}

Build docs developers (and LLMs) love