# Bun Rewrites in Rust: What It Means for the JavaScript Runtime

> Bun merged a million-line PR rewriting its core from Zig to Rust. This post covers the technical motivations, what changed in the codebase, measured performance impact, and what it signals for the JavaScript runtime ecosystem.

- Author: Fawad Hussain Syed, software engineer, Geneva (https://fawadhs.dev/about)
- Published: 2026-05-24
- Category: Review
- Canonical: https://fawadhs.dev/blog/bun-rust-rewrite-technical-review

---
On May 11, 2026, Jarred Sumner merged PR #30412 into Bun's main branch: "Rewrite Bun in Rust." 1.01M lines added across 6,755 commits. The JavaScript runtime that built its identity on Zig is now primarily Rust.

## The Numbers

| Metric | Before | After |
| --- | --- | --- |
| Primary language | Zig | Rust (46.6%) |
| Zig proportion | ~70% | 32.2% |
| C++ (JSC bridge) | ~15% | 13.2% |
| Binary size | Baseline | 3–8 MB smaller |
| Test suite | Passes | Passes (+ fixes flaky tests) |
| Performance | Baseline | Neutral to faster |

The PR itself: 1.01M additions and 4,024 deletions. The branch name — `claude/phase-a-port` — hints at AI-assisted porting, consistent with Bun's acquisition by Anthropic in December 2025.

## Why Move Away from Zig?

Zig was Bun's identity — the "fast systems language that isn't C++" story. So why leave?

Jarred Sumner:

> We now have compiler-assisted tools for catching & preventing memory bugs, which have costed the team an enormous amount of development & debugging time over the years.

It's about debugging time. Zig doesn't have a borrow checker. No compile-time prevention of use-after-free, no data race detection. When your runtime manages millions of objects, handles concurrent I/O, and talks to a garbage collector (JavaScriptCore), these bugs show up constantly.

The follow-up PRs confirm the scale of the problem:
- "Fix effectively every native-code memory leak in Bun" — landed days after the merge
- "clippy: 45 deny lints + fix 2735 violations across workspace" — 2,735 issues that Rust's linter caught automatically

None of this would've surfaced easily in Zig. Rust's tooling just hands it to you.

## What Actually Changed (and What Didn't)

From the PR description, the architectural choices are conservative:

- **Same architecture, same data structures.** No redesign, no new abstractions for the sake of being "Rusty." The code maps 1:1.
- **Few third-party libraries.** They didn't pull in hundreds of crates. Minimal dependencies carried over.
- **No async Rust.** No Tokio, no async runtime. The event loop stays custom, tuned for JS runtime semantics. They sidestepped Rust's async complexity entirely.
- **C++ remains for JSC.** The JavaScriptCore bridge is still C++. Code generation (`generate-classes.ts`) produces the Rust ↔ C++ bindings.

Contributors now need a specific Rust nightly toolchain (pinned in `rust-toolchain.toml`).

## Claude Code as the Implementation Tool

Bun was acquired by Anthropic in December 2025. Every PR in the rewrite uses a `claude/` branch prefix:

| PR | Branch | Commits | Lines Changed |
| --- | --- | --- | --- |
| #30412 (main rewrite) | `claude/phase-a-port` | 6,755 | +1.01M / −4,024 |
| #30875 (memory leaks) | `claude/asan-system-allocator` | 251 | +5,004 / −1,822 |
| #30901 (bundler perf) | `claude/bundler-perf-2x` | 34 | +408 / −256 |
| #31115 (main-thread perf) | `claude/bundler-main-thread-perf` | 18 | +1,324 / −1,112 |
| #31116 (code quality) | `claude/clippy-deny-sweep` | 53 | +18,626 / −23,043 |
| **Total** | | **7,111** | **+1.03M / −30,257** |

Every branch uses the `claude/` prefix. The Claude bot shows up as a reviewer on all 5 PRs. GitHub Actions stamps commits with "Generated with Claude Code."

The clippy sweep (PR #31116) gives the clearest picture of the workflow: 4 parallel AI fixer shards, 2 AI reviewers per diff that both had to approve, 9 rounds until zero violations. Commit messages reference `review-round.workflow.ts` — it's a programmatic loop, not someone sitting at a keyboard.

Jarred directed the work, set the goals, reviewed output, and merged. The test suite is what validates correctness — it passes on all platforms.

## Merge Timeline: 11 Days, 7,111 Commits

The entire sequence — from opening the main PR to landing the last follow-up — took **11 days**:

```mermaid
gantt
    title Bun Rust Rewrite — PR Timeline
    dateFormat YYYY-MM-DD
    axisFormat %b %d

    section Main Port
    30412 main rewrite, 6755 commits :active, pr1, 2026-05-10, 2026-05-17

    section Follow-up
    30901 bundler perf               :pr3, 2026-05-16, 2026-05-17
    30875 memory leaks, 251 commits  :pr2, 2026-05-17, 2026-05-19
    31115 main-thread perf           :pr4, 2026-05-19, 2026-05-20
    31116 clippy sweep               :pr5, 2026-05-19, 2026-05-21
```

That's 646 commits/day on average. The main PR alone landed 965/day. Only makes sense with AI generating in bulk while CI validates and Jarred reviews.

After the main merge, the follow-up sprint (May 17–21) knocked out quality in 4 days:
1. Bundler hot-path profiling and fixes (34 commits)
2. Native memory leak detection and repair (251 commits)
3. Performance gap vs Zig closed (18 commits)
4. 45 clippy lints enforced, 2,735 violations fixed (53 commits)

## Performance

Jarred's initial line was "benchmarks are between neutral and faster." The follow-up PRs paint a more specific picture.

### Bundler Benchmark: 10,000 Modules

Using the `rolldown-benchmark apps/10000` suite on linux x64, 64-core:

| Build | Wall Time | Total CPU | Main Thread | vs Zig Baseline |
| --- | --- | --- | --- | --- |
| Rust (initial port) | 3,819 ms | 3,065 ms | 470 ms | 1.10× slower |
| Rust (after PR #30901) | ~3,600 ms | ~2,800 ms | ~445 ms | ~1.06× slower |
| Rust (after PR #31115) | 3,443 ms | ~2,680 ms | 429 ms | **1.05× faster** |
| Zig v1.3.14 baseline | 3,398 ms | 2,417 ms | 422 ms | — |

Interesting bit: total CPU is *lower* than Zig even when wall time was higher. The bottleneck wasn't compute — it was main-thread serialisation blocking the parallel work from actually running in parallel.

### What Fixed It

PR #31115 went after the hot-path regressions one by one:

- **`load_node_modules` self-time:** 1.28% → <0.26% (eliminated path allocation overhead)
- **Tree-shaking allocations:** eliminated ~1.1 million alloc+free pairs by iterating by index instead of cloning per-part Vecs
- **`Path` struct size:** 120 bytes → 56 bytes (stopped storing `PathName` inline, compute on demand)
- **`MatchResult` copying:** 300-byte struct was copied by value through 7 function levels — now passed as `&mut` out-parameter
- **48 spurious `#[cold]` annotations** on `bundle_v2.rs` — the Zig-to-Rust port marked every public method `#[cold]`, which told LLVM to deprioritise the hot path
- **`Part::is_live` field:** moved to a per-file `AutoBitSet` so `mark_part_live` checks a bit before loading the full 272-byte `Part` struct
- **`dir_info_cached_maybe_log` stack frame:** 8.6 KB → 72 bytes (outlined the cache-miss tail)

### Memory Leaks

PR #30875 (251 commits, 5,004 lines) made Bun "effectively leak-free."

The root cause was sneaky: Zig builds used libc malloc under ASAN, so LeakSanitizer could see every allocation. The Rust port unconditionally used mimalloc with `MI_TRACK_ASAN=1`, which only annotates shadow state — LeakSanitizer couldn't see most leaks at all.

Fix: `#[global_allocator]` is now gated on `cfg(bun_asan)`. ASAN builds use `std::alloc::System` (libc malloc), giving LeakSanitizer full visibility. Once the leaks were visible, they turned up everywhere:

- Bundler & linker — graph columns, chunk data, CSS rule slabs, sourcemap tables
- Parser / AST / CSS — arena-stranded heap fields on reset
- Package manager — Task request/data union arms, manifest cache, workspace cache
- Dev server — transpilers, arena-stored chunks, framework projections
- Runtime — FetchTasklet, ReadableStream, subprocess refcount imbalances
- VM teardown — RareData stores, transpiler heap fields, timer draining

### Code Quality: 2,735 → 0

PR #31116 turned on 45 deny-level clippy lints and fixed every violation:

| Fix Category | Count |
| --- | --- |
| `// SAFETY:` comments on unsafe blocks | 1,341 |
| Function signature changes (by-value → by-ref) | 840 |
| Unsafe pointer deref fixes (`not_unsafe_ptr_arg_deref`) | 156 |
| `mem_forget` → `ManuallyDrop`/`Box::into_raw` | 47 |
| Dead code removed (net LOC) | −6,200 |

The lint categories show what the initial port got wrong:

- **Soundness:** `undocumented_unsafe_blocks`, `mem_forget`, `cast_ptr_alignment`, `uninit_vec`
- **Performance:** `redundant_clone`, `needless_collect`, `large_types_passed_by_value`, `large_stack_frames`
- **Disallowed std methods:** `std::sync`, `std::fs`, `std::collections` all routed through Bun's own thread-safe, wyhash-backed wrappers

Miri (Tree Borrows model) CI was also added for all FFI-free crates — formal verification of memory aliasing.

## What This Means If You Use Bun

**Right now:** nothing breaks. Canary builds pass the full test suite. Try it with `bun upgrade --canary`. Stable releases don't include the Rust code yet.

**Soon:** fewer mystery crashes. Use-after-free, double-free, buffer overflows — the bugs behind "Bun crashed and I don't know why" reports — are the exact class that Rust's borrow checker prevents at compile time.

**Later:** faster feature velocity from the team. Less time on memory debugging means more time shipping. Better profiling (perf), fuzzing (cargo-fuzz), and static analysis (clippy, miri) come with the Rust ecosystem.

## Ecosystem Implications

### Zig

Bun was the highest-profile Zig project in production. Losing it suggests that missing compile-time safety guarantees is a real cost at this scale — not a theoretical one.

That said, 32% of Bun is still Zig. Its strengths (simple mental model, C interop, comptime) haven't gone away. This is about bug surface area at Bun's scale, not about Zig being a bad language.

### Rust for JS Runtimes

Node.js: C++. Deno: Rust. Bun: now Rust. The pattern is consistent — memory safety without GC, strong concurrency guarantees, good C/C++ interop for engine integration.

### AI-Driven Porting

The workflow across all 5 PRs was the same loop:

1. Claude generates bulk translations (Zig semantics → Rust)
2. CI runs the test suite on all platforms
3. Claude does adversarial review
4. Jarred approves architecture-level decisions
5. Claude fixes what breaks

With a comprehensive test suite, an AI can translate a codebase and the tests prove it works. The follow-up PRs show the same loop applied to performance and code quality work.

## Build System Changes

The migration touches the full build pipeline:

- **Build tool:** `bun run build` compiles the Rust workspace via Cargo + CMake for C++ components
- **Toolchain:** Rust nightly (pinned in `rust-toolchain.toml`) + LLVM 21 for C++
- **Code generation:** TypeScript scripts generate Rust ↔ C++ FFI bindings
- **Linting:** clippy with 45 deny lints enforced in CI
- **Formatting:** `cargo fmt --all` replaces `zig fmt`
- **Sanitizers:** AddressSanitizer covers Rust code, C++ bindings, and all dependencies in debug builds
- **Incremental builds:** Cargo's incremental compilation makes Rust-only rebuilds fast; only the final link step is unavoidable

For contributors, the workflow is now:

```bash
# Type-check without linking (fast feedback)
cargo check -p <crate>

# Or check entire workspace
bun run rust:check

# Watch mode — runs cargo check on every save
bun run watch
```

## Community Reaction

PR #30412's GitHub reaction ratio:
- 👍 1,607 (50.6%)
- 👎 1,559 (49.4%)
- 😄 577

A near-even split with 1,384+ comments — an unusually divisive PR. The follow-up PRs (#30875, #30901, #31115, #31116) received no controversy.

## Bottom Line

1.03M lines across 5 PRs in 11 days. All on `claude/` branches at 646 commits/day. Bundler went from 12% slower to roughly matching the Zig baseline. Memory leaks fixed across every native subsystem. 2,735 clippy violations cleaned up, 6,200 lines of dead code removed.

The test suite passes on all platforms, the binary is smaller, and the Rust toolchain now catches at compile time what used to be weeks of debugging.

Try it: `bun upgrade --canary`. Stable release is waiting on a ~250ms main-thread gap on 100k-module bundles — five functions each under 2% CPU, mostly cache-miss-bound.
