11°
Portada del artículo: Bun rewrote itself from Zig to Rust in eleven days. I measured the five numbers they published
RustArquitectura

Bun rewrote itself from Zig to Rust in eleven days. I measured the five numbers they published

Bun ported close to a million lines from Zig to Rust and published five numbers to show it went well. Version 1.3.14 was the last Zig build and 1.4.0 the first Rust one, so you can download both and measure the same thing with only the binary changing. Two figures fall short, two beat what was announced, and one had to be withdrawn.

Efrain Garay 28 August 2026

A large project announced it had rewritten its entire codebase in another language, and published five numbers to prove it had gone well. The numbers were good. Suspiciously round, too: five times less CPU, half the memory, twice the start-up speed.

The rewrite itself interested me little. Something else caught my eye: both versions can be downloaded. 1.3.14 was the last build in Zig and 1.4.0 the first in Rust, with the same server running on top and the same system underneath. The only variable that changes is the binary. That turns an announcement into something checkable, and checking it costs an afternoon.

So I downloaded both and measured the five figures.

The bench actually running: four runtimes in containers with one core and 512 MB each, load alternating between them, and live cgroup readings. Narrated.Watch it in the reel viewer →

What Bun is, and why this is measurable

Bun runs JavaScript and TypeScript, the same role as Node, inside a single binary that also ships a package manager, a bundler and a test runner. It sells itself as a drop-in replacement for Node.

The project describes the translation as eleven days of intensive work over roughly 535,000 lines of Zig, and publishes a breakdown of how it organised that. The process deserves its own conversation. What interests me here is narrower and more checkable: does the resulting binary do what they say it does.

The five published figures are these: start-up 50% faster on Linux, five times less CPU while idle, 48% less memory on HTTP servers, between 2% and 5% more requests per second, and around 20% smaller binary.

The setup, and why the first attempt was worthless

Both versions go to separate directories, without touching any working installation:

for V in 1.3.14 1.4.0; do
  curl -fsSL https://bun.sh/install | BUN_INSTALL=$PWD/i-$V bash -s "bun-v$V"
  cp $PWD/i-$V/bin/bun $PWD/bin/bun-$V
done

The first figure comes from there, without running anything: 92.75 MB for the Zig binary, 80.76 MB for the Rust one. That is 12.9% less. The project talks about roughly 20%, and the difference probably lies in exactly what gets weighed; the number above is the binary their installer downloads, measured with ls.

The test server is the same for all three candidates, with no dependencies and no framework on top, because any library would be a variable I do not control:

const handler = () => new Response("hello", {headers: {"content-type": "text/plain"}});

if (typeof Bun !== "undefined") {
  Bun.serve({port, fetch: handler});
} else {
  require("http").createServer((req, res) => {
    res.writeHead(200, {"content-type": "text/plain"});
    res.end("hello");
  }).listen(port);
}

And here comes the first stumble, worth more than several of the measurements.

The five figures, measured

All on the same machine, a Ryzen 7 7800X3D with 30 GB of RAM, on CPU.

bun 1.4 announcement · against my bench
2/ 4se sostienen al medirlas
Cold start, on Linuxse sostiene
dicen 50% faster

61,4% fasterFrom 7.58 ms to 2.93 ms, median of the 60 runs in the chart below.

CPU while idlese sostiene
dicen 5x less

9,3x lessOver 300 seconds: 216.19 ms of CPU against 23.18 ms. Repeated, the multiple moves between 8.8 and 13.8.

Memory of an HTTP serverno llega
dicen 48% less

38,2% lessAfter serving traffic. Right after the process starts it does reach 55.5%, so the number depends on when you look.

Binary sizeno llega
dicen 20% less

12,9% lessFrom 92.75 MB to 80.76 MB, weighing the binary their official installer downloads.

The grey mark on each bar is what the announcement promises; the bar is what came out when measuring it. A fifth row is missing, requests per second: I withdrew it after finding that the server and the generator shared physical cores. Explained further down.

The start-up gain is real and it is huge

It is the largest difference of all, and the easiest to check: run a file with a console.log and time it.

start up and run one line · 60 runs each · ms

bun 1.4.0 · Rust2,93ms

The tightest cloud of the three: 2.85 to 3.10 ms.

bun 1.3.14 · Zig7,58ms

node 22.23.215,81ms

Las 60 corridas de cada candidato, en ms
candidatomedianamínimomáximo
bun 1.4.0 · Rust2,932,853,10
bun 1.3.14 · Zig7,587,458,63
node 22.23.215,8114,7818,07

Runs drop in the order they were actually executed, and the median is recomputed with each one. The three clouds end up not touching: when the separation is this clean, the difference belongs to the program and not to one lucky run.

61.4% faster in this case, against the 50% they announce. With one caveat: this is a console.log with the binary already in page cache. It exercises no modules, no dependency resolution and no transpilation, and those are exactly the stages a real start-up does pay for. The Rust binary is also the steadiest of the three: its sixty runs fit inside 250 microseconds, while Zig has a loose spike of 8.63 ms and Node one of 18.07.

That steadiness is the reason to publish the median and not the average. A single scheduler spike drags a whole average, and on a shared machine there is always one.

Idle CPU cannot be measured with the obvious tools

Here I picked the wrong instrument before getting it right. A server that is up with nobody asking it anything should consume practically zero, and that is exactly what makes it hard to measure.

The first pass used the counters in /proc/<pid>/stat, which count in ten-millisecond ticks. Over three hundred seconds it gave eight ticks for Zig and one tick for Rust. Dividing that yields “eight times less”, which sounds like a result. It is not: with one tick of resolution you cannot tell “eight times less” from “nothing measurable”, and Node in that same pass gave zero ticks, which is to say infinity.

The right tool is /proc/<pid>/schedstat, which exposes CPU time in nanoseconds, six orders of magnitude better, and sums all the threads of the process:

def cpu_ns(pid):
    """CPU nanoseconds for the process, summing all its threads."""
    total = 0
    for tid in os.listdir(f"/proc/{pid}/task"):
        total += int(open(f"/proc/{pid}/task/{tid}/schedstat").read().split()[0])
    return total

At that resolution the number comes out clean, and it is the most favourable of the five.

schedstat · CPU accumulated by an idle server · 300 s
215,1bun 1.3.14 · Zig
22,2bun 1.4.0 · Rust
150 s
started5 min
bun 1.3.14 · Zig · 215,1 msburns CPU in all 299 secondsbun 1.4.0 · Rust · 22,2 msonly in 47 of 299

Nobody asks this server for anything during these five minutes. The Zig curve rises in every single one of the 299 seconds; the Rust one stays flat and takes 47 scattered steps. The total matters less than the shape: one keeps waking up and the other actually sleeps.

Nine point three times less, against the five they announce. But the total is the least interesting thing in the chart.

What you see when it plays back is that the two curves have different shapes. Zig’s is a ramp: it climbs in all 299 seconds, skipping none. Rust’s is an almost flat staircase, with 47 steps across the whole window. Rust does not make each wake-up cheaper. It wakes up six times less often.

This is the only figure I did not measure on the same machine as the rest. Halfway through the afternoon the test machine filled up with other work, and idle is precisely the most sensitive thing to that: an idle process on a saturated machine wakes more often through contention and accrues CPU that is not its own. The numbers here come from a four-core server at 0.05 load, where measuring a sleeping process bothers nobody.

And something has to be said about the precision of that multiple. I repeated the experiment three times and got 8.8, then 13.8, then 9.3, on the same machine with the same method. When numerator and denominator are both tiny, the ratio between them dances. The exact multiple is not a firm figure; that it beats the announced five times, it is, because that held across every run.

One honest clarification: both consume an irrelevant amount of CPU while idle. Two hundred and sixteen milliseconds over five minutes ruins nobody’s day. Where this matters is multiplied by hundreds of processes, which is exactly the case the project mentions.

Memory improves, but not as much as they say

RuntimeJust startedAfter serving traffic
bun 1.3.14 · Zig36.4 MB44.5 MB
bun 1.4.0 · Rust16.2 MB27.5 MB
node 22.23.248.9 MB62.0 MB

Here is the nuance that explains the gap. Right after the process starts, the improvement is 55.5%, above the announced 48%. After serving real traffic it drops to 38.2%, below it.

I do not think anyone cheated. The number depends on when you look, and an announcement rarely spells that out. If what you care about is what the process will occupy in production, the useful figure is the one after traffic, and that is 38.2%.

That said, the improvement is large anyway, and both Bun versions land far below Node.

Requests per second: I am withdrawing this measurement

I published a tie here, and it was badly measured. I am leaving it written with the mistake in plain sight because the cause is the kind of trap that repeats.

I pinned the server to cores 10 and 11, and the load generator to cores 0 through 5. By the numbers they look like separate sets. They are not:

$ cat /sys/devices/system/cpu/cpu10/topology/core_cpus_list
2,10
$ cat /sys/devices/system/cpu/cpu11/topology/core_cpus_list
3,11

CPU 10 is the logical sibling of CPU 2, and 11 of 3. On a processor with SMT, two “CPUs” with distant numbers can be the same physical core. Throughout that whole measurement the server and the generator fought over two physical cores, so what I measured was that fight.

The number I published was 84,162 against 84,508. I withdraw it: neither a tie nor an improvement can be claimed from it.

And the lesson, worth more than the figure: taskset gives no warning. It accepts any list of numbers and the work looks pinned. The only way to know is to ask about the topology.

The gap with Node does survive the error, because it is an order of magnitude apart: nearly twice the requests served with the same code. Two runtimes fighting over a shared core do not become twice as fast as each other because of it.

Installing dependencies: no change

Bun’s most frequent daily use is not serving HTTP, it is installing packages. With eight common dependencies and the cache wiped between runs:

RuntimeCold install
bun 1.3.14 · Zig2.43 s
bun 1.4.0 · Rust2.34 s

A tie again, within the margin. The package manager is bound by network and disk, so it makes sense that a change of language does not move it.

What I learned measuring this

A project publishing favourable numbers about itself does not mean they are inflated. None here were: two fall short on my bench, two beat them comfortably, and all point in the right direction.

What does change when you measure it yourself is the nuance, and nuance is what you decide with:

When I would use it and when I would not

I would upgrade to 1.4 without hesitation if start-up matters: scripts, hooks, scheduled tasks, containers that start and die. There the 61% is real and you feel it.

I would upgrade for the memory too, with adjusted expectations: count on a third less, not half.

And I would not switch from Node to Bun for idle consumption alone: both spend so little that the difference only matters multiplied by hundreds of processes.

With a warning that arithmetic supplies for me: this is the first stable release on a brand-new codebase, translated whole in eleven days. The numbers are good. The amount of freshly minted surface is enormous, and no bench measures that.

How to reproduce it

Everything above comes from four short scripts: one for start-up, one for memory and CPU, one for idle in nanoseconds and one wrapping oha. The test server is the twelve lines above.

Conditions matter more than the scripts. Halfway through the measurement the machine had other work on it and start-up times doubled: 13.77 ms for Zig where an idle machine gave 7.58. The proportions held, the absolutes did not. Everything published here was measured with the machine at rest, verified with uptime before each run, and with processes pinned to specific cores.

Sources

Comments

No comments yet. The first one is yours.

Reviewed before publishing. The email is not stored and never appears anywhere.