cat posts/2022-07-12-bun-deno-and-node.md
Bun, Deno and Node: a runtime bake-off
bun install finished our frontend repo’s dependencies in a little under four seconds. The same install with npm ci, on the same laptop, same lockfile, takes 52. I ran it three times because I assumed I’d done something wrong, like pointing it at a half-populated node_modules. I hadn’t. (The four seconds is with Bun’s cache already warm from the first run. The first run was about 19 seconds, which is still a lot better than 52.)
That’s the kind of number that’s been all over my feeds since last Tuesday, when Jarred Sumner put out the public beta of Bun, version 0.1.0. It’s a JavaScript runtime like Node or Deno, written in Zig, built on JavaScriptCore (Safari’s engine) instead of V8, and it comes with a bundler, a TypeScript and JSX transpiler, an npm-compatible package manager, a test runner and a SQLite client all in one binary. The homepage has three bar charts. The first one shows a hello-world HTTP server doing 48,936 requests a second on Bun against 16,288 on Node 18.1 and 15,786 on Deno 1.23. That chart is the one everybody is screenshotting.
I wanted to know what happens on things that aren’t the homepage’s benchmarks, so I spent a couple of evenings running all three on my M1 MacBook Air. This is not science. It’s one laptop, one person, and three things I actually care about.
A script
We have a pile of small TypeScript scripts in one repo, for things like generating test fixtures and poking at our API. On Node each one gets compiled with tsc first or run through ts-node, which is slow to start in a way you notice every single time. Deno and Bun both run TypeScript directly, so this was the comparison I was most curious about.
For a script that just prints a line, Node 18 takes about 40 milliseconds to start on this machine, Deno about 25, and Bun under 10. Add ts-node to Node and it’s closer to half a second, which is where the real pain was. For our fixture generator, which reads some JSON and writes a few thousand rows, all three finished in well under a second once they were running, and the difference was almost all startup.
Startup is a real advantage if you run lots of short processes: scripts, CLI tools, anything in a tight loop in CI. Deno gets you most of the way there, though, and it’s been stable for two years.
A service
Then I tried our webhook relay, a small TypeScript service of about 600 lines that sits between a couple of third-party systems and our queue. It runs on Node 16 in production.
Deno wouldn’t run it without changes, which I expected. Deno doesn’t use npm or node_modules, it imports by URL, and our relay depends on Express and an AWS SDK package. There’s a --compat flag for Node compatibility and there are CDNs that repackage npm modules for Deno, and after half an hour with both I had something that started and then failed on the first request. A real port would be a rewrite of the imports plus finding Deno-native replacements for two libraries. That’s a reasonable thing to do for a new service and a silly thing to do for a working one.
Bun is meant to be a drop-in replacement for Node, and that’s the big difference in what the two projects are trying to be. It got a lot further. It installed the dependencies, transpiled the TypeScript without complaint, and then died on startup with an error from deep inside one of the dependencies about a Node API it doesn’t implement yet. I spent an evening trying to work around it and then stopped, because this is a 0.1 beta and its own README has a long “not implemented yet” section, and it says so up front. It also segfaulted on me twice that evening, both times while I was messing with the code and restarting it a lot. I’m not holding that against a beta, but I’m not putting it anywhere near our queue either.
So for the HTTP numbers I fell back to hello world, which proves less. With Bun’s own Bun.serve and Node’s plain http module, and wrk running on the same laptop (which is a bad idea, since it competes for the same cores), Bun came out at roughly twice Node’s requests per second, and Deno landed between the two. That’s not the 3x on the homepage, but my setup was a lot worse than theirs, and even 2x is a lot.
It’s also not what limits our relay. It spends most of its time waiting on two HTTPS calls and a queue write. If the runtime got infinitely fast, our p99 would barely move.
The install
That leaves the number I started with, and it’s the part of Bun I’d actually use this month. Dependency installs are a big part of every CI run in our frontend repo, and on developer laptops they’re the thing people complain about after a branch switch. bun install reads package.json and writes a normal node_modules, so in principle you could use it to install and keep running everything else on Node. It writes its own binary lockfile, bun.lockb, instead of package-lock.json, though, and that’s a problem for us. After left-pad we agreed that lockfile changes get reviewed, and a reviewer can’t skim a binary diff. Bun can print the lockfile as yarn-format text, which would help if we wired it into the review, but it’s another thing to set up.
What I think
Node is the one I’d pick for anything going into production this year, and probably next year. Every library we use works on it, the failure modes are ones we’ve already met, and Node 18 is quietly picking up the things that made the other two attractive, like a built-in fetch and a test runner, both still marked experimental.
Deno is the one I’d like to use. Permissions you have to grant explicitly (--allow-net, --allow-read) are the right default for running code from the internet, and after event-stream I wish Node had them. The formatter, linter and test runner come in the box. But leaving npm behind is still a big cost for any existing codebase, and I’m not sure that’s going to change.
Bun is the one that’s fast, and the speed is real. Whatever else happens to it, I expect it to make Node and npm faster, the way esbuild made everyone look at their webpack build times. Whether it becomes something you’d run a service on depends on how fast that compatibility list fills in, and on the project still being around in two years. As far as I can tell from the commit history, most of it so far is one person’s work. The other thing is that there’s no Windows build, only WSL, which rules it out for about half the machines I use in a normal week.
I’ll try the relay again at 0.2.