Bun's HTTP Server Architecture: Native Integration Over Stream Abstraction
Bun's HTTP server bypasses Node.js's stream architecture by integrating a C++ parser directly with JavaScriptCore, using zero-copy Request/Response objects. This architecture note covers the design boundaries, operational verification steps, and failure modes when migrating from Node.js.
12 Dec 2025, 03:04 UTC

Requirements Driving the Design
Node.js's http module builds on a stream-based architecture designed in 2009. Every request creates readable and writable stream objects, piping data through multiple transformation layers before reaching application code. This design made sense when backpressure handling and composable pipelines were novel, but it introduces per-request allocation overhead, callback indirection, and buffer copying that become measurable at high concurrency.
Bun's requirement was simpler: accept HTTP requests and hand them to JavaScript handlers with minimal ceremony. The design targets workloads where request latency and throughput matter more than middleware composability—API gateways, edge functions, and high-frequency internal services.
Smallest Suitable Design: Bun.serve()
The entire public API surface is one function:
// server.ts
import { serve } from "bun";
serve({
fetch(req: Request) {
return new Response("Hello "+ req.url);
},
port: 3000,
});
No createServer, no request/response event emitters, no stream piping. The handler receives a standard Request object and returns a Response (or a promise resolving to one). This aligns with the Fetch API specification, making the same handler portable to Cloudflare Workers, Deno, or the browser.
Run it with bun run server.ts. The process starts in ~15ms on a warm cache (measured on Linux x86_64, Bun 1.1.x). Compare to ~80ms for an equivalent Node.js http.createServer script.
Trust and Data Boundaries
C++ Network Layer to JavaScriptCore Boundary
Bun's HTTP parser lives in C++ (based on llhttp, the same parser Node.js uses). When a request arrives, the parser populates a native HttpRequest struct. Instead of copying headers and body into new JavaScript objects, Bun creates a Request instance whose internal slots reference the native buffers directly.
This zero-copy approach means:
- Headers are exposed via a
Headersobject backed by the parser's string table. - The request body is a
ReadableStreamwhose underlying source pulls from the socket buffer without an intermediate copy. - Writing a
Responsewith aUint8ArrayorBlobbody writes directly to the socket write buffer.
The boundary is enforced by JavaScriptCore's C API: native code allocates JS objects, sets internal fields, and registers finalizers. If the JS handler throws, the native layer catches the exception, sends a 500, and cleans up the request context—no leaked file descriptors.
Memory Ownership Model
Request buffers are owned by the connection object, which lives in a per-thread arena. When the response finishes, the arena resets. This avoids per-request malloc/free churn. Under sustained load (10k RPS, 1KB payloads), RSS grows ~2MB over 60 seconds versus ~40MB for a comparable Node.js process (observed on Ubuntu 22.04, 8-core). The trade-off: large response bodies held in memory until fully written, since there's no backpressure signal to the application layer.
Operational Checks
Throughput Baseline
Run a 30-second wrk test against the example server:
# Terminal 1
bun run server.ts
# Terminal 2
wrk -t4 -c100 -d30s http://localhost:3000/
Expected output on 8-core Linux (Bun 1.1.20): ~95,000 req/sec. Node.js 20 with http.createServer under identical conditions: ~42,000 req/sec. The gap narrows with larger bodies or TLS termination.
Memory Profile Verification
While the load test runs, sample RSS:
watch -n1 'ps -o pid,rss,comm -p $(pgrep -f "bun run server.ts")'
Look for flat RSS after warmup. A steady climb suggests a leak—likely in application code retaining references, not the runtime.
Web Standard Compliance
Verify Request and Response behavior matches spec:
// test-compliance.ts
import { serve } from "bun";
import { describe, it, expect } from "bun:test";
const server = serve({
fetch(req) {
// Request.clone() must not consume body
const clone = req.clone();
const text = await clone.text();
return new Response(text, { headers: { "x-echo": req.headers.get("x-test") || "" } });
},
port: 0, // random port
});
const res = await fetch(server.url, {
method: "POST",
body: "test-body",
headers: { "x-test": "header-value" },
});
expect(res.headers.get("x-echo")).toBe("header-value");
expect(await res.text()).toBe("test-body");
server.stop();
Run with bun test test-compliance.ts. Failures indicate divergence from Fetch spec—report them upstream.
Failure Modes
Node.js Middleware Incompatibility
Express-style middleware expects (req, res, next) with mutable res objects and next() callbacks. Bun's fetch handler returns a Response. Adapters exist (@hono/node-server, elysia), but they add a translation layer that negates some zero-copy benefits.
If your codebase depends on connect-compatible middleware, evaluate whether the migration cost outweighs the performance gain. A pragmatic path: keep the middleware-heavy service on Node.js, move net-new high-throughput endpoints to Bun.
JavaScriptCore vs V8 JIT Differences
JSC's tiered compilation (LLInt → Baseline → DFG → FTL) optimizes differently than V8's (Ignition → TurboFan). Hot paths in request handlers may reach peak throughput slower or faster depending on code patterns:
- Polymorphic inline caches: JSC handles megamorphic call sites better in some benchmarks.
- Escape analysis: V8 currently eliminates more heap allocations in tight loops.
- Wasm integration: JSC's Wasm tier-up is faster for cold starts; V8 wins on sustained compute.
Profile with bun --perf and perf record to see which tier your handler runs in. Don't assume Node.js microbenchmarks transfer.
API Stability Across Minor Versions
Bun 1.0 → 1.1 changed Bun.serve() options: hostname replaced host, development flag behavior shifted. The project follows semver but treats 1.x as "stable with occasional breaking changes in minor releases." Pin your dependency ("bun": "1.1.20" in package.json via bun add -d bun@1.1.20) and test upgrades in CI.
Conditions That Would Change the Design
- HTTP/3 requirement: Current implementation is HTTP/1.1 and HTTP/2 only. QUIC support would need a separate UDP-based listener, likely a new
Bun.serveQuic()API. - Streaming request transformation: If middleware needs to transform request bodies before the handler sees them (e.g., decompression, decryption), the current single-handler model forces buffering or a pipeline abstraction—reintroducing stream overhead.
- Multi-threaded request handling: Bun's event loop is single-threaded per process. CPU-bound handlers block the loop. The current workaround is multiple processes behind a load balancer. A future
workeroption inserve()would change the memory model (shared-nothing vs shared arena). - WASI preview 2 sockets: If WASI standardizes async socket APIs, Bun could compile the HTTP parser to WebAssembly, enabling the same server to run in Wasm runtimes without native code.
Practical Verification Checklist
bun --versionmatches your pinned version in CI.wrkbaseline within 10% of documented numbers for your hardware.- RSS stable for 5 minutes at target RPS.
bun testpasses Fetch compliance suite.- No
unhandledrejectionwarnings under load (indicates promise leaks in handlers).
If all five pass, the architecture fits the workload. If not, the bottleneck is usually application code—JSON.parse on large bodies, synchronous crypto, or accidental closure retention—not the server implementation.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.