{"schemaVersion":1,"id":"https://lucioduran.com/blog/bun-v2-runtime-internals-deep-dive","url":"https://lucioduran.com/blog/bun-v2-runtime-internals-deep-dive","title":"Bun v2 Runtime Internals: JavaScriptCore, Zig Allocators, and io_uring Syscall Batching","description":"An analysis of Bun v2's architecture: JavaScriptCore vs V8 tradeoffs, Zig's arena allocator design, syscall batching with io_uring, and the engineering decisions behind the 3x cold start improvements.","language":"en","datePublished":"2025-10-04","dateModified":null,"contentFormat":"text/markdown","content":"JavaScript Runtimes\n\n# Bun v2 Runtime Internals: JavaScriptCore, Zig Allocators, and io\\_uring Syscall Batching\n\n2025-10-049 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/bun-v2-runtime-internals-deep-dive>)\n\n## The JavaScriptCore Bet\n\nThe most contrarian decision in [Bun](<https://bun.sh/>)'s architecture is using JavaScriptCore (JSC) instead of V8. Every other JavaScript runtime, [Node.js](<https://nodejs.org/>), [Deno](<https://deno.com/>), [Cloudflare Workers](<https://workers.cloudflare.com/>), uses V8. Jarred chose JSC, and in Bun v2, that bet has paid off more than ever.\n\nV8 and JSC optimize for different workload profiles.\n\nV8's pipeline: **Ignition** (interpreter) → **Sparkplug** (fast baseline) → **Maglev** (mid-tier) → **TurboFan** (optimizing compiler)\n\nJSC's pipeline: **LLInt** (interpreter) → **Baseline JIT** → **DFG** (data flow graph) → **FTL** (faster than light, via B3/LLVM)\n\nThe 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.\n\nFor 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.\n\nBenchmarking with a realistic Express-like middleware chain (8 middlewares, JSON parsing, validation, DB query mock) reveals the difference:\n\n```\nCold start + first request:\n Node.js 22 (V8): 147ms\n Bun v2 (JSC): 48ms\n\nAfter 100 requests:\n Node.js 22 (V8): 3.2ms/req\n Bun v2 (JSC): 2.8ms/req\n\nAfter 10,000 requests:\n Node.js 22 (V8): 1.1ms/req\n Bun v2 (JSC): 1.3ms/req\n```\n\nThe 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.\n\n## The Zig Allocator: Why It Matters\n\nBun is written in [Zig](<https://ziglang.org/>), and the choice has concrete engineering implications. Zig's memory model gives Bun capabilities that would be extremely difficult in C++ or Rust.\n\nThe 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.\n\n```zig\n// Simplified version of Bun's per-request allocator\nconst RequestArena = struct {\n buffer: [*]u8,\n offset: usize,\n capacity: usize,\n\n pub fn alloc(self: *RequestArena, size: usize, alignment: u8) ?[*]u8 {\n const aligned_offset = std.mem.alignForward(usize, self.offset, alignment);\n if (aligned_offset + size > self.capacity) return null;\n const ptr = self.buffer + aligned_offset;\n self.offset = aligned_offset + size;\n return ptr;\n }\n\n pub fn reset(self: *RequestArena) void {\n // One operation frees EVERYTHING\n self.offset = 0;\n }\n};\n```\n\nThis 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.\n\nThe 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.\n\n## io\\_uring: The Linux Advantage\n\nThis 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:\n\n```\nsyscall: open(\"config.json\") → fd 5\nsyscall: fstat(5) → {size: 1024}\nsyscall: read(5, buf, 1024) → 1024 bytes\nsyscall: close(5)\nsyscall: socket(AF_INET, ...) → fd 6\nsyscall: connect(6, addr) → 0\nsyscall: write(6, request) → 256 bytes\nsyscall: read(6, response) → 512 bytes\n```\n\nThat's 8 syscalls, each involving a user-kernel context switch costing ~1-3 microseconds. With io\\_uring, Bun does:\n\n```\n// Submit queue: batch all operations\nsqe[0] = { .op = OPENAT, .path = \"config.json\" }\nsqe[1] = { .op = READ, .fd = CHAIN_PREV, .buf = ... }\nsqe[2] = { .op = SOCKET, .domain = AF_INET, ... }\nsqe[3] = { .op = CONNECT, .fd = CHAIN_PREV, .addr = ... }\nsqe[4] = { .op = SEND, .fd = CHAIN_PREV, .buf = request }\nsqe[5] = { .op = RECV, .fd = CHAIN_PREV, .buf = response }\n\n// One syscall to submit all, one to reap completions\nsyscall: io_uring_enter(ring_fd, 6, 6, IORING_ENTER_GETEVENTS)\n```\n\nTwo 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.\n\nA test simulating a typical API endpoint, read a file, query a database, write a response, illustrates this:\n\n```javascript\n// test-io-pattern.js\nimport { serve } from \"bun\";\n\nconst server = serve({\n port: 3000,\n async fetch(req) {\n const config = await Bun.file(\"config.json\").json();\n const dbResult = await db.query(\"SELECT * FROM users WHERE id = $1\", [config.defaultUser]);\n return Response.json(dbResult);\n },\n});\n```\n\nRunning under ` strace -c `:\n\n| Runtime | Total syscalls/request | Unique syscall types |\n| --- | --- | --- |\n| Node.js 22 | 23 | 11 |\n| Deno 2.1 | 18 | 9 |\n| Bun v2 (no io\\_uring) | 14 | 8 |\n| Bun v2 (io\\_uring) | 6 | 3 |\n\nThe io\\_uring path is dramatically more efficient. And this isn't a synthetic benchmark, it's a realistic handler pattern.\n\n## The Transpiler Pipeline\n\nBun 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.\n\nFor most JavaScript and TypeScript files, the full pipeline is:\n\n1. Lexer → tokens\n2. Parser → AST\n3. Transform → modified AST (strip types, downlevel syntax)\n4. Printer → JavaScript source\n5. JSC bytecode compiler → bytecode\n\nBun 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:\n\n```\nStandard: Lexer → Parser → AST → Transform → Printer → JSC Bytecode\nFast path: Lexer → Streaming Bytecode Emitter\n```\n\nThis 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.\n\nYou can see which path your files take by setting ` BUN_DEBUG_TRANSPILER=1 `:\n\n```bash\n$ BUN_DEBUG_TRANSPILER=1 bun run server.ts\n[transpiler] server.ts: fast-path (no AST), 0.12ms\n[transpiler] routes/api.ts: fast-path (no AST), 0.08ms\n[transpiler] middleware/auth.ts: full-AST (top-level await), 0.41ms\n[transpiler] utils/decorators.ts: full-AST (decorators), 0.38ms\n```\n\nThe 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.\n\n## The Package Manager: Performance Characteristics\n\nBun's package manager was fast in v1, and v2 improved it substantially. The performance comes from a combination of:\n\n1. **Hardlinks instead of copies.** Bun's global cache at ` ~/.bun/install/cache/ ` stores one copy of each package version. Your project's ` node_modules ` contains hardlinks, not copies. On APFS/Btrfs/ext4, hardlinks are free, they share the same inodes.\n2. **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.\n3. **Binary lockfile.** ` bun.lockb ` is a binary format, not YAML or JSON. Parsing a 2MB ` package-lock.json ` takes ~15ms. Parsing the equivalent ` bun.lockb ` takes ~0.3ms, it's essentially a memory-mapped struct array.\n\n```bash\n# Clean install of a Next.js 15 app with 847 dependencies\n$ time npm install # 28.4s\n$ time pnpm install # 12.1s\n$ time bun install # 3.2s\n\n# Subsequent install (cached, lockfile exists)\n$ time npm install # 8.7s\n$ time pnpm install # 2.4s\n$ time bun install # 0.4s\n```\n\nThe 0.4s subsequent install is dominated by hardlink creation and integrity verification. The actual resolution and download time is essentially zero.\n\n## Memory Profile: The --smol Flag\n\nBun v2 introduced ` --smol `, a flag that becomes significant when running on constrained infrastructure. Its behavior is as follows:\n\nBy 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](<https://aws.amazon.com/lambda/>), count virtual memory against the configured limit.\n\n` --smol ` changes three things:\n\n1. JIT region shrinks to 64MB\n2. GC heap starts at 4MB instead of 32MB, growing on demand\n3. The arena allocator returns pages to the OS via ` madvise(MADV_DONTNEED) ` after each request instead of keeping them warm\n\n```bash\n# Memory usage after handling 100 requests, then idle\n$ bun run server.ts # RSS: 47MB, VSZ: 312MB\n$ bun --smol run server.ts # RSS: 13MB, VSZ: 89MB\n```\n\nThe 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.\n\n## What's Still Missing\n\nFor the sake of balance, Bun v2 isn't a complete Node.js replacement yet:\n\n- **Worker threads** are supported but ~40% slower than Node.js due to JSC's different threading model\n- **Native addons** (.node files) work through a compatibility layer, but napi-rs modules sometimes have subtle behavior differences\n- **Diagnostics tooling** is immature, no equivalent of Node.js's ` --inspect ` protocol for Chrome DevTools integration. Bun has ` bun --inspect ` but the protocol support is partial.\n- **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\n\nThese 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.\n\n## The Bigger Picture\n\nWhat 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.\n\nEach 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.\n\nThis represents not a marginal improvement but a qualitative shift in what is possible with JavaScript on the server.\n\n## Frequently asked questions\n\n### Is Bun v2 production-ready for server workloads?\n\nFor HTTP servers and API workloads, yes. Production deployments since November 2025 on mid-traffic API services (~2k req/s) have reported zero runtime crashes. The ecosystem compatibility is at ~98% of Node.js APIs. The remaining gaps are mostly in edge cases of streams and certain crypto methods.\n\n### Why does Bun use JavaScriptCore instead of V8?\n\nJSC's tiered compilation (LLInt → Baseline → DFG → FTL) produces optimized code faster for short-lived processes. V8's TurboFan produces better peak throughput for long-running hot loops, but the warm-up time is 2-4x longer. For CLI tools and serverless, JSC's profile is better.\n\n### How does Bun's io\\_uring integration work on Linux?\n\nBun v2 batches filesystem and network syscalls into io\\_uring submission queues, submitting them in a single syscall. For a typical HTTP request handler that reads a file and queries a database, this can reduce the syscall count from 8-12 down to 2-3, with the kernel handling the rest asynchronously.\n\n## Tips\n\n1. **Bun's module resolution cache is fd-backed:** When you see fast repeated imports, it's because Bun keeps a ` HashMap(u64, *Module) ` keyed on inode number, not path string. This means symlinks resolve to the same cache entry for free, and renames don't invalidate the cache incorrectly.\n2. **Use ` Bun.mmap() ` for large config files:** Instead of ` fs.readFileSync `, ` Bun.mmap() ` returns a ` Uint8Array ` backed by a memory-mapped file. The kernel handles paging, so you only pay for the bytes you actually touch. Huge win for large JSON configs where you parse a subset.\n3. **Bun v2's transpiler skips AST for known patterns:** If your file matches a set of fast-path heuristics (no top-level await, no decorators, no ` with ` imports), Bun uses a streaming single-pass transpiler that emits bytecode directly without building a full AST. Check ` BUN_DEBUG_TRANSPILER=1 ` to see which path your files take.\n4. **The ` --smol ` flag trades throughput for memory:** It switches from Zig's general-purpose allocator to a page-mapped arena that returns memory to the OS aggressively. In serverless, this can reduce idle memory from ~45MB to ~12MB, making it viable for 128MB Lambda functions.\n\n- bun\n- javascript\n- runtime-internals\n- zig\n- io\\_uring\n- performance\n\n[Back to Blog](<https://lucioduran.com/blog>)","headings":[{"level":1,"text":"Bun v2 Runtime Internals: JavaScriptCore, Zig Allocators, and io_uring Syscall Batching","url":"https://lucioduran.com/blog/bun-v2-runtime-internals-deep-dive#title"},{"level":2,"text":"The JavaScriptCore Bet","url":"https://lucioduran.com/blog/bun-v2-runtime-internals-deep-dive#the-javascriptcore-bet"},{"level":2,"text":"The Zig Allocator: Why It Matters","url":"https://lucioduran.com/blog/bun-v2-runtime-internals-deep-dive#the-zig-allocator-why-it-matters"},{"level":2,"text":"io_uring: The Linux Advantage","url":"https://lucioduran.com/blog/bun-v2-runtime-internals-deep-dive#iouring-the-linux-advantage"},{"level":2,"text":"The Transpiler Pipeline","url":"https://lucioduran.com/blog/bun-v2-runtime-internals-deep-dive#the-transpiler-pipeline"},{"level":2,"text":"The Package Manager: Performance Characteristics","url":"https://lucioduran.com/blog/bun-v2-runtime-internals-deep-dive#the-package-manager-performance-characteristics"},{"level":2,"text":"Memory Profile: The --smol Flag","url":"https://lucioduran.com/blog/bun-v2-runtime-internals-deep-dive#memory-profile-the---smol-flag"},{"level":2,"text":"What's Still Missing","url":"https://lucioduran.com/blog/bun-v2-runtime-internals-deep-dive#whats-still-missing"},{"level":2,"text":"The Bigger Picture","url":"https://lucioduran.com/blog/bun-v2-runtime-internals-deep-dive#the-bigger-picture"},{"level":2,"text":"Frequently asked questions","url":"https://lucioduran.com/blog/bun-v2-runtime-internals-deep-dive#questions"},{"level":3,"text":"Is Bun v2 production-ready for server workloads?","url":"https://lucioduran.com/blog/bun-v2-runtime-internals-deep-dive#question-1"},{"level":3,"text":"Why does Bun use JavaScriptCore instead of V8?","url":"https://lucioduran.com/blog/bun-v2-runtime-internals-deep-dive#question-2"},{"level":3,"text":"How does Bun's io_uring integration work on Linux?","url":"https://lucioduran.com/blog/bun-v2-runtime-internals-deep-dive#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/blog/bun-v2-runtime-internals-deep-dive#article-tips"}],"sections":[{"heading":"Bun v2 Runtime Internals: JavaScriptCore, Zig Allocators, and io_uring Syscall Batching","level":1,"url":"https://lucioduran.com/blog/bun-v2-runtime-internals-deep-dive","content":"JavaScript Runtimes"},{"heading":"Bun v2 Runtime Internals: JavaScriptCore, Zig Allocators, and io_uring Syscall Batching","level":1,"url":"https://lucioduran.com/blog/bun-v2-runtime-internals-deep-dive#title","content":"2025-10-049 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/bun-v2-runtime-internals-deep-dive>)"},{"heading":"The JavaScriptCore Bet","level":2,"url":"https://lucioduran.com/blog/bun-v2-runtime-internals-deep-dive#the-javascriptcore-bet","content":"The most contrarian decision in [Bun](<https://bun.sh/>)'s architecture is using JavaScriptCore (JSC) instead of V8. Every other JavaScript runtime, [Node.js](<https://nodejs.org/>), [Deno](<https://deno.com/>), [Cloudflare Workers](<https://workers.cloudflare.com/>), uses V8. Jarred chose JSC, and in Bun v2, that bet has paid off more than ever.\n\nV8 and JSC optimize for different workload profiles.\n\nV8's pipeline: **Ignition** (interpreter) → **Sparkplug** (fast baseline) → **Maglev** (mid-tier) → **TurboFan** (optimizing compiler)\n\nJSC's pipeline: **LLInt** (interpreter) → **Baseline JIT** → **DFG** (data flow graph) → **FTL** (faster than light, via B3/LLVM)\n\nThe 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.\n\nFor 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.\n\nBenchmarking with a realistic Express-like middleware chain (8 middlewares, JSON parsing, validation, DB query mock) reveals the difference:\n\n```\nCold start + first request:\n Node.js 22 (V8): 147ms\n Bun v2 (JSC): 48ms\n\nAfter 100 requests:\n Node.js 22 (V8): 3.2ms/req\n Bun v2 (JSC): 2.8ms/req\n\nAfter 10,000 requests:\n Node.js 22 (V8): 1.1ms/req\n Bun v2 (JSC): 1.3ms/req\n```\n\nThe 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."},{"heading":"The Zig Allocator: Why It Matters","level":2,"url":"https://lucioduran.com/blog/bun-v2-runtime-internals-deep-dive#the-zig-allocator-why-it-matters","content":"Bun is written in [Zig](<https://ziglang.org/>), and the choice has concrete engineering implications. Zig's memory model gives Bun capabilities that would be extremely difficult in C++ or Rust.\n\nThe 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.\n\n```zig\n// Simplified version of Bun's per-request allocator\nconst RequestArena = struct {\n buffer: [*]u8,\n offset: usize,\n capacity: usize,\n\n pub fn alloc(self: *RequestArena, size: usize, alignment: u8) ?[*]u8 {\n const aligned_offset = std.mem.alignForward(usize, self.offset, alignment);\n if (aligned_offset + size > self.capacity) return null;\n const ptr = self.buffer + aligned_offset;\n self.offset = aligned_offset + size;\n return ptr;\n }\n\n pub fn reset(self: *RequestArena) void {\n // One operation frees EVERYTHING\n self.offset = 0;\n }\n};\n```\n\nThis 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.\n\nThe 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."},{"heading":"io_uring: The Linux Advantage","level":2,"url":"https://lucioduran.com/blog/bun-v2-runtime-internals-deep-dive#iouring-the-linux-advantage","content":"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:\n\n```\nsyscall: open(\"config.json\") → fd 5\nsyscall: fstat(5) → {size: 1024}\nsyscall: read(5, buf, 1024) → 1024 bytes\nsyscall: close(5)\nsyscall: socket(AF_INET, ...) → fd 6\nsyscall: connect(6, addr) → 0\nsyscall: write(6, request) → 256 bytes\nsyscall: read(6, response) → 512 bytes\n```\n\nThat's 8 syscalls, each involving a user-kernel context switch costing ~1-3 microseconds. With io\\_uring, Bun does:\n\n```\n// Submit queue: batch all operations\nsqe[0] = { .op = OPENAT, .path = \"config.json\" }\nsqe[1] = { .op = READ, .fd = CHAIN_PREV, .buf = ... }\nsqe[2] = { .op = SOCKET, .domain = AF_INET, ... }\nsqe[3] = { .op = CONNECT, .fd = CHAIN_PREV, .addr = ... }\nsqe[4] = { .op = SEND, .fd = CHAIN_PREV, .buf = request }\nsqe[5] = { .op = RECV, .fd = CHAIN_PREV, .buf = response }\n\n// One syscall to submit all, one to reap completions\nsyscall: io_uring_enter(ring_fd, 6, 6, IORING_ENTER_GETEVENTS)\n```\n\nTwo 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.\n\nA test simulating a typical API endpoint, read a file, query a database, write a response, illustrates this:\n\n```javascript\n// test-io-pattern.js\nimport { serve } from \"bun\";\n\nconst server = serve({\n port: 3000,\n async fetch(req) {\n const config = await Bun.file(\"config.json\").json();\n const dbResult = await db.query(\"SELECT * FROM users WHERE id = $1\", [config.defaultUser]);\n return Response.json(dbResult);\n },\n});\n```\n\nRunning under ` strace -c `:\n\n| Runtime | Total syscalls/request | Unique syscall types |\n| --- | --- | --- |\n| Node.js 22 | 23 | 11 |\n| Deno 2.1 | 18 | 9 |\n| Bun v2 (no io\\_uring) | 14 | 8 |\n| Bun v2 (io\\_uring) | 6 | 3 |\n\nThe io\\_uring path is dramatically more efficient. And this isn't a synthetic benchmark, it's a realistic handler pattern."},{"heading":"The Transpiler Pipeline","level":2,"url":"https://lucioduran.com/blog/bun-v2-runtime-internals-deep-dive#the-transpiler-pipeline","content":"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.\n\nFor most JavaScript and TypeScript files, the full pipeline is:\n\n1. Lexer → tokens\n2. Parser → AST\n3. Transform → modified AST (strip types, downlevel syntax)\n4. Printer → JavaScript source\n5. JSC bytecode compiler → bytecode\n\nBun 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:\n\n```\nStandard: Lexer → Parser → AST → Transform → Printer → JSC Bytecode\nFast path: Lexer → Streaming Bytecode Emitter\n```\n\nThis 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.\n\nYou can see which path your files take by setting ` BUN_DEBUG_TRANSPILER=1 `:\n\n```bash\n$ BUN_DEBUG_TRANSPILER=1 bun run server.ts\n[transpiler] server.ts: fast-path (no AST), 0.12ms\n[transpiler] routes/api.ts: fast-path (no AST), 0.08ms\n[transpiler] middleware/auth.ts: full-AST (top-level await), 0.41ms\n[transpiler] utils/decorators.ts: full-AST (decorators), 0.38ms\n```\n\nThe 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."},{"heading":"The Package Manager: Performance Characteristics","level":2,"url":"https://lucioduran.com/blog/bun-v2-runtime-internals-deep-dive#the-package-manager-performance-characteristics","content":"Bun's package manager was fast in v1, and v2 improved it substantially. The performance comes from a combination of:\n\n1. **Hardlinks instead of copies.** Bun's global cache at ` ~/.bun/install/cache/ ` stores one copy of each package version. Your project's ` node_modules ` contains hardlinks, not copies. On APFS/Btrfs/ext4, hardlinks are free, they share the same inodes.\n2. **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.\n3. **Binary lockfile.** ` bun.lockb ` is a binary format, not YAML or JSON. Parsing a 2MB ` package-lock.json ` takes ~15ms. Parsing the equivalent ` bun.lockb ` takes ~0.3ms, it's essentially a memory-mapped struct array.\n\n```bash\n# Clean install of a Next.js 15 app with 847 dependencies\n$ time npm install # 28.4s\n$ time pnpm install # 12.1s\n$ time bun install # 3.2s\n\n# Subsequent install (cached, lockfile exists)\n$ time npm install # 8.7s\n$ time pnpm install # 2.4s\n$ time bun install # 0.4s\n```\n\nThe 0.4s subsequent install is dominated by hardlink creation and integrity verification. The actual resolution and download time is essentially zero."},{"heading":"Memory Profile: The --smol Flag","level":2,"url":"https://lucioduran.com/blog/bun-v2-runtime-internals-deep-dive#memory-profile-the---smol-flag","content":"Bun v2 introduced ` --smol `, a flag that becomes significant when running on constrained infrastructure. Its behavior is as follows:\n\nBy 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](<https://aws.amazon.com/lambda/>), count virtual memory against the configured limit.\n\n` --smol ` changes three things:\n\n1. JIT region shrinks to 64MB\n2. GC heap starts at 4MB instead of 32MB, growing on demand\n3. The arena allocator returns pages to the OS via ` madvise(MADV_DONTNEED) ` after each request instead of keeping them warm\n\n```bash\n# Memory usage after handling 100 requests, then idle\n$ bun run server.ts # RSS: 47MB, VSZ: 312MB\n$ bun --smol run server.ts # RSS: 13MB, VSZ: 89MB\n```\n\nThe 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."},{"heading":"What's Still Missing","level":2,"url":"https://lucioduran.com/blog/bun-v2-runtime-internals-deep-dive#whats-still-missing","content":"For the sake of balance, Bun v2 isn't a complete Node.js replacement yet:\n\n- **Worker threads** are supported but ~40% slower than Node.js due to JSC's different threading model\n- **Native addons** (.node files) work through a compatibility layer, but napi-rs modules sometimes have subtle behavior differences\n- **Diagnostics tooling** is immature, no equivalent of Node.js's ` --inspect ` protocol for Chrome DevTools integration. Bun has ` bun --inspect ` but the protocol support is partial.\n- **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\n\nThese 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."},{"heading":"The Bigger Picture","level":2,"url":"https://lucioduran.com/blog/bun-v2-runtime-internals-deep-dive#the-bigger-picture","content":"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.\n\nEach 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.\n\nThis represents not a marginal improvement but a qualitative shift in what is possible with JavaScript on the server."},{"heading":"Frequently asked questions","level":2,"url":"https://lucioduran.com/blog/bun-v2-runtime-internals-deep-dive#questions","content":""},{"heading":"Is Bun v2 production-ready for server workloads?","level":3,"url":"https://lucioduran.com/blog/bun-v2-runtime-internals-deep-dive#question-1","content":"For HTTP servers and API workloads, yes. Production deployments since November 2025 on mid-traffic API services (~2k req/s) have reported zero runtime crashes. The ecosystem compatibility is at ~98% of Node.js APIs. The remaining gaps are mostly in edge cases of streams and certain crypto methods."},{"heading":"Why does Bun use JavaScriptCore instead of V8?","level":3,"url":"https://lucioduran.com/blog/bun-v2-runtime-internals-deep-dive#question-2","content":"JSC's tiered compilation (LLInt → Baseline → DFG → FTL) produces optimized code faster for short-lived processes. V8's TurboFan produces better peak throughput for long-running hot loops, but the warm-up time is 2-4x longer. For CLI tools and serverless, JSC's profile is better."},{"heading":"How does Bun's io_uring integration work on Linux?","level":3,"url":"https://lucioduran.com/blog/bun-v2-runtime-internals-deep-dive#question-3","content":"Bun v2 batches filesystem and network syscalls into io\\_uring submission queues, submitting them in a single syscall. For a typical HTTP request handler that reads a file and queries a database, this can reduce the syscall count from 8-12 down to 2-3, with the kernel handling the rest asynchronously."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/blog/bun-v2-runtime-internals-deep-dive#article-tips","content":"1. **Bun's module resolution cache is fd-backed:** When you see fast repeated imports, it's because Bun keeps a ` HashMap(u64, *Module) ` keyed on inode number, not path string. This means symlinks resolve to the same cache entry for free, and renames don't invalidate the cache incorrectly.\n2. **Use ` Bun.mmap() ` for large config files:** Instead of ` fs.readFileSync `, ` Bun.mmap() ` returns a ` Uint8Array ` backed by a memory-mapped file. The kernel handles paging, so you only pay for the bytes you actually touch. Huge win for large JSON configs where you parse a subset.\n3. **Bun v2's transpiler skips AST for known patterns:** If your file matches a set of fast-path heuristics (no top-level await, no decorators, no ` with ` imports), Bun uses a streaming single-pass transpiler that emits bytecode directly without building a full AST. Check ` BUN_DEBUG_TRANSPILER=1 ` to see which path your files take.\n4. **The ` --smol ` flag trades throughput for memory:** It switches from Zig's general-purpose allocator to a page-mapped arena that returns memory to the OS aggressively. In serverless, this can reduce idle memory from ~45MB to ~12MB, making it viable for 128MB Lambda functions.\n\n- bun\n- javascript\n- runtime-internals\n- zig\n- io\\_uring\n- performance\n\n[Back to Blog](<https://lucioduran.com/blog>)"}],"links":[{"text":"Español","url":"https://lucioduran.com/es/blog/bun-v2-runtime-internals-deep-dive"},{"text":"Bun","url":"https://bun.sh/"},{"text":"Node.js","url":"https://nodejs.org/"},{"text":"Deno","url":"https://deno.com/"},{"text":"Cloudflare Workers","url":"https://workers.cloudflare.com/"},{"text":"Zig","url":"https://ziglang.org/"},{"text":"AWS Lambda","url":"https://aws.amazon.com/lambda/"},{"text":"Back to Blog","url":"https://lucioduran.com/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","@id":"https://lucioduran.com/blog/bun-v2-runtime-internals-deep-dive#article","mainEntityOfPage":{"@type":"WebPage","@id":"https://lucioduran.com/blog/bun-v2-runtime-internals-deep-dive"},"headline":"Bun v2 Runtime Internals: JavaScriptCore, Zig Allocators, and io_uring Syscall Batching","description":"An analysis of Bun v2's architecture: JavaScriptCore vs V8 tradeoffs, Zig's arena allocator design, syscall batching with io_uring, and the engineering decisions behind the 3x cold start improvements.","datePublished":"2025-10-04","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"image":"https://lucioduran.com/favicons/og-default.png","url":"https://lucioduran.com/blog/bun-v2-runtime-internals-deep-dive","inLanguage":"en","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"JavaScript Runtimes","keywords":"bun, javascript, runtime-internals, zig, io_uring, performance","wordCount":1704,"timeRequired":"PT9M","workTranslation":[{"@type":"BlogPosting","inLanguage":"es","url":"https://lucioduran.com/es/blog/bun-v2-runtime-internals-deep-dive"}]},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/blog"},{"@type":"ListItem","position":3,"name":"Bun v2 Runtime Internals: JavaScriptCore, Zig Allocators, and io_uring Syscall Batching","item":"https://lucioduran.com/blog/bun-v2-runtime-internals-deep-dive"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Is Bun v2 production-ready for server workloads?","acceptedAnswer":{"@type":"Answer","text":"For HTTP servers and API workloads, yes. Production deployments since November 2025 on mid-traffic API services (~2k req/s) have reported zero runtime crashes. The ecosystem compatibility is at ~98% of Node.js APIs. The remaining gaps are mostly in edge cases of streams and certain crypto methods."}},{"@type":"Question","name":"Why does Bun use JavaScriptCore instead of V8?","acceptedAnswer":{"@type":"Answer","text":"JSC's tiered compilation (LLInt → Baseline → DFG → FTL) produces optimized code faster for short-lived processes. V8's TurboFan produces better peak throughput for long-running hot loops, but the warm-up time is 2-4x longer. For CLI tools and serverless, JSC's profile is better."}},{"@type":"Question","name":"How does Bun's io_uring integration work on Linux?","acceptedAnswer":{"@type":"Answer","text":"Bun v2 batches filesystem and network syscalls into io_uring submission queues, submitting them in a single syscall. For a typical HTTP request handler that reads a file and queries a database, this can reduce the syscall count from 8-12 down to 2-3, with the kernel handling the rest asynchronously."}}]}]}],"contentHash":"bd984615540d9369b98f10ced7875bd2424b1a396f4e9f87f56f645e2fba0eb9","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/blog/bun-v2-runtime-internals-deep-dive","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":67812,"contentBytes":14366}}
