Bun 1.4 shipped on 20 August as the first stable release built on Bun's new Rust-based core, replacing the Zig runtime layer that shipped everything since 1.0. The release notes claim faster startup, less idle CPU, and lower memory. I installed the last pre-rewrite build and the new one side by side and measured all three, plus what happens when you point a node symlink at the new binary, which is how a lot of people actually run Bun in production.
I put Bun 1.3.14 in ~/.bun-old and 1.4.2 in ~/.bun-new using the official install script with a pinned version tag, so both binaries sit on the same machine and I can call either one directly.
Startup time
My first pass measured bun --version twenty times per binary. The difference was almost nothing: 2.24ms median for 1.3.14, 2.00ms for 1.4.2. That command exits before it initialises the JS engine, so it wasn't measuring what the release notes were talking about. Once I switched to actually running a script, the gap showed up.
console.log("hi")
Thirty runs of bun run hello.js each, timed with time.perf_counter() around a subprocess.run call:
| median | min | max | |
|---|---|---|---|
| 1.3.14 | 10.54ms | 9.32ms | 18.00ms |
| 1.4.2 | 4.22ms | 3.90ms | 5.25ms |
That's a 60% drop in median startup time, better than the "50% faster on Linux" the release notes claim. The old binary also had a much longer tail (up to 18ms), while the new one stayed inside a 1.4ms band across all 30 runs.
Idle memory
I ran a two-line Bun.serve echo server on each version, hit it once to warm it up, then read VmRSS from /proc/<pid>/status after a further 60 seconds of doing nothing. Three runs each:
| RSS after warmup | RSS after 60s idle | |
|---|---|---|
| 1.3.14 (run 1) | 34,876 KB | 34,960 KB |
| 1.3.14 (run 2) | 34,924 KB | 34,928 KB |
| 1.3.14 (run 3) | 34,872 KB | 34,936 KB |
| 1.4.2 (run 1) | 18,864 KB | 18,920 KB |
| 1.4.2 (run 2) | 19,068 KB | 19,136 KB |
| 1.4.2 (run 3) | 19,056 KB | 19,144 KB |
Averaging the idle figures: 34.9MB down to 19.0MB, a 46% reduction. The release notes say "up to 35%", so on this workload it beat its own number, though a bare echo server is close to the best case for a claim like this — there's no application memory to dwarf the runtime's own footprint.
Idle CPU: this is where the claim didn't hold up
The release notes claim 5x less idle CPU. This is the one I'd flag as not backed by what I measured.
I sampled utime + stime from /proc/<pid>/stat before and after a 60-second idle window, three times per version (100 clock ticks per second, so each tick is 10ms of CPU time):
| run | 1.3.14 ticks/60s | 1.4.2 ticks/60s |
|---|---|---|
| 1 | 11 | 6 |
| 2 | 11 | 4 |
| 3 | 10 | 3 |
| mean | 10.67 | 4.33 |
That's roughly 0.178% average CPU for 1.3.14 against 0.072% for 1.4.2 — a real improvement, and in the right direction, but a 2.5x reduction, not 5x. Both numbers are small enough that a single bad sample would swing the ratio a lot, which is exactly why I went from a 5-second window (0 to 2 ticks total, useless) to 60 seconds before trusting any of it. It's possible Bun's 5x figure comes from a different idle workload — something with an active file watcher or timer loop rather than a bare Bun.serve with no traffic — but on the simplest possible idle server, I got 2.5x.
Throughput under load
The release notes don't claim a throughput number, so I measured one anyway, since idle numbers only tell you about servers doing nothing. I ran autocannon -c 50 -d 8 against the same echo server, three runs per version:
| run | 1.3.14 avg req/s | 1.4.2 avg req/s |
|---|---|---|
| 1 | 29,456 | 62,310 |
| 2 | 29,578 | 61,998 |
| 3 | 28,042 | 62,922 |
| mean | 29,025 | 62,410 |
That's 2.15x more requests per second at 50 concurrent connections, and p50 latency dropped from 1ms to sub-millisecond in autocannon's output. This one held up better under concurrency than at idle, which is the opposite of what I expected going in — I assumed a rewrite aimed at idle efficiency would show its biggest gains with nothing happening, not under load.
The regression: invoking Bun as node silently drops your .env
Bun positions itself as a drop-in replacement for Node, and one common way people use that is symlinking a node binary to bun so existing tooling picks it up without changes. I tested exactly that setup.
$ mkdir -p /tmp/nodebin && ln -s /root/.bun-old/bin/bun /tmp/nodebin/node
$ echo 'MY_SECRET=hello123' > .env
$ /tmp/nodebin/node check.js
MY_SECRET=hello123
That's 1.3.14, invoked through a symlink literally named node. It loads .env as expected. Now the same setup against 1.4.2:
$ mkdir -p /tmp/nodebin && ln -s /root/.bun-new/bin/bun /tmp/nodebin/node
$ /tmp/nodebin/node check.js
MY_SECRET=undefined
No error, no warning, exit code 0. The process just runs with MY_SECRET undefined. I checked whether this was specific to the symlink trick or a wider regression: plain bun run check.js and bun --bun run check.js both still load .env correctly on 1.4.2. It's specifically the "binary is named node" detection path that stopped auto-loading environment files.
This matters because "point a node symlink at Bun" is a documented migration path, not an edge case I invented. Anyone running Bun that way in a container image or CI pipeline, expecting Node-compatible behaviour, will get a process that starts cleanly and silently runs with missing configuration. The workaround exists and works:
$ /tmp/nodebin/node --env-file=.env check.js
MY_SECRET=hello123
But you have to know to add it. Nothing in the normal run output tells you your .env was skipped.
What I got wrong on the way
I nearly wrote off the startup claim entirely after my first benchmark. bun --version is a fast path that never spins up the JS engine, so it wasn't exercising the thing the release notes described, and it showed almost no difference between versions. I only found the real 60% gap after switching to a script that actually gets parsed and run. If a "no difference" result comes out of the first thing you try, it's worth asking whether you measured the feature or just the binary's exit path.
The same thing happened with idle CPU: my first sampling window was 5 seconds, which produced 0-2 clock ticks total per run — not enough resolution to say anything. Stretching the window to 60 seconds is what made the numbers usable, and it's also what exposed that the claimed 5x doesn't hold at this scale.
Run it yourself
# install both versions side by side
BUN_INSTALL=~/.bun-old bash -c 'curl -fsSL https://bun.sh/install | bash -s "bun-v1.3.14"'
BUN_INSTALL=~/.bun-new bash -c 'curl -fsSL https://bun.sh/install | bash'
# startup timing
echo 'console.log("hi")' > hello.js
python3 -c "
import subprocess, time, statistics
for label, binpath in [('old', '$HOME/.bun-old/bin/bun'), ('new', '$HOME/.bun-new/bin/bun')]:
times = []
for _ in range(30):
t0 = time.perf_counter()
subprocess.run([binpath, 'run', 'hello.js'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
times.append((time.perf_counter() - t0) * 1000)
times.sort()
print(label, 'median', round(times[15], 2), 'ms')
"
# the node-symlink .env regression
mkdir -p /tmp/nodebin && ln -sf ~/.bun-new/bin/bun /tmp/nodebin/node
echo 'MY_SECRET=hello123' > .env
echo 'console.log("MY_SECRET=" + process.env.MY_SECRET)' > check.js
/tmp/nodebin/node check.js
If you run Bun 1.4 through anything that presents it as node — a container base image, an nvm-style shim, a CI runner that symlinks a runtime — go and check one thing before you rely on it: put a throwaway variable in your .env, run your actual entrypoint through that symlink, and print the variable back out. If it comes back undefined, you've found the same gap I found, and --env-file on your start command is the fix. The startup and memory gains are worth having either way; I'd rather find out about the missing config here than from a support ticket.
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.