| | |

Bun 09 ⚡ Building Fast HTTP Servers with Bun.serve()

⏱️ Estimated read: 9 min | |

The Bun.serve() API is the runtime’s built-in HTTP server. It is not a wrapper around Node’s http module, nor does it require an external framework like Express or Fastify. The server is powered by a fork of uWebSockets, written in C++ and Zig, and exposed through a JavaScript API that uses the standard Request and Response objects . The performance claim is concrete: roughly 2.5 times more requests per second than a Node.js http.createServer on Linux for a simple “hello world” response . The API surface is small but includes features that typically require middleware: built-in routing, WebSocket upgrades, TLS, per-request timeouts, and client IP inspection.

Key point: Bun.serve() returns a Server object. The fetch handler receives a Request and returns a Response. The routes object provides built-in routing with path parameters and wildcards. The websocket handler enables WebSocket upgrades. All three can coexist in the same server instance.


Why Bun.serve() exists

The dependency problem. A Node.js HTTP server requires either the built-in http module (which is callback-based and lacks routing) or a framework like Express (which adds a dependency tree). Bun.serve() provides the server, the router, and the WebSocket handler in the runtime itself. A complete API server can be written in a single file without npm install .

The performance problem. Node’s http module is implemented in JavaScript, and its request/response cycle involves multiple object allocations. Bun’s server is implemented in native code and uses uWebSockets for the HTTP parsing and connection handling. The benchmark in Bun’s documentation shows the gap: approximately 160,000 requests per second for Bun.serve() versus 64,000 for Node’s http on the same hardware .

The routing problem. A server without routing requires manual URL parsing. Bun.serve() includes a tree-based router with SIMD-accelerated parameter decoding. Routes are defined as an object: the keys are path patterns (/users/:id), and the values are handler functions or static Response objects. The router matches in order of specificity: exact routes first, then parameter routes, then wildcards, then the global catch-all .

The WebSocket problem. A Node.js WebSocket server requires the ws package or uWebSockets.js. Bun.serve() includes WebSocket upgrade handling and a handler object with open, message, close, and drain callbacks. The server.upgrade() method upgrades the connection, and the websocket handler takes over. Topics and backpressure are built in .

The trade-off. Bun.serve() is optimized for Bun. It does not run on Node.js. The Request and Response objects are the standard Fetch API types, but the server-specific features—server.upgrade(), server.timeout(), server.requestIP()—are Bun-only. A project that needs to run on both runtimes cannot use these features.


a. The basic server and fetch handler

The simplest server defines a fetch function that returns a Response. The function receives a Request and the Server instance as arguments.

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

console.log(`Listening on ${server.url}`);

The server.url property is a URL object containing the protocol, hostname, and port. The server.port property is the actual port (useful when port: 0 is passed to let the OS choose). The server.protocol is "http" or "https" .

The fetch handler runs for every request that is not matched by a route. If a routes object is also defined, the router attempts to match first, and fetch handles the fallthrough .


b. Routing with routes

The routes object maps path patterns to handlers. A handler can be a function that returns a Response (or a Promise<Response>), or a static Response object that is returned without invoking a function .

const server = Bun.serve({
  routes: {
    "/": () => new Response("Home"),
    "/health": new Response("OK"),
    "/api/users/:id": (req) => {
      return Response.json({ id: req.params.id });
    },
    "/files/*": (req) => {
      return new Response(`File path: ${req.params["*"]}`);
    },
    "/*": () => new Response("Not found", { status: 404 }),
  },
  fetch() {
    return new Response("Fallback");
  },
});

Path parameters are accessed through req.params. The type of params is inferred from the route string when it is passed as a string literal, so req.params.id is recognized by TypeScript without a cast . The wildcard * captures the remainder of the path and is available as params["*"].

The route precedence is strict: exact routes (/users/all) are checked before parameter routes (/users/:id), which are checked before wildcards (/users/*), which are checked before the global catch-all (/*) . A request to /users/all matches the exact route, not the parameter route.

Static Response objects are optimized for zero-allocation dispatch. "/health": new Response("OK") does not invoke a function for each request; the same Response object is reused .


c. WebSocket handling

The websocket handler defines the callbacks for WebSocket connections. The server.upgrade() method upgrades an HTTP request to a WebSocket and passes contextual data to the handler.

const server = Bun.serve({
  fetch(req, server) {
    const url = new URL(req.url);
    if (url.pathname === "/chat") {
      const upgraded = server.upgrade(req, {
        data: { username: url.searchParams.get("name") },
      });
      if (upgraded) return undefined;
      return new Response("Upgrade failed", { status: 400 });
    }
    return new Response("Hello");
  },
  websocket: {
    data: {} as { username: string },
    open(ws) {
      ws.subscribe("chat");
      ws.send("Welcome to the chat");
    },
    message(ws, message) {
      ws.publish("chat", `${ws.data.username}: ${message}`);
    },
    close(ws) {
      ws.unsubscribe("chat");
    },
  },
});

The data property in the websocket handler is the type declaration for ws.data. The actual value is passed to server.upgrade() and is available in every handler . The ws.subscribe() and ws.publish() methods implement a topic-based pub/sub system. A message published to a topic is sent to every subscriber except the sender (unless publishToSelf is enabled) .

The message handler receives a string or a Buffer/ArrayBuffer/Uint8Array depending on the binaryType setting. The ws.send() method returns a number: -1 for backpressure, 0 for a dropped message, and the byte count for a successful send .


Complete Example Session

# ============================================
# PART 1: BASIC SERVER
# ============================================

const server = Bun.serve({
  port: 3000,
  fetch() {
    return new Response("Hello");
  },
});
console.log(`Listening on ${server.url}`);
# ============================================
# PART 2: ADD ROUTES
# ============================================

const server = Bun.serve({
  routes: {
    "/": () => new Response("Home"),
    "/api/status": () => Response.json({ ok: true }),
    "/users/:id": (req) => Response.json({ id: req.params.id }),
  },
  fetch() {
    return new Response("Not found", { status: 404 });
  },
});
# ============================================
# PART 3: STATIC RESPONSE
# ============================================

routes: {
  "/health": new Response("OK"),
}
# ============================================
# PART 4: WILDCARD ROUTE
# ============================================

routes: {
  "/files/*": (req) => new Response(`Path: ${req.params["*"]}`),
}
# ============================================
# PART 5: WEBSOCKET UPGRADE
# ============================================

fetch(req, server) {
  if (new URL(req.url).pathname === "/ws") {
    const upgraded = server.upgrade(req, { data: { id: 1 } });
    if (upgraded) return undefined;
    return new Response("Upgrade failed", { status: 400 });
  }
  return new Response("Not found", { status: 404 });
}
# ============================================
# PART 6: WEBSOCKET HANDLER
# ============================================

websocket: {
  data: {} as { id: number },
  message(ws, message) {
    ws.send(`Echo: ${message}`);
  },
}
# ============================================
# PART 7: PER-REQUEST TIMEOUT
# ============================================

fetch(req, server) {
  server.timeout(req, 60);
  return new Response("Long request allowed");
}
# ============================================
# PART 8: CLIENT IP
# ============================================

fetch(req, server) {
  const ip = server.requestIP(req);
  return new Response(`IP: ${ip?.address}`);
}
# ============================================
# PART 9: TLS
# ============================================

const server = Bun.serve({
  tls: {
    key: Bun.file("./key.pem"),
    cert: Bun.file("./cert.pem"),
  },
  fetch() {
    return new Response("Secure");
  },
});

The nine parts trace the server lifecycle: basic server, routing, static responses, wildcards, WebSocket upgrade, WebSocket handlers, per-request timeout, client IP inspection, and TLS.


Quick Reference

Server Options

OptionPurpose
portPort to listen on (default 3000)
hostnameBind address (default “0.0.0.0”)
fetchFallback request handler
routesRoute map (static, param, wildcard)
websocketWebSocket handler object
tlsTLS configuration (key, cert)
developmentEnable dev error messages

Server Methods

MethodPurpose
server.upgrade(req, opts)Upgrade to WebSocket
server.timeout(req, seconds)Override idle timeout
server.requestIP(req)Get client IP and port
server.reload(opts)Hot-reload handlers
server.stop()Stop the server
server.ref() / server.unref()Control process keep-alive

Server Properties

PropertyPurpose
server.urlFull URL
server.portActual port
server.protocol“http” or “https”
server.pendingRequestsIn-flight request count
server.pendingWebSocketsConnected WebSocket count

WebSocket Handler

CallbackPurpose
open(ws)Connection established
message(ws, msg)Message received
close(ws, code, reason)Connection closed
drain(ws)Backpressure relieved

Best Practices

✅ Do This:

routes: {
  "/health": new Response("OK"),
}
<!-- Use static Response for health checks -->          # ✅
server.upgrade(req, { data: { userId } });
<!-- Pass contextual data to WebSocket handler -->      # ✅
server.timeout(req, 0);
<!-- Disable timeout for Server-Sent Events -->         # ✅

❌ Don’t Do This:

# Assume fetch handler runs for matched routes
<!-- Router matches first; fetch handles fallthrough --> # ❌
# Use server.upgrade() without checking the return value
<!-- Upgrade may fail; handle the false case -->        # ❌
# Rely on pendingRequests for precise metrics
<!-- It is a snapshot, not a rate counter -->          # ❌

Common Pitfalls

PitfallWhy It HappensFix
Route not matchingPrecedence rulesPlace exact routes before parameter routes
WebSocket upgrade failsRequest not checkedHandle the false return from server.upgrade()
Streaming response closedDefault idle timeoutserver.timeout(req, 0)
req.params undefinedRoute has no parametersOnly use params on parameterized routes
TLS not workingKey/cert are paths, not contentsUse Bun.file() to read contents

Real-World Examples

1. Basic Server

Bun.serve({ fetch: () => new Response("Hi") });

2. JSON Route

routes: { "/api": () => Response.json({ ok: true }) }

3. Parameter Route

routes: { "/users/:id": (req) => Response.json({ id: req.params.id }) }

4. Wildcard Route

routes: { "/files/*": (req) => new Response(req.params["*"]) }

5. WebSocket Upgrade

server.upgrade(req, { data: { id: 1 } })

6. WebSocket Handler

websocket: { message(ws, msg) { ws.send(msg) } }

7. Client IP

server.requestIP(req)?.address

8. Per-Request Timeout

server.timeout(req, 60)

9. TLS

tls: { key: Bun.file("key.pem"), cert: Bun.file("cert.pem") }

10. Hot Reload

server.reload({ fetch: () => new Response("v2") })

Visual

Request Flow

┌──────────────────────────────────────────────┐
│  Incoming Request                            │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  Router                                      │
│  ├── Exact route? → Handler                  │
│  ├── Parameter route? → Handler              │
│  ├── Wildcard? → Handler                     │
│  └── Catch-all? → Handler                    │
└──────────────────┬───────────────────────────┘
                   │ No match
                   ▼
┌──────────────────────────────────────────────┐
│  fetch handler                               │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  Response                                    │
└──────────────────────────────────────────────┘

WebSocket Upgrade

┌──────────────────────────────────────────────┐
│  fetch(req, server)                          │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  server.upgrade(req, { data })               │
└──────────┬───────────────────────┬───────────┘
           │ true                  │ false
           ▼                       ▼
┌──────────────────────┐  ┌──────────────────────┐
│  WebSocket handler   │  │  Return HTTP Response│
│  open/message/close  │  │  (upgrade failed)    │
└──────────────────────┘  └──────────────────────┘

Summary

ItemValue
Server constructorBun.serve({ ... })
Basic handlerfetch(req, server)
Routerroutes: { "/path": handler }
Path parametersreq.params
WebSocket upgradeserver.upgrade(req, { data })
WebSocket handlerwebsocket: { open, message, close }
TLStls: { key, cert }
Client IPserver.requestIP(req)
Per-request timeoutserver.timeout(req, seconds)
Performance claim~2.5x Node.js http

Key takeaways:

  • Bun.serve() is the runtime’s HTTP server. It does not require Express or Fastify. The server, router, and WebSocket handler are built in .
  • The router is tree-based and precedence-ordered. Exact routes match before parameter routes, which match before wildcards. The fetch handler catches unmatched requests .
  • WebSocket upgrades pass contextual data. The data object passed to server.upgrade() is available as ws.data in every handler .
  • TLS is configured with key and cert contents. The Bun.file() helper reads the file contents. Passing a path string does not work .
  • The performance advantage is measurable. Bun’s benchmark shows roughly 2.5x more requests per second than Node’s http module on Linux .

Remember: Bun.serve() is not a framework. It is the server. The fetch handler is the fallback; the routes object is the router; the websocket object is the WebSocket handler. The server is the process. The port is the entry point. The Response is the output.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!