
Bun rewrote itself from Zig to Rust in eleven days. I measured what they published, and what they did not
Bun ported 535,000 lines from Zig to Rust and published three concrete figures: start-up, requests per second, and binary size. 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. Start-up beats it comfortably, binary size falls short, and for memory and idle CPU the announcement gives no number at all: those two I measure with nothing official to compare against.
A large project announced it had rewritten its entire codebase in another language, and published a few numbers to prove it had gone well: up to 20% smaller binary, up to 4.8% more requests per second depending on the server, a 10% faster start-up on a tool running on top of it. About memory and idle CPU it said nothing.
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 what they published, and two more things they did not.
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.
What Bun publishes is this: 10% faster start-up on Linux (measured on a tool running on top of Bun, not on Bun itself), between 2% and 5% more requests per second depending on the server, and around 20% smaller binary. For memory and idle CPU it gives no number at all, so those two I measure with nothing to compare against.
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);
}
The first stumble is worth more than several of the measurements.
What was published, and what was not, measured
All on the same machine, a Ryzen 7 7800X3D with 30 GB of RAM, on CPU.
- Cold start, on Linuxse sostiene
61,4% fasterFrom 7.58 ms to 2.93 ms, median of the 60 runs in the chart below. The published 10% is Claude Code CLI start-up running on Bun, not a general Bun figure; my case is narrower, a one-line script.
- Binary sizeno llega
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 publishes; the bar is what came out when measuring it. For memory and idle CPU Bun publishes no number at all, so those two get their own measurement further down with no comparison mark, because none exists. And the requests-per-second row is missing: 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.
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
| candidato | mediana | mínimo | máximo |
|---|---|---|---|
| bun 1.4.0 · Rust | 2,93 | 2,85 | 3,10 |
| bun 1.3.14 · Zig | 7,58 | 7,45 | 8,63 |
| node 22.23.2 | 15,81 | 14,78 | 18,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, well above the 10% Bun announces for the Claude Code CLI’s start-up on 1.4 on Linux, a narrower case than mine: a real tool running on top, instead of the bare binary. 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. Summing all the threads of the process means walking each one separately, under /proc/<pid>/task/<tid>/schedstat:
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.
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. Bun publishes no idle-CPU number at all; this figure is entirely mine. And 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 the Rust process sleeps far more than the Zig one, 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
| Runtime | Just started | After serving traffic |
|---|---|---|
| bun 1.3.14 · Zig | 36.4 MB | 44.5 MB |
| bun 1.4.0 · Rust | 16.2 MB | 27.5 MB |
| node 22.23.2 | 48.9 MB | 62.0 MB |
Here is the nuance. Right after the process starts, the improvement is 55.5%. After serving real traffic it drops to 38.2%. Bun publishes no memory figure for HTTP servers: the closest thing it says is a memory reduction across repeated Bun.build() runs, a different scenario from the one I measure here. So this is independent measurement end to end.
The number depends on when you look. 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. The new measurement, in containers with limits and cores checked by topology instead of by number, is the one running in the video above.
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:
| Runtime | Cold install |
|---|---|
| bun 1.3.14 · Zig | 2.43 s |
| bun 1.4.0 · Rust | 2.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. Of the ones Bun publishes, start-up beats it comfortably and binary size falls a bit short; requests per second I withdrew over the measurement error. And there are two figures Bun simply does not publish, HTTP-server memory and idle CPU, that only exist because I measured them myself.
What does change when you measure it yourself is the nuance, and nuance is what you decide with:
- Start-up improved far more than Bun publishes, and it is the difference you actually feel in command-line tools, git hooks and functions that start per request.
- Memory improves considerably, though there is no official figure to measure it against; it depends on whether you look after traffic or at process start.
- HTTP throughput did not move, at least on the simplest possible case.
- And while idle, the difference is not in spending less per wake-up: it is in waking up far less often, 47 seconds with activity against 299.
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
- Rewriting Bun in Rust — the project announcement, where the contrasted start-up, binary-size and requests-per-second figures come from.
- oha — the load generator, version 1.16.0.
proc(5), section/proc/pid/schedstat— the nanosecond counter that makes idle measurable.
NEWSLETTER · MEASURED
A new measurement, when there is one.
Each post with its numbers, its failures and the scripts to repeat it. One to three emails a month. Unsubscribe with one click.
LATEST
- 7 harnesses on MiniMax-M329 of 39 with the simplest harness
- runc vs gVisor vs KataKata starts 27× slower
- ComfyUI vs Forge on 16 GB2× with Z-Image
I run Listmonk on my own server; your address stays there. You confirm by email before getting anything.
Comments
No comments yet. The first one is yours.