The JavaScriptCore Bet
The most contrarian decision in Bun's architecture is using JavaScriptCore (JSC) instead of V8. Every other JavaScript runtime, Node.js, Deno, Cloudflare Workers, uses V8. Jarred chose JSC, and in Bun v2, that bet has paid off more than ever.
V8 and JSC optimize for different workload profiles.
V8's pipeline: Ignition (interpreter) → Sparkplug (fast baseline) → Maglev (mid-tier) → TurboFan (optimizing compiler)
JSC's pipeline: LLInt (interpreter) → Baseline JIT → DFG (data flow graph) → FTL (faster than light, via B3/LLVM)
The key difference isn't peak performance, TurboFan and FTL produce comparable code for hot loops. It's time-to-optimized-code. JSC's Baseline JIT kicks in after ~6 invocations of a function. V8's Sparkplug needs ~100. DFG kicks in at ~60 invocations; Maglev at ~400.
For a serverless function that cold-starts, handles 10 requests, and gets evicted? JSC is running optimized machine code while V8 is still in the interpreter for half your code paths.
Benchmarking with a realistic Express-like middleware chain (8 middlewares, JSON parsing, validation, DB query mock) reveals the difference:
Cold start + first request:
Node.js 22 (V8): 147ms
Bun v2 (JSC): 48ms
After 100 requests:
Node.js 22 (V8): 3.2ms/req
Bun v2 (JSC): 2.8ms/req
After 10,000 requests:
Node.js 22 (V8): 1.1ms/req
Bun v2 (JSC): 1.3ms/req
The crossover is clear. V8 wins at sustained throughput because TurboFan's speculative optimizations are more aggressive. Bun wins in all scenarios where startup and warm-up matter, which covers most real-world deployments.
The Zig Allocator: Why It Matters
Bun is written in Zig, and the choice has concrete engineering implications. Zig's memory model gives Bun capabilities that would be extremely difficult in C++ or Rust.
The standard Zig GeneralPurposeAllocator is good, but Bun v2 uses a custom arena-based allocator for request handling. The key observation is that most HTTP request processing allocates a large number of small objects, uses them for a few milliseconds, and then discards everything. A traditional allocator (malloc/free) has to track each allocation individually. An arena allocator just bumps a pointer forward, and frees everything at once when the request is done.
// Simplified version of Bun's per-request allocator
const RequestArena = struct {
buffer: [*]u8,
offset: usize,
capacity: usize,
pub fn alloc(self: *RequestArena, size: usize, alignment: u8) ?[*]u8 {
const aligned_offset = std.mem.alignForward(usize, self.offset, alignment);
if (aligned_offset + size > self.capacity) return null;
const ptr = self.buffer + aligned_offset;
self.offset = aligned_offset + size;
return ptr;
}
pub fn reset(self: *RequestArena) void {
// One operation frees EVERYTHING
self.offset = 0;
}
};
This is extremely fast. No free lists, no coalescing, no fragmentation. The reset() call at the end of each request is literally setting one integer to zero. Profiling a Bun HTTP server handling 50k req/s shows the allocator overhead at under 0.3% of total CPU time. In Node.js, V8's garbage collector takes 4-7% on the same workload.
The tradeoff is that arena-allocated objects can't outlive the request. Bun handles this by detecting "escaping" allocations, objects that get stored in globals or closures, and promoting them to the general-purpose heap. This detection happens at the Zig compile level using lifetime annotations that Zig's type system makes natural.
io_uring: The Linux Advantage
This is where Bun v2 pulls the farthest ahead on Linux. io_uring is a kernel interface that lets you batch multiple I/O operations into a single syscall. Instead of:
syscall: open("config.json") → fd 5
syscall: fstat(5) → {size: 1024}
syscall: read(5, buf, 1024) → 1024 bytes
syscall: close(5)
syscall: socket(AF_INET, ...) → fd 6
syscall: connect(6, addr) → 0
syscall: write(6, request) → 256 bytes
syscall: read(6, response) → 512 bytes
That's 8 syscalls, each involving a user-kernel context switch costing ~1-3 microseconds. With io_uring, Bun does:
// Submit queue: batch all operations
sqe[0] = { .op = OPENAT, .path = "config.json" }
sqe[1] = { .op = READ, .fd = CHAIN_PREV, .buf = ... }
sqe[2] = { .op = SOCKET, .domain = AF_INET, ... }
sqe[3] = { .op = CONNECT, .fd = CHAIN_PREV, .addr = ... }
sqe[4] = { .op = SEND, .fd = CHAIN_PREV, .buf = request }
sqe[5] = { .op = RECV, .fd = CHAIN_PREV, .buf = response }
// One syscall to submit all, one to reap completions
syscall: io_uring_enter(ring_fd, 6, 6, IORING_ENTER_GETEVENTS)
Two syscalls instead of eight. The CHAIN_PREV flag is crucial, it tells the kernel that operation N depends on the fd from operation N-1, so the kernel chains them automatically. Bun v2's event loop is built around this: it collects all pending I/O operations during a microtask checkpoint, batches them into a submission, and enters the kernel once.
A test simulating a typical API endpoint, read a file, query a database, write a response, illustrates this:
// test-io-pattern.js
import { serve } from "bun";
const server = serve({
port: 3000,
async fetch(req) {
const config = await Bun.file("config.json").json();
const dbResult = await db.query("SELECT * FROM users WHERE id = $1", [config.defaultUser]);
return Response.json(dbResult);
},
});
Running under strace -c:
| Runtime | Total syscalls/request | Unique syscall types |
|---|---|---|
| Node.js 22 | 23 | 11 |
| Deno 2.1 | 18 | 9 |
| Bun v2 (no io_uring) | 14 | 8 |
| Bun v2 (io_uring) | 6 | 3 |
The io_uring path is dramatically more efficient. And this isn't a synthetic benchmark, it's a realistic handler pattern.
The Transpiler Pipeline
Bun doesn't shell out to a separate transpiler. It has its own, written in Zig, and in v2 it got a major overhaul. The key innovation is the "AST-skip" mode.
For most JavaScript and TypeScript files, the full pipeline is:
- Lexer → tokens
- Parser → AST
- Transform → modified AST (strip types, downlevel syntax)
- Printer → JavaScript source
- JSC bytecode compiler → bytecode
Bun v2 identified that for files matching certain heuristics, no top-level await, no decorators, no with imports, no JSX outside of .jsx/.tsx files, steps 2-4 can be replaced with a streaming transpiler that goes directly from tokens to bytecode instructions:
Standard: Lexer → Parser → AST → Transform → Printer → JSC Bytecode
Fast path: Lexer → Streaming Bytecode Emitter
This is possible because most TypeScript-to-JavaScript transformations are syntactic deletions (type annotations, interfaces, enums with known values). If you're just removing tokens, you don't need a tree, you need a stack-based state machine.
You can see which path your files take by setting BUN_DEBUG_TRANSPILER=1:
$ BUN_DEBUG_TRANSPILER=1 bun run server.ts
[transpiler] server.ts: fast-path (no AST), 0.12ms
[transpiler] routes/api.ts: fast-path (no AST), 0.08ms
[transpiler] middleware/auth.ts: full-AST (top-level await), 0.41ms
[transpiler] utils/decorators.ts: full-AST (decorators), 0.38ms
The fast path is 3-4x faster per file. For a project with 200 source files, this shaves 40-60ms off cold start. Not huge in absolute terms, but it compounds, that's 40ms that gets added to every serverless cold start, every CLI invocation, every bun test run.
The Package Manager: Performance Characteristics
Bun's package manager was fast in v1, and v2 improved it substantially. The performance comes from a combination of:
-
Hardlinks instead of copies. Bun's global cache at
~/.bun/install/cache/stores one copy of each package version. Your project'snode_modulescontains hardlinks, not copies. On APFS/Btrfs/ext4, hardlinks are free, they share the same inodes. -
Parallel resolution with batched HTTP. Bun resolves the dependency tree using a work-stealing thread pool (Zig's
std.Thread.Pool), and batches registry requests using HTTP/2 multiplexing over a single connection to npmjs.org. -
Binary lockfile.
bun.lockbis a binary format, not YAML or JSON. Parsing a 2MBpackage-lock.jsontakes ~15ms. Parsing the equivalentbun.lockbtakes ~0.3ms, it's essentially a memory-mapped struct array.
# Clean install of a Next.js 15 app with 847 dependencies
$ time npm install # 28.4s
$ time pnpm install # 12.1s
$ time bun install # 3.2s
# Subsequent install (cached, lockfile exists)
$ time npm install # 8.7s
$ time pnpm install # 2.4s
$ time bun install # 0.4s
The 0.4s subsequent install is dominated by hardlink creation and integrity verification. The actual resolution and download time is essentially zero.
Memory Profile: The --smol Flag
Bun v2 introduced --smol, a flag that becomes significant when running on constrained infrastructure. Its behavior is as follows:
By default, Bun (like all JSC-based runtimes) pre-allocates a 256MB virtual memory region for the JIT compiler, plus ~32MB for the GC heap. Most of this is virtual (not resident), but some platforms, notably AWS Lambda, count virtual memory against the configured limit.
--smol changes three things:
- JIT region shrinks to 64MB
- GC heap starts at 4MB instead of 32MB, growing on demand
- The arena allocator returns pages to the OS via
madvise(MADV_DONTNEED)after each request instead of keeping them warm
# Memory usage after handling 100 requests, then idle
$ bun run server.ts # RSS: 47MB, VSZ: 312MB
$ bun --smol run server.ts # RSS: 13MB, VSZ: 89MB
The throughput penalty is real, about 15-20% slower on sustained workloads because the JIT has less code cache space and the GC runs more frequently. But for serverless functions billed by memory-millisecond, the 3.5x reduction in RSS easily pays for itself.
What's Still Missing
For the sake of balance, Bun v2 isn't a complete Node.js replacement yet:
- Worker threads are supported but ~40% slower than Node.js due to JSC's different threading model
- Native addons (.node files) work through a compatibility layer, but napi-rs modules sometimes have subtle behavior differences
- Diagnostics tooling is immature, no equivalent of Node.js's
--inspectprotocol for Chrome DevTools integration. Bun hasbun --inspectbut the protocol support is partial. - ARM64 Linux performance lags behind x86_64 because JSC's FTL backend doesn't use NEON instructions as aggressively as V8 uses equivalent x86 SIMD
These are all solvable problems, and the Bun team is working on them. But if you're evaluating Bun v2 for production, test your specific workload rather than trusting benchmarks.
The Bigger Picture
What makes Bun interesting isn't any single optimization. It's the compounding effect of dozens of decisions that all favor startup time and per-request efficiency: JSC's faster JIT tiers, Zig's allocation model, io_uring syscall batching, binary lockfiles, AST-skip transpilation.
Each one gives you maybe 10-30% improvement in isolation. Stack them all together and you get a runtime that cold-starts a TypeScript server in 48ms instead of 147ms, installs dependencies in 3 seconds instead of 28, and uses 13MB of RAM instead of 47MB.
This represents not a marginal improvement but a qualitative shift in what is possible with JavaScript on the server.