Bun HTTP — Native HTTP Server with Bun.serve()
Status: Active | Last Updated: 2026-08-29 Category: Backend — HTTP Prerequisites: Server basics; Bun runtime Tags: bun, http, server, fetch, bun-serve, websockets Estimated Time: 3-4 hours (Self-paced, includes lab time)
Summary
Bun has a built-in HTTP server that uses the Web standard Request and Response objects — no separate framework needed for simple APIs. Bun.serve() handles routing, request parsing, JSON serialization, WebSockets, and streaming. This article covers the patterns you'll use most often.
What You'll Learn (Core Competencies)
- Use
Bun.serve()to create a minimal HTTP server with a fetch handler - Implement routing by URL pattern and HTTP method using the Web URL API
- Read JSON, form data, plain text, and headers from incoming requests
- Return text, JSON, and binary responses with correct status codes and headers
- Implement Server-Sent Events (SSE) and WebSocket endpoints
Table of Contents
- Architectural Overview & Core Schema
- Deep Dive & Implementation
- Anti-Patterns & Common Pitfalls
- Independent Challenge
- Consolidation & Key Invariants
- Next Steps
1. Architectural Overview & Core Schema
Bun.serve() is Bun's built-in HTTP server. It takes a configuration object with a fetch handler. Bun handles socket binding, HTTP parsing, and request lifecycle automatically.
Bun.serve({
port: 3000,
fetch(request) {
return new Response("Hello, fogserv");
}
});
console.log("Listening on http://localhost:3000");
The fetch function is called for every incoming HTTP request. It receives a Request object and must return a Response (or a Promise<Response>). This contract makes middleware and routing straightforward: wrap the fetch handler, inspect the request, and either return a response or call the inner handler.
Because Bun.serve() implements the Web fetch handler interface, the same code works in Bun, Deno, browsers, and Cloudflare Workers. The skills transfer across runtimes. Bun binds to port 3000 by default and starts a TCP listener automatically.
2. Deep Dive & Implementation
Routing and Methods
Add routes by checking request.method and the URL path. Extract the pathname with new URL(request.url).pathname:
Bun.serve({
port: 3000,
async fetch(request) {
const { pathname } = new URL(request.url);
if (pathname === "/" && request.method === "GET") {
return new Response("Welcome");
}
if (pathname.startsWith("/posts/")) {
const id = pathname.split("/")[2];
return Response.json({ post: { id } });
}
if (pathname === "/api/users" && request.method === "POST") {
const body = await request.json();
return Response.json({ created: body }, { status: 201 });
}
return new Response("Not Found", { status: 404 });
}
});
For larger APIs, consider a router library (hono, bun-router) instead of writing if-chains. Each library has trade-offs: hono supports middleware stacks and typed routes; bun-router is minimal and fast; raw Bun.serve() avoids dependencies entirely.
When routing manually, use simple string operations (===, startsWith, split) rather than regex. String operations are faster and easier to debug for the common cases.
Reading Request Bodies
// JSON
const json = await request.json();
// Form data
const form = await request.formData();
const name = form.get("name");
// Plain text
const text = await request.text();
// Path parameters
const url = new URL(request.url);
const id = url.pathname.split("/")[2]; // /users/42 -> "42"
Always wrap request.json() in a try/catch because malformed JSON throws a SyntaxError. Use await request.text() when the client sends plain text or custom formats. formData() is useful for file uploads or traditional HTML forms. Note that request.json() consumes the body stream — you can only read it once per request.
If a request sends a Content-Type header that does not match the body, Bun does not enforce it — the server must validate. For example, a client could claim application/json but send plain text. Always check the parsed result and return 400 on errors.
Response Patterns
Response.json() creates a response with Content-Type: application/json automatically. Set status codes and custom headers explicitly when needed.
Bun.serve({
port: 3000,
fetch(request) {
const url = new URL(request.url);
// Plain text
if (url.pathname === "/hello") {
return new Response("Hello, fogserv", {
headers: { "Content-Type": "text/plain" },
});
}
// JSON response with status
if (url.pathname === "/created") {
return Response.json({ id: 123 }, { status: 201, headers: { "X-Custom": "yes" } });
}
// Binary (serving a file)
if (url.pathname === "/logo") {
const data = Bun.file("logo.png");
return new Response(data);
}
return new Response("Not Found", { status: 404 });
}
});
Always include Content-Type so clients know how to interpret the body. For APIs, use application/json. For error responses, include a structured JSON body rather than raw text when the client expects JSON.
Streaming and SSE
Bun supports streaming responses via the standard ReadableStream interface. Server-Sent Events (SSE) are a common pattern for real-time updates where the server pushes data over a long-lived HTTP connection.
Bun.serve({
port: 3000,
fetch(request) {
const stream = new ReadableStream({
start(controller) {
let count = 0;
const interval = setInterval(() => {
controller.enqueue(`data: { "count": ${++count} }\n\n`);
if (count >= 5) {
clearInterval(interval);
controller.close();
}
}, 1000);
}
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
}
});
}
});
Streaming reduces memory usage for large payloads. Instead of loading an entire file or dataset into memory, Bun reads chunks as they arrive. For SSE, the text/event-stream content type tells browsers to treat the connection as a persistent event stream. Always include Cache-Control: no-cache to prevent proxies from buffering the stream.
WebSockets
WebSockets provide full-duplex, persistent connections over a single TCP socket. Bun handles the upgrade handshake and provides open, message, and close events.
Bun.serve({
port: 3000,
fetch(req, server) {
if (server.upgrade(req)) {
return;
}
return new Response("Upgrade failed", { status: 400 });
},
websocket: {
open(ws) {
console.log("WebSocket connected");
ws.send("Hello from server");
},
message(ws, message) {
console.log("Received:", message);
ws.send(`Echo: ${message}`);
},
close(ws) {
console.log("WebSocket disconnected");
}
}
});
server.upgrade(req) initiates the WebSocket handshake. If it succeeds, the websocket handlers take over. Use WebSockets for real-time chat, gaming, or collaborative editing where low latency matters. For simple server-to-client updates, SSE is simpler and works over standard HTTP ports.
Guided Checkpoint
Start the server and verify all response types:
# Start: bun run server.ts
# GET / — plain text welcome
curl -s http://localhost:3000/ | cat
# GET /posts/42 — JSON with extracted param
curl -s http://localhost:3000/posts/42 | jq .
# POST /api/users — returns 201 with parsed body
curl -s -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" \
-d '{"name":"Alice"}' | jq .
# POST /api/users with bad JSON — returns 400
curl -s -w "\n%{http_code}" -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" \
-d 'not-json'
# GET /nonexistent — returns 404
curl -s -w "\n%{http_code}" http://localhost:3000/nonexistent
# SSE endpoint — receives 5 events then closes
curl -s http://localhost:3000/stream | head -20
Each response must have the correct status code and Content-Type. The SSE stream delivers exactly 5 JSON events before closing.
3. Anti-Patterns & Common Pitfalls
Calling request.json() without try/catch. Malformed JSON throws a SyntaxError that crashes the handler if uncaught. Always wrap: try { body = await request.json(); } catch { return Response.json({ error: "..." }, { status: 400 }); }.
Reading the request body twice. The body stream is consumed on the first read. If you call request.json() and then try to read it again, the second call returns an empty body. Store the parsed result in a variable and use that instead.
Using regex for common routing instead of string operations. Regex compilation is slow per request. For most APIs, pathname === "/users" or pathname.startsWith("/users/") is faster and clearer than /^\/users(\/.*)?$/.
Serving large files without streaming. Loading a multi-GB file into memory as a Buffer blocks the event loop. Use Bun.file(path) which returns a lazy file handle that streams automatically.
WebSocket without checking server.upgrade() return value. If upgrade() returns false, you must return a fallback Response. Omitting the fallback leaves the request hanging.
Not handling the OPTIONS method for WebSocket connections. Browsers send OPTIONS preflight before upgrading to WebSocket. Without a handler that returns CORS headers, the upgrade fails silently.
4. Independent Challenge
Build a real-time event stream server using WebSockets or SSE. The server must:
- Accept WebSocket connections at
/ws - On connection, send a welcome message with a unique session ID
- Broadcast a timestamp every 2 seconds to all connected clients
- Echo back any message the client sends, prefixed with the session ID
- Clean up the session when the client disconnects
- Track and log the number of active connections
Implement this using Bun.serve() with the websocket handler. Use a Map to store active connections keyed by session ID. Do not use a library.
5. Consolidation & Key Invariants
Bun.serve()requires thefetchhandler to return aResponse— never returnundefined- Always wrap
request.json()in try/catch and return400for malformed bodies request.json()consumes the body stream — read it once and store the result- Extract pathnames with
new URL(request.url).pathname; use simple string ops (===,startsWith) over regex for most routes - Serve large files with
Bun.file(path)which streams automatically without loading into memory - WebSocket upgrade requires both the
server.upgrade(req)call and a fallbackResponse - Include
Cache-Control: no-cacheandConnection: keep-aliveheaders on SSE responses
6. Next Steps
- Read
kb/backend/routing-patterns.mdfor structured route design using parameterized paths and route groups. - Read
kb/backend/middleware-patterns.mdfor auth, logging, and composable middleware chains. - Build a small real-time API using WebSockets or SSE, then deploy it with Docker (
kb/containers/docker-concepts.md).
Change Log
- 2026-08-29: Initial scaffold
- 2026-08-29: Hydrated to production depth — expanded all sections with code examples, added Streaming, WebSockets, Next Steps, and Change Log sections
- 2026-08-29: Migrated to 6-section canonical template (2026-08-29)