How Bun Works Internally: The JavaScriptCore Engine, Zig Runtime, and the Architecture Decisions That Make It Fast
A deep dive into Bun's internals covering why it chose JavaScriptCore over V8, how the Zig runtime manages memory and async I/O without libuv, the bundler and transpiler architecture, native SQLite integration, the HTTP server implementation, the test runner, and the real tradeoffs compared to Node.js and Deno.
Bun made a claim that immediately attracted skepticism: a JavaScript runtime built from scratch, faster than Node.js and Deno at nearly everything, with a bundler, test runner, package manager, and SQLite driver included. The skepticism was reasonable. Node.js has twenty years of optimization work behind it. V8 is the product of thousands of engineer-years at Google. Competing with that using a new runtime written in Zig sounded more like marketing than engineering.
The performance numbers turned out to be real, at least for the workloads Bun targets. Understanding why requires understanding what Bun actually is underneath: not a reimplementation of Node.js, but a different set of architectural choices at every layer of the stack.
Why JavaScriptCore Instead of V8
Every other major JavaScript runtime uses V8: Node.js, Deno, Cloudflare Workers. Bun chose JavaScriptCore (JSC), the engine Apple ships in Safari and WebKit. This is the most consequential single decision in Bun’s architecture, and the reasoning is not obvious.
V8 is not a bad engine. It is exceptionally well optimized for the workloads that Google cares about: long-running browser tabs, server-side rendering under sustained load, and JIT compilation of hot code paths. The tradeoffs V8 makes reflect those priorities. It has a relatively expensive startup path, spends significant time in its garbage collector for heap-heavy workloads, and its embedding API (the public C++ interface that Node.js uses) carries overhead from being designed for broad compatibility.
JavaScriptCore has a different tradeoff profile. Apple’s primary concern is mobile Safari on constrained hardware: fast startup, low memory pressure, and a GC that does not cause visible frame drops. JSC uses a four-tier compilation pipeline: LLInt (a low-level interpreter that starts executing bytecode immediately), Baseline JIT (quick native code generation), DFG (Data Flow Graph, the first optimizing compiler), and FTL (Faster Than Light, which uses B3 and LLVM for maximum optimization of the hottest paths). This tiering means JSC starts faster than V8 for cold or short-lived workloads because it does not front-load compilation time.
For server workloads like HTTP request handling, the startup advantage compounds: each request handler invocation benefits from faster initial bytecode interpretation, and for short-lived processes (scripts, CLI tools, test suites), you may never even reach the FTL tier. The JIT warm-up cost simply does not exist.
Bun does not use JSC’s public API. It accesses JSC internals directly, bypassing the overhead that a normal embedder would pay. This is only possible because JSC is open source and Bun’s team is willing to maintain the coupling cost as JSC evolves.
The Zig Runtime: Memory and Async I/O Without libuv
Node.js is built on libuv, a C library that provides the cross-platform event loop, async I/O, and thread pool that underpin everything from fs.readFile to net.createServer. libuv works well, but it carries historical weight: it was designed to abstract over Windows IOCP and POSIX epoll/kqueue at a time when those differed significantly, and it uses a thread pool for operations that block (most filesystem calls) because POSIX async filesystem I/O is inconsistent across platforms.
Bun replaces libuv with its own I/O layer written in Zig. The reasons are architectural, not NIH.
Zig is a systems language with manual memory management and no hidden control flow. It compiles to efficient native code, has first-class support for C interoperability, and its comptime (compile-time computation) feature allows zero-cost abstractions that would require macro gymnastics in C. Crucially, Zig’s error handling model makes the async I/O paths explicit without the overhead of exception unwinding.
Bun’s async I/O uses platform-native interfaces directly. On Linux it uses io_uring, the kernel interface introduced in 5.1 that supports fully asynchronous filesystem and network I/O without a thread pool for most operations. io_uring uses two ring buffers shared between userspace and the kernel: the submission queue (SQ) and the completion queue (CQ). Bun batches I/O submissions into the SQ, yields control to the event loop, and reads completions from the CQ. The result is significantly fewer syscalls per I/O operation compared to the traditional epoll_wait + thread pool model.
On macOS, Bun uses kqueue with kevent64 for network I/O and a Zig-managed thread pool for filesystem operations, because macOS does not have io_uring. The abstraction layer is thin enough that Bun can use io_uring semantics on Linux without paying the portability overhead that libuv carries.
// Bun's file I/O API looks like this at the surface
const file = Bun.file("/var/log/app.log");
const text = await file.text();
// What happens underneath on Linux:
// 1. Bun constructs an io_uring sqe (submission queue entry) for openat
// 2. Submits it, yields to the event loop
// 3. On completion, constructs a readv sqe
// 4. The kernel fills the read buffer without a thread pool context switch
// 5. Bun resolves the promise with the buffer content
// The same pattern for network writes:
const server = Bun.serve({
port: 3000,
fetch(req) {
return new Response("ok");
},
});
// Internally: accept() and send() are io_uring operations on Linux
// No thread pool involvement for the network path at all
Memory management in the Zig runtime is explicit. Bun uses a combination of arena allocators (for per-request allocations that are freed in bulk at request end) and the JSC garbage collector (for JavaScript heap objects). The split matters: request headers, URL strings, and HTTP metadata are allocated in arenas and freed without GC pressure. Only objects that escape into JavaScript need to be tracked by JSC’s GC. This reduces the heap pressure that most Node.js applications experience, where every string parsed from an HTTP request becomes a GC-managed object.
The Event Loop Implementation
Bun’s event loop is not libuv’s event loop. It is implemented in Zig, integrated directly with JSC’s microtask queue and the platform I/O interfaces.
The loop runs the following phases in order on each tick:
- Drain the JSC microtask queue (Promise
.thencallbacks,queueMicrotaskcalls). - Collect completed I/O events from io_uring (Linux) or kqueue (macOS).
- Fire the corresponding JavaScript callbacks or resolve the corresponding Promises.
- Drain the microtask queue again (callbacks may have queued more microtasks).
- Run any pending
setTimeout/setIntervaltimers whose deadline has passed. - If there is pending I/O and no microtasks, block on io_uring/kqueue until the next event arrives.
The key difference from Node.js is phase granularity. Node.js has six distinct phases (timers, pending callbacks, idle/prepare, poll, check, close) and Promise microtasks drain between each phase boundary. Bun’s loop is simpler: microtasks drain eagerly after every I/O callback fires, which matches the WHATWG event loop specification more closely. The practical implication is that deeply nested Promise chains resolve faster in Bun because there are fewer scheduling context switches.
The Bundler and Transpiler Architecture
Bun ships a bundler and transpiler as core features, not plugins or separate tools. The bundler handles TypeScript, JSX, and ESM/CJS transformation natively, written in Zig.
The transpiler uses a hand-written parser rather than calling into an existing tool like esbuild’s Go parser or Babel’s AST infrastructure. Hand-written parsers are faster because they avoid the overhead of a general-purpose AST representation: Bun’s parser builds a compact internal representation optimized for the transformations it needs to apply (TypeScript erasure, JSX compilation, import resolution) rather than a full ECMAScript-spec-conformant AST.
The module resolution algorithm is implemented natively as well. When Bun resolves import { foo } from "some-package", it walks the node_modules tree using a fast path that reads package.json exports fields with explicit support for the bun, browser, import, and require conditions. The resolution cache is stored in a per-process hash table keyed on (importer path, specifier) pairs. Repeated imports of the same module from different files skip the filesystem walk entirely.
// Bun can run TypeScript directly without a separate tsc/ts-node step
// bun run server.ts
// Under the hood: Bun's Zig transpiler strips types first
// No type checking happens at this stage — pure erasure
// This is the same tradeoff ts-node makes with transpileOnly: true
// For the bundler:
await Bun.build({
entrypoints: ["./src/index.ts"],
outdir: "./dist",
target: "bun", // can also be "node" or "browser"
splitting: true, // code splitting for shared chunks
minify: true,
});
// The bundler runs in the same process, in the same Zig thread pool
// No child_process spawn, no worker_threads overhead
One important caveat: Bun’s transpiler does not perform TypeScript type checking. It strips types and moves on. For production builds, you still run tsc --noEmit separately. This is the right tradeoff for a runtime (type checking at startup would be unusable for large codebases), but engineers sometimes miss it when switching from ts-node with full type checking enabled.
The HTTP Server Implementation
Bun.serve is the built-in HTTP server. It does not use Node.js’s http module or any Node.js internals. It is implemented on top of the uWebSockets.h C++ library, which Bun has integrated and tuned.
The server model is synchronous by design at the API surface: fetch(req) receives a Request object and returns a Response (or a Promise resolving to one). Each request is processed in the JavaScript event loop, but the underlying accept/read/write operations use io_uring on Linux. Bun achieves high request throughput not by parallelism within a single connection, but by batching I/O submissions across many concurrent connections in a single ring buffer flush.
WebSocket support is built in with the same architecture: Bun.serve accepts a websocket handler alongside fetch, and WebSocket frames are read and written via the same io_uring path as HTTP.
const server = Bun.serve({
port: 3000,
fetch(req, server) {
const url = new URL(req.url);
// Upgrade to WebSocket if requested
if (url.pathname === "/ws") {
const upgraded = server.upgrade(req);
if (!upgraded) return new Response("Upgrade failed", { status: 500 });
return undefined; // Response is handled by the websocket handler
}
return new Response(JSON.stringify({ path: url.pathname }), {
headers: { "Content-Type": "application/json" },
});
},
websocket: {
open(ws) {
ws.subscribe("events");
},
message(ws, data) {
// Publish to all subscribers on the "events" topic
server.publish("events", data);
},
close(ws) {
ws.unsubscribe("events");
},
},
});
The built-in pub/sub (server.publish) is Bun-specific. It routes messages to all WebSocket connections subscribed to a topic within the same process, without going through a message broker. For single-instance deployments or horizontally scaled setups with per-node state, it eliminates a dependency on Redis or another pub/sub layer.
Native SQLite Integration
Bun ships bun:sqlite, a native SQLite binding. This is not a thin wrapper over the Node.js better-sqlite3 package or a WASM compilation of SQLite. It is a direct integration between Bun’s Zig runtime and the SQLite C library, with results mapped directly into JSC values.
The binding uses JSC’s native value types to avoid boxing overhead. An integer result from SQLite becomes a JSC integer directly, not a JavaScript Number object allocated on the heap. Row objects are allocated in the request arena and released without GC involvement when the query scope ends.
import { Database } from "bun:sqlite";
const db = new Database("app.db");
// Prepared statements are cached in the Database instance
// Re-preparing the same SQL string reuses the existing statement
db.exec(`
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY,
type TEXT NOT NULL,
payload TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
)
`);
const insert = db.prepare(
"INSERT INTO events (type, payload) VALUES ($type, $payload)"
);
const selectRecent = db.prepare(
"SELECT id, type, payload, created_at FROM events ORDER BY created_at DESC LIMIT $limit"
);
// Transactions run synchronously — bun:sqlite is a synchronous API
// This matches SQLite's own execution model
const insertBatch = db.transaction((rows: Array<{ type: string; payload: string }>) => {
for (const row of rows) {
insert.run({ $type: row.type, $payload: JSON.stringify(row.payload) });
}
});
insertBatch([
{ type: "user.created", payload: { id: 42 } },
{ type: "plan.upgraded", payload: { plan: "pro" } },
]);
const events = selectRecent.all({ $limit: 10 });
// events is a plain JavaScript array of plain objects
// No ORM overhead, no connection pooling, no async I/O round-trip
bun:sqlite is synchronous because SQLite itself is synchronous. Wrapping it in async would add overhead without benefit: SQLite’s WAL mode allows one writer and many concurrent readers, and in a single-process runtime the contention model is different from a connection-pool database. For write-heavy workloads, the synchronous transaction API actually produces better throughput than async wrappers because it avoids the event loop overhead between statements.
The Test Runner
bun test is built into the runtime. It runs Jest-compatible test files without installing any packages: describe, it, expect, beforeEach, afterEach, mock, and spyOn are all available globally.
The test runner is implemented in Zig and JavaScript. It scans for *.test.ts, *.spec.ts, and test/*.ts files using the same native filesystem code as the rest of Bun, then executes each file in an isolated context using JSC’s context mechanism. Isolation is per-file, not per-test: module-level side effects are shared within a file but do not leak between files.
The performance advantage in the test runner comes from startup time. Running a suite of 50 test files in Node.js with Jest requires spawning a Jest worker pool, loading the Jest transform infrastructure, and running the TypeScript transform for each file. With bun test, each file is a native-speed TypeScript execution. The overhead difference is typically measured in seconds for medium-sized suites.
// tests/events.test.ts
import { describe, it, expect, mock, beforeEach } from "bun:test";
import { processEvent } from "../src/events";
const mockStorage = {
save: mock(() => Promise.resolve({ id: 1 })),
};
describe("processEvent", () => {
beforeEach(() => {
mockStorage.save.mockClear();
});
it("saves the event and returns the id", async () => {
const result = await processEvent(
{ type: "user.created", payload: { id: 42 } },
mockStorage
);
expect(mockStorage.save).toHaveBeenCalledTimes(1);
expect(result.id).toBe(1);
});
it("rejects unknown event types", async () => {
expect(
processEvent({ type: "unknown", payload: {} }, mockStorage)
).rejects.toThrow("Unknown event type");
});
});
One area where bun test diverges from Jest is module mocking. Jest’s jest.mock() hoists calls to the top of the file using Babel transforms. Bun’s module mock system does not use hoisting. Instead, you use mock.module() which intercepts imports at the module registry level. The behavior is equivalent but the mechanism differs, and code written for Jest’s hoisting behavior may need adjustment.
Tradeoffs Compared to Node.js and Deno
| Dimension | Bun | Node.js | Deno |
|---|---|---|---|
| JavaScript engine | JavaScriptCore | V8 | V8 |
| Runtime language | Zig | C++ | Rust |
| Async I/O | io_uring (Linux), kqueue (macOS) | libuv (epoll/kqueue + thread pool) | Tokio (epoll/kqueue + thread pool) |
| Cold start time | Fastest in class for short-lived scripts | Moderate | Moderate |
| Peak throughput (HTTP) | Competitive with or faster than Node.js | Baseline | Near Node.js |
| TypeScript support | Native (transpile only, no type check) | Via ts-node or tsx (external) | Native (transpile only) |
| Node.js compatibility | High but not complete | Complete | Partial (via compat flag) |
| Package manager | Built-in (bun install) | npm, yarn, pnpm (external) | URL-based or npm compat |
| SQLite | Built-in (bun:sqlite) | better-sqlite3 (npm) | Built-in (Deno.openKv is KV, not SQLite) |
| Bundler | Built-in | webpack, esbuild, Rollup (external) | Built-in (esbuild-based) |
| Test runner | Built-in (Jest-compatible) | Jest, Vitest (external) | Built-in |
| Maturity | Growing but younger ecosystem | 15+ years, massive ecosystem | ~5 years, growing ecosystem |
| Windows support | Available but less tested | Complete | Complete |
The biggest practical gap between Bun and Node.js is ecosystem compatibility. Bun implements a large subset of the Node.js API surface, including http, https, fs, path, crypto, events, stream, and child_process. Most packages that depend only on these APIs work without modification. Packages that use native Node.js addons (.node files compiled against Node.js’s ABI) do not work and cannot work without recompilation or replacement. In practice, the problematic packages are usually C extension bindings to native libraries. Pure JavaScript packages almost always work.
The second gap is long-term production confidence. Node.js has fifteen years of battle testing. Bun’s behavior under sustained load, complex memory pressure, and unusual OS configurations is less well characterized. The GitHub issue tracker tells the real story: Bun addresses bugs rapidly, but they exist in places Node.js ironed out years ago.
Production Considerations
Containerized deployments: Bun’s Docker images are based on Debian or Alpine. The Alpine image (oven/bun:alpine) is significantly smaller. If you build with multi-stage Dockerfiles, copy only the bun binary and your application code. The binary is self-contained.
Node.js compatibility shims: Before moving a production service to Bun, run bun run against your full test suite and look for module-not-found errors and unexpected behavior. Pay particular attention to any package that uses __filename, __dirname, require.resolve, or process.env in non-standard ways. Most issues surface in tests, not in production, which is the right order.
Heap profiling: Bun exposes Bun.gc(true) for forcing a GC cycle and basic memory stats via process.memoryUsage(). JSC’s heap inspector is accessible via bun --inspect, which opens a WebKit-compatible DevTools endpoint. The profiling tooling is functional but less mature than Node.js’s V8-based profiling ecosystem.
The io_uring kernel requirement: io_uring requires Linux kernel 5.6+ for full feature support (5.1 introduced it, 5.6 added socket operations). Most container hosts and modern distributions meet this. If you are running on kernel 4.x (older RHEL, some LTS distributions), Bun falls back to epoll, losing some of the async I/O advantage.
Worker threads: Bun supports worker_threads via the Web Worker API. Each worker runs in a separate JSC context. The API is compatible with Node.js’s worker_threads for message passing and SharedArrayBuffer patterns. CPU-bound work that would block the event loop should be offloaded to workers exactly as in Node.js.
Where Bun Actually Wins
The performance advantage is real but context-specific. Bun’s strongest wins are:
Scripts and CLI tools where startup time dominates. Running a TypeScript script with Node.js via tsx or ts-node adds 200-500ms of startup overhead before your code runs. Bun runs the same script in under 50ms cold. For tools that developers invoke hundreds of times per day, this is meaningful.
Test suite execution time. Bun’s test runner eliminates the Jest transform pipeline. A suite that takes 30 seconds under Jest often runs in 8-12 seconds under bun test, with the same test code and the same assertions. This is largely startup and transform overhead, not test logic.
HTTP throughput on Linux with io_uring. For workloads where network I/O is the bottleneck and the handler logic is lightweight (JSON parsing, database query, response serialization), Bun’s io_uring path reduces syscall overhead meaningfully. The difference narrows as handler logic becomes more CPU-intensive.
The case for Bun in production is strongest for greenfield services, internal tooling, and applications where you control the entire dependency tree. For existing Node.js services with native addon dependencies or large teams with deep Node.js operational knowledge, the migration cost is likely higher than the performance gain.
The underlying architectural choices, JavaScriptCore’s tiered compilation, Zig’s predictable memory model, io_uring’s ring buffer batching, are sound. The question is not whether Bun’s design is correct. It is whether the engineering maturity has caught up to Node.js for your specific workload. For many workloads in 2026, it has.
More in System Design
How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.
How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.
How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.
How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.