Rewriting Bun in Rust

The creator of Bun explains the transition from Zig to Rust, detailing the complex memory management challenges of mixing a garbage-collected runtime with manual memory allocation.
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. I bet on Zig after seeing the single-page Zig Language Reference 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:zlibwhen 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:zlibwhen anonerrorcallback issued a re-entrantwrite()followed byclose()on native handles - use-after-free crashes in
node:http2when 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()andsendMany()where user code invalueOf()ortoString()callbacks could detach anArrayBufferbetween payload capture and the actual send - crash and out-of-bounds read in
Buffer#copyandBuffer#fillwhen avalueOfcallback detaches or resizes the underlyingArrayBufferduring 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.scryptwhere the callback and protected password/salt buffers were never released when the output buffer allocation failed SSLWrapper.initleaked the strdup'd passphrase on error paths- memory leak in
tlsSocket.setSession()where each call leaked oneSSL_SESSION(~6.5 KB per call) due to a missingSSL_SESSION_freeafterd2i_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-cliphad vendor prefixes and multi-layer backgrounds DuplexUpgradeContextwas never freed — a full leak pertls.connect({ socket: duplex })- race condition crash in
MessageEventwhere the GC marker thread could observe a torn variant inm_dataduring concurrent access from aBroadcastChannelorMessagePort
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.
- 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, 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.
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 is an example in Zig and Google's 31,000 word C++ style guide 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:
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:
fn foo(a: *TCPSocket) !void {
const b = try do_something_with_a(a);
// ...
}
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 -
HPACKand 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...
Source: Hacker News















