Can Bun Eat Node.js’s Lunch? Testing the Trendy Toolkit

We at Lab Zero always strive to keep abreast of industry trends. And what could be trendier than JavaScript, the web-centric language that the community seems to reinvent once every couple of months! That’s why it’s been so helpful to have a pet project on which to test these new developments: Lunch, the restaurant voting app for teams.

Already in this past year, Lunch has been modernized by upgrading most of its packages and converting almost all of the code to TypeScript. And even more recently in the interest of reducing bundle size, I’ve entirely removed Babel from the build and replaced React with Preact.

Lunch is already a zippy little app. We pride ourselves on writing performant code, as evidenced by our React work with other clients. But there are always new industry developments, the latest of which is the 1.0 release of Bun, a JavaScript runtime and framework that boasts surprising speed and easy adoption. Given Lunch’s server, builder, test framework, and other aspects seemed like a good fit for Bun (not to mention the name), we decided to give it a go!

Is Bun production-ready?

Bun’s 1.0 announcement proudly proclaimed that the toolkit is stable and production-ready:

  • We could swap out ts-node for bun.
  • We could ditch Webpack for Bun.build.
  • The eternal struggle between npm and Yarn would be ended by bun install.
  • Who’d need Jest (or in Lunch’s case, good ol’ Mocha) when you could just use bun test.

And best of all, this could all be done with minimal refactoring as all of these features boast nearly complete backwards compatibility.

Running Bun

We started by seeing if server.js, a large JS file emitted by Webpack containing an Express-based server, could simply be run using Bun instead of Node. Aaaand… segmentation fault, with a rather inscrutable core dump.

Defeat was not part of the plan. I continued to pare the server’s source code down until I found the culprit: any code having to do with cryptography, such as crypto and bcrypt. Not to worry—Bun’s hashing functionality came to the rescue, and a quick find/replace got us, well… one step closer.

No segmentation faults this time! Instead, Bun seemed to go quiet, indefinitely, when trying to connect to Lunch’s Postgres server. A benefit to living on the bleeding edge was that I just needed to wait literally a day until 1.0.7 came out, fixing the issue with this hanging database socket. Well, mostly. It still happens sometimes when running migrations. But hey, progress!

Building Bun

Great, so Bun can run an Express server. Let’s expand our scope a bit and have Bun run Webpack to build the server and client bundles.

Aaaand… another segmentation fault. This time it was with a particular plugin responsible for building the service worker and injecting a list of assets to precache. For a while I considered just dropping service workers entirely (are Progressive Web Apps still a hip, cool thing that people want?), but later realized that the issue stemmed from the type of source map I was specifying. Letting Webpack’s mode setting take care of that decision seemed to solve my problem.

Here’s where Bun actually made my life easier, though! Webpack generates a server and client bundle. This is necessary because both on the server and client, there are import statements for images and Sass files—non-JavaScript assets that need some handling logic. Also, back in 2016 when I first built Lunch, Node needed transpilers like Babel to evaluate all sorts of modern ECMAScript features—and later, TypeScript.

No need for that on the server anymore. Bun supports plugins that intercept imports and allow for custom loading logic. Even better, its plugin API is loosely based off of esbuild, meaning there’s an existing plugin ecosystem… assuming the plugins only make use of Bun’s currently limited plugin API.

Images were easy. Look, I can post the entire plugin here:

import { plugin } from "bun";

plugin({
  name: "Image",
  async setup(build) {
    const assetManifest = require("../../build/asset-manifest.json");
    build.onLoad({ filter: /\.(svg|png|jpg)$/ }, (args) => ({
      contents: `export default "${assetManifest[args.path.replace(`${process.cwd()}/`, "")]}"`,
      loader: "js",
    }));
  },
});

This simply transforms every image import into a virtual JS file that exports the path name of the image. That path can then be applied to img src attributes. Of note, this still depends on Webpack to build the client bundle as it references the URLs of each image with an added hash at the end, so both the server and browser can load and cache the same assets, but we could skip this caching improvement if we wanted to.

Sass, on the other hand, was a challenge. esbuild-sass-plugin used features not available on Bun’s plugin API, and I’m using a now-antiquated Webpack style loader, so I had to write a rather convoluted compatibility layer, handling both traditional style and CSS module imports, to make everyone happy. Suffice to say, it’s too long to print here. But it works!

Testing Bun

I had a functional, Bun-powered app! I could still build the client bundle using Bun-powered Webpack, and all server functionality seemed to work—even the complicated database, authentication, and websocket bits—but testing is where I hit a hard stop.

For starters, my unit testing suite makes extensive use of module mocks thanks to proxyquire, a Mocha-era (read: old) library that allows the tester to fake code adjacent to the actual logic being tested. Jest has the same concept with manual mocks. But Bun does not support module mocking - or at least it didn’t when I got started on this project, but this functionality was introduced in 1.0.8. Unfortunately, this new functionality doesn’t support default exports or export lists, which makes it impossible for me to adopt, for now.

End-to-end tests are where I had the most trouble. I use Cypress to automate my browser testing, and I was getting the weirdest error when trying to load the site within its iframe: HPE_INVALID_CONSTANT - or in other words, Cypress was unable to parse Bun’s HTTP response.

After hours of poking and prodding my Bun server with Wireshark, I finally reached a conclusion: Bun sends a malformed response when the transfer-encoding is chunked. And Express’s session library chunks any response that handles cookies (read: all of them). This malformed response is taken in stride by web browsers, but not by Node’s HTTP client, which Cypress uses under-the-hood to proxy all of its communication with the server.

As an aside, I was having a heck of a time trying to load environment variables from my test environment. While I was able to devise a temporary workaround, it seems that Bun does not correctly prioritize the correct .env file to use.

In the cases of both unit tests and end-to-end tests, I was up the creek without a paddle. One option was for me to fall back to Node to run these tests instead—but that defeats the purpose, especially in the case of end-to-end tests, which is to ensure the site works properly on the technology it actually uses.

Your Bun will be ready… soon!

Don’t get me wrong! Bun is an amazing effort, rolling almost all aspects of the modern JavaScript tooling ecosystem into a single monolith (for better or worse). But in my opinion, it is clearly not production-ready as I’m still encountering segmentation faults for common tasks, there are issues with its mocking and HTTP APIs, and it’s missing some vital features that even a greenfield project would expect.

As always, Lunch is open-source and all the work to convert it into a Bun app is available here. Since it doesn’t pass CI, we can’t deploy it to production just yet. I’m hoping that the few remaining blockers are rectified soon, and then we can all enjoy Lunch… on a Bun!

Hero image background by Vecteezy

Continue the conversation.

Lab Zero is a San Francisco-based product team helping startups and Fortune 100 companies build flexible, modern, and secure solutions.