Rewriting Bun in Rust Bun, the JavaScript runtime acquired by Anthropic in December 2025, is being rewritten from Zig to Rust to address persistent memory safety bugs and stability issues. The rewrite, led by Bun's original creator using a pre-release version of Claude Fable 5, aims to systematically prevent use-after-free crashes and memory leaks that have plagued the runtime despite its 22 million monthly downloads. Disclosure: Bun was acquired by Anthropic in December 2025. I and others on the Bun team work at Anthropic. I used a pre-release version of Claude Fable 5 for much of the Rust rewrite. Bun started as a line-for-line port of esbuild's JavaScript & TypeScript transpiler from Go to Zig. I wrote my first line of Zig on April 16, 2021 https://github.com/ziglang/zig/issues/8575 . I bet on Zig after seeing the single-page Zig Language Reference https://ziglang.org/documentation/master/ on Hacker News and getting really excited about the low-level control and care for performance. From the start, Bun's scope was massive: - JavaScript, TypeScript, and CSS transpiler, minifier, and bundler - npm-compatible package manager - Jest-like test runner - Node.js & TypeScript-compatible module resolution - HTTP/1.1 & WebSocket client - Node.js API implementations like fs , net , tls , and dozens of other modules The initial version of Bun was written by me in 1 year, in a cramped Oakland apartment, pre-LLM, in Zig. The default outcome for ambitiously-scoped projects like Bun is joining the graveyard of dead side projects on a GitHub profile page. Zig made Bun possible. I would never have been able to build this much in 1 year if it wasn't for Zig. Nowadays, Bun's CLI gets over 22 million monthly downloads. Popular tools like Claude Code and OpenCode bet on Bun as their runtime. Vercel, Railway, DigitalOcean and more have 1st-party support for Bun. Bun's scope has also been a challenge for stability. Here's a small sample of bugs we fixed in Bun v1.3.14: - heap-use-after-free crash in node:zlib when calling .reset on a zlib, Brotli, or Zstd stream while an async .write is still in progress on the threadpool - use-after-free crash in node:zlib when an onerror callback issued a re-entrant write followed by close on native handles - use-after-free crashes in node:http2 when re-entrant JS callbacks e.g. session.request inside a timeout listener, an options getter, or a write callback triggered a hashmap rehash, invalidating internal stream pointers - use-after-free in UDPSocket.send and sendMany where user code in valueOf or toString callbacks could detach an ArrayBuffer between payload capture and the actual send - crash and out-of-bounds read in Buffer copy and Buffer fill when a valueOf callback detaches or resizes the underlying ArrayBuffer during argument coercion - heap out-of-bounds write in UDPSocket.sendMany when the socket's connection state changed mid-iteration via user JS callbacks - memory leak in crypto.scrypt where the callback and protected password/salt buffers were never released when the output buffer allocation failed SSLWrapper.init leaked the strdup'd passphrase on error paths- memory leak in tlsSocket.setSession where each call leaked one SSL SESSION ~6.5 KB per call due to a missing SSL SESSION free after d2i SSL SESSION - memory leak where fs.watch watchers were never garbage collected after .close , caused by a reference count underflow that permanently pinned each watcher as a GC root - double-free crash in the CSS parser when background-clip had vendor prefixes and multi-layer backgrounds DuplexUpgradeContext was never freed — a full leak per tls.connect { socket: duplex } - race condition crash in MessageEvent where the GC marker thread could observe a torn variant in m data during concurrent access from a BroadcastChannel or MessagePort We could have kept fixing these kinds of bugs one-off in perpetuity, but we owe it to our users counting on us to do better than that, and systematically prevent these kinds of bugs from recurring. What we were already doing what-we-were-already-doing - We patched the Zig compiler to add Address Sanitizer support. We run our test suite with ASAN on every commit. - We ship Zig safety-checked ReleaseSafe builds on Windows - We fuzz Bun's runtime APIs 24/7 using Fuzzilli https://github.com/googleprojectzero/fuzzilli , the JavaScript engine fuzzer used by V8 & JavaScriptCore - We have a whole lot of end-to-end memory leak tests This is more than many projects do. Just be really smart and don't make mistakes? just-be-really-smart-and-don-t-make-mistakes Our bugfix list felt bad and I was tired of going to sleep worrying about crashes in Bun. I don't blame Zig for that - other users of Zig don't have the bugs we had, and mixing GC with manually-managed memory is an uncommon enough thing for software to need that no language really designs for it. We wouldn't have gotten this far if not for Zig, and I'll always be grateful. Until very recently, programming language choice was a one-way decision for a project like Bun. JavaScript is a garbage-collected language and modern JavaScript engines like JavaScriptCore and V8 have strict rules around exception handling and the garbage collector. Zig, like C, doesn't manage memory for you and this is a tradeoff that for many projects is a great reason to use Zig. Zig does not have constructors/destructors, and most cleanup is expected to be written out explicitly at each call site with defer . For Bun, correctly handling the lifetimes of garbage-collected values and manually-managed values has been a major source of stability issues - most often small memory leaks and occasionally, crashes. Every memory allocation has to be meticulously reviewed. Where do these bytes get freed? How do we ensure it only gets freed once? Did we check for JavaScript exceptions properly? Is this garbage-collected pointer visible to the conservative stack scanner? Is this garbage collected memory or manually managed memory? For stability issues, knowing as early as possible is best. Fuzzing happens after code is merged. CI happens when code is pushed. Runtime safety checks & address sanitizer happens when code is run hopefully in development, before CI . One common way to reduce this class of issue is to ensure cleanup code is always run exactly once for code that needs it. Zig is designed to be a simple language with no hidden control flow, and so it prefers the explicit defer keyword to run code at the end of a scope over C++'s implicit ~Destructor or Rust's implicit Drop . | Language | Cleanup | |---|---| | Zig | defer , errdefer | | C++ | ~Destructor, &&Move | | Rust | Drop | For Zig code, when exactly should we be running the cleanup code? If we're passing the same T to many different functions, how do we know when it's no longer accessible and can be cleaned up? How does it work when some functions need to continue to reference the memory after the function is called? Our current approach is a mix of: - arena lifetimes, where the scope of when it's accessible is clear parser state doesn't escape the calling function and so AST nodes are a good choice there - reference-counting - pay really close attention Many projects opt to answer these kinds of questions through a style guide. TigerBeetle's TigerStyle https://tigerstyle.dev/ is an example in Zig and Google's 31,000 word C++ style guide https://google.github.io/styleguide/cppguide.html is another. The challenge with style guides is enforcement. How do you make sure the style guide is followed? Historically, code review was the answer with best-effort enforcement via linters & static analyzers. Having a rigid style guide with clear ownership expectations explicitly spelled out in the type system was a real option for Bun. Since Zig has no operator overloading, we would likely end up with a lot of code looking something like this: js fn foo a ptr: SharedPtr TCPSocket void { const a: TCPSocket = a ptr.get ; defer a ptr.deref ; const b = try do something with a a ; defer b.deref ; // ... } This is less ergonomic than the Zig we expect: js fn foo a: TCPSocket void { const b = try do something with a a ; // ... } What about C/C++? what-about-c-c About 20% of Bun's code is written in C++ and Bun embeds several C/C++ libraries: - JavaScriptCore, the JavaScript engine that powers Safari - uWebSockets & usockets - our HTTP/WebSocket server, and event loop - lshpack & lsquic - HPACK and HTTP/3 libraries - BoringSSL, Google's OpenSSL fork - SQLite C++ instead of Zig would be a reasonable choice for Bun. We would get constructors & destructors. We could delete lots of extern "C" wrapper code. But, we would still be reliant on style guides enforced through code review, and even with ASAN, memory corruption and memory leaks would still happen. Why Rust? why-rust A large percentage of bugs from that list are use-after-free, double-free, and "forgot to free" in an error path. In safe Rust, these are compiler errors and RAII-like automatic cleanup with Drop . Compiler errors are a better feedback loop than a style guide. Historically, rewrites are a terrible idea. Excluding comments, Bun is 535,496 lines of Zig. A rewrite in another language would take a small team of engineers a full year. It would mean freezing bugfixes, security fixes or feature development for that time. The least risky approach to getting something shippable would be a mechanical port from Zig to Rust, with the minimal number of behavioral changes, using the exact same test suite we already use for testing Bun. Fortunately, Bun's own test suite is written in TypeScript which means it doesn't depend on the runtime's programming language. A year of zero user-facing impact is not a realistic option we could consider. So, enforcement through code-style to fix stability issues was our best bet, and was our plan when we added Rust-inspired smart pointers https://github.com/oven-sh/bun/blob/3a79bd746b11601c9db970b608c73f0b9f96ac81/src/ptr/shared.zig L569 to Bun's codebase. But honestly, I didn't want to do it. Homegrown smart pointers offer worse ergonomics than Rust, with none of the guarantees. What if, instead, I spend a week testing if Anthropic's new model can rewrite Bun in Rust? At first, I didn't expect it to work. A few days in, a high % of the test suite started passing and I saw how much the new Rust code matched up with the original Zig codebase. My opinion went from "this is worth trying" to "I'm going to merge this". Claude, rewrite Bun in Rust. claude-rewrite-bun-in-rust There are a lot of ways to do a terrible job of this. For example, prompting Claude "Rewrite Bun in Rust. Don't make any mistakes." and then praying it would work is not what I did. Think about how a person would do this. The first big question is: Incremental rewrite? Or, everything all at once? In my experience porting esbuild's transpiler from Go to Zig for the initial version of Bun without LLMs , everything all at once is better. An incremental rewrite adds temporary code that you hope gets deleted eventually, and would be painful in the short-medium term. The second big question: how? How do we keep Bun in Rust the same Bun as before, with the same architecture, performance, and feature-set while also getting the language features of Rust like the borrow checker? How do we ensure the team can still maintain it after the rewrite? Do the rewrite that looks like we transpiled our Zig code to Rust. We can gradually refactor it to reduce unsafe usage and look more like idiomatic Rust after Bun v1.4 ships. Those are the only two big questions. Everything else is tactics. Loops that write & review code loops-that-write-review-code A lot of day-to-day engineering work as software engineers can be over-simplified into loops. js // Pseudocode, not real code: let task; while task = todoList.pop { const result = task ; const feedback = await Promise.all review result , review result ; await apply feedback, result ; } A task has some context associated with it a Jira ticket, a GitHub issue, etc . The result is the code you wrote to fix it. Code reviewer s review the changes to check for regressions & correctness. And then you address the feedback. I rewrote Bun in Rust using about 50 dynamic workflows in Claude Code run continuously over the course of 11 days. Each dynamic workflow was a loop like this - a workflow for: - Generate a porting guide mapping Zig patterns & types to Rust patterns & types - Mechanically port every .zig file to a .rs file, matching the PORTING.md and LIFETIMES.tsv - Fix every crate's compiler errors - Get subcommands like bun test or bun build to work - Get every test in Bun's entire test suite to pass - Several large refactors and cleanup passes For most of those 11 days and after , I monitored workflows - manually reading the outputs to check for issues and bugs, and prompting Claude to edit the loop to fix things. How do you review a PR with +1 million lines added? How do you start to build the confidence needed to responsibly merge large quantities of LLM-authored code? A language-independent test suite with a million assertions, adversarial code review and when something does go wrong, fixing the process that generates the code instead of hand-fixing the code. Adversarial review adversarial-review Adversarial review asks Claude in a separate context window to exhaustively come up with reasons why the changes create bugs or do not work. Split context windows Usually with humans, the person reviewing the code is not the person who authored the code. The person writing the code wants to merge the code, which can bias their actions to ship before it's ready. Claude is the same way. The Claude that wrote the code wants the code to get accepted. The Claude that reviews wants to find issues in the code. 1 implementer, 2 or more adversarial reviewers per implementer. The reviewer's only job: find bugs & reasons why the code does not work. The implementer doesn't review. The reviewer doesn't implement. What does this look like? what-does-this-look-like If you're about to do something big and expensive, it saves time and money to de-risk it first. Prep work prep-work Before writing any code, I spent about 3 hours talking to Claude about how to map patterns from our Zig codebase closely to Rust. Claude serialized this discussion into a PORTING.md document, which ended up on Hacker News https://news.ycombinator.com/item?id=48016880 . The next question: how do you add Rust lifetimes to code that manually manages memory? That's where I prompted Claude something like this: Me: Let's kick off a dynamic workflow to analyze the proper lifetimes of every struct field in the codebase. This workflow should read every struct field within every single file and trace the control flow. First, look for struct fields with complex lifetimes to express in Rust, then propose a lifetime for that field, then use 2 adversarial review agents to review that lifetime, then apply any feedback and serialize into a LIFETIMES.tsv for other claudes to look at. Then a round of adversarial reviews on the PORTING.md and the LIFETIMES.tsv together to fix any conflicting suggestions and double check everything. I also manually read over it. Trial run trial-run Before asking Claude to translate all 1,448 .zig files to .rs files, I started with just 3. For each of the 3 files, 1 implementer wrote the new .rs file, 2 adversarial reviewers checked the .rs file matched the behavior of the .zig file and that it followed the PORTING.md & LIFETIMES.tsv . After that, 1 fixer applied any suggestions. False starts false-starts I asked Claude to loop the workflow on all 1,448 .zig files, and about 2 minutes in, one Claude ran git stash before committing. Another ran git stash pop . And then git reset HEAD --hard . They were stepping on each other And if I put each Claude into a separate worktree, I would run out of disk space because Bun's git repository is too big and eventually the changes will need to be compiled and seen together. So, I asked Claude to edit the workflow to instruct Claude to never run git stash or git reset or any git command that doesn't commit a specific file at once. No cargo either. No slow commands at all. Then, Claude resumed the workflows. And it was working Too slowly, so I split it into just 4 workflow shards each with their own worktree 4 worktrees total , each running 16 claudes committing and pushing files. Finally writing the code finally-writing-the-code Thanks to all the parallelization & this prep work, at peak Claude wrote about 1,300 lines of code per minute. Every line of code was reviewed by two separate adversarial reviewers also Claude and went through a round of fixes before committing. Absolutely none of it worked yet. Notice the inconsistent timing? I forgot to increase the default IOPS on the EC2 instance this ran on. One slow grep command was all it took to freeze disk reads & writes for minutes. Compiler errors as a work queue compiler-errors-as-a-work-queue After writing all the code, I asked Claude to write a workflow fixing every compiler error. We went crate-by-crate. The trickiest class of error was cyclical dependencies. Our Zig codebase was one compilation unit effectively one crate . I wanted to split the new Rust codebase into ~100 crates so the Rust would compile faster, but this needed to avoid cyclical dependencies while minimizing changes compared to the original Zig implementation. My PR https://github.com/oven-sh/bun/pull/30224 to do this immediately before starting the Rust rewrite was insufficient. Instead of starting over, I ran another workflow to classify where the code with cyclical dependencies should go and write it all down - and then another workflow to do the refactor. Fixing the cyclical dependencies revealed about 16,000 compiler errors. A massive number for 1 human, but not a crazy number for 64 claudes at once. To maximize parallelism, the workflow looped over each crate. - For each crate, run cargo check , group the output by file and save the errors to a file - Fix all the compiler errors within that crate - 2 adversarial reviewers for the crate's changes - 1 fixer applies the fixes To prevent claudes from stepping on each other, cargo check only ran at the very start and like the other runs, no git until the end. Another false start Claude interpreted "let's get all the crates to compile" as "stub out the functions with compilation errors". Claude also started adding suspiciously long explanatory comments to document workarounds, so I added this rule for the adversarial reviewers to reject: If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code. One prompt edit and a few hours later, these things stopped happening. Smoke tests smoke-tests Models love saying "smoke tests" Once cargo check passed, getting it to compile and run bun --version was next. It had linker errors. Then, it panicked immediately on start. The next goal was to get it to run bun test