Render just added native support for Bun. As a UX engineer at Render, I wanted to try it out.
So I took Bun for a spin in a personal project. I migrated the Sveld open source library to use Bun v1.1.3 across build, test, and deploy steps on GitHub Actions and Render.
It’s rare for any technology to have few tradeoffs. I think Bun gets very close.
You can decide for yourself. In this post, I’ll share:
I won’t do Bun full justice here, so please check out the Bun site to learn more.
Updating
In Sveld’s
For the most part, swapping in
This command created a
You’ll notice that Bun's lockfile uses a binary format. This is no accident—it’s a design choice that helps Bun achieve faster performance.
With these two steps, Yarn was swapped out.
Updating
Next, I had to swap Vitest-specific APIs with those supported by Bun’s test runner.
The good news was that my Vitest files were already basically compatible with Bun. This is because Bun and Vitest both implement an API modeled after Jest.
In fact, Bun simply borrows the Jest API. Internally, Bun then maps Jest APIs to equivalent
With these three steps, my tests were migrated off Vitest.
Here’s a minor gotcha: by default, the GitHub Action will always install the latest version of Bun. To reuse a cached version of Bun, you must specify the Bun version:
One workflow done; one to go.
Notably, I didn’t have to install Bun, because Render natively supports Bun.
And with this last change, Sveld was fully migrated off Yarn and Vitest, and onto Bun.
I rewrote my script using Bun's native Shell and Glob APIs, which can execute familiar bash commands cross-platform. The Glob API removes the need for third-party libraries like
While Bun’s speed gains get the most attention, I think these code-level improvements are just as notable.
What does “cache” mean in this context?
And an example run on my machine:
Note that my unit tests are written in TypeScript. Bun's test runner can start faster than Vitest, because Bun natively supports TypeScript, but Vitest must first transpile the code.
As in the GitHub Actions workflow, most of the speed gains came from faster package installs.
Not bad for a simple migration.
- Step-by-step how I migrated to Bun: The migration was simple. I call out minor gotchas to save you some Googling.
- My favorite part of Bun—devX improvements: Of course, Bun reduced the number of dependencies in Sveld. To my pleasant surprise, using Bun’s Glob and Shell APIs also helped make a build script much more readable. See the before and after code snippet.
- A breakdown of speed gains in Sveld’s CI/CD: After moving from Yarn and Vitest to Bun, Sveld’s CI/CD on GitHub Actions and Render sped up by 2x. Most of the time savings came from faster package installs. At the end, I share a detailed breakdown of the speed gains.
Quick intro to Bun
If you haven’t heard of Bun, you should get to know it. Bun is an all-in-one JavaScript and TypeScript runtime and toolkit. It promises to unite the fragmented JavaScript ecosystem and speed up your development workflow. For example, with Bun, you can:- Replace Node, npm, npx, Jest/Vitest, Parcel, ts-node, etc. with one tool.
- Stop futzing with CommonJS versus ESM.
- Install packages 15-30x faster.
- Simplify and speed up code with new Bun-only APIs.
fs
to its own implementation:
import fs from "node:fs";
fs.readFileSync("file.txt", "utf8");
Sveld, before Bun
Sveld is the open source library I migrated to Bun. Sveld generates TypeScript definitions and docs for Svelte components. I created Sveld during my time at IBM as part of IBM’s Carbon Design System, and I continue to maintain Sveld on the side. Before the migration, Sveld’s tech stack was:- Yarn: the package manager. When I created Sveld, Yarn was a popular choice.
- Vitest: the test runner. I originally chose Vitest because it’s faster than Jest, and it can compile and run TypeScript files without a separate dependency (e.g.
ts-node-dev
). - TypeScript: Sveld is written in TypeScript. Sveld also uses the TypeScript compiler to generate TypeScript definitions.
- GitHub Actions: Sveld’s CI/CD pipeline consists of two GitHub Actions workflows.
- Render: The Sveld playground is hosted on Render.
- Bun: Bun replaced Yarn and Vitest.
Migration overview: four stages
I’ll now share how I migrated from Yarn and Vitest to Bun. Feel free to skim this section now and come back for a deeper read when you’re doing a migration. Skimming this section will help you understand the DX and speed gains I show at the end. There were four main stages to the migration:- Replace Yarn with Bun as the package manager.
- Replace Vitest with Bun as the test runner.
- Migrate the CI/CD workflow that publishes releases to npm.
- Migrate the CI/CD workflow that deploys the demo site to Render.
Stage 1: Replace Yarn, the package manager
Replacing Yarn with Bun as the package manager took two steps. I had to:- Update
package.json
. - Update the lockfile.
Updating package.json
In Sveld’s package.json
file, I replaced yarn
commands with bun
commands.
For example, one update I made looked like this:
- yarn build
+ bun run build
bun
“just worked”. However, there are a few differences and gotchas to note about Bun:
- Some keywords are reserved: Bun reserves some keywords, including
bun install
,bun build
, andbun add
. If you want to use these keywords as scripts in yourpackage.json
, you’ll need to invokebun run <command>
. - File extensions are required: Bun requires you to specify the file extension when you execute a file.
- Runtime default differs for files versus packages: In the Bun CLI, Bun is used as the default runtime to execute a file. However, Node.js is used as the default runtime to execute a package. To use Bun as the runtime to execute a package, you need to pass an explicit
--bun
flag.
bun build | Reserved command that invokes the Bun.build API to bundle code. |
---|---|
bun run build | Runs the build script defined in package.json . Similar to npm run build . |
bun build.ts | Executes a file named build.ts using Bun as the runtime. The file extension must be specified. |
bun tsc | Runs the TypeScript tsc CLI using Node.js as the runtime. |
bun --bun tsc | Runs the TypeScript tsc CLI using Bun as the runtime. |
Updating the lockfile
Next, I swapped out the yarn lockfile for the bun lockfile. This was quick. I just deleted the existingyarn.lock
file and ran bun install
:
rm yarn.lock && bun install
bun.lockb
file:
- yarn.lock
+ bun.lockb
Stage 2: Replace Vitest, the test runner
Sveld originally used Vitest as the test runner. Although Bun and Vitest can be used together, I wanted to see if Bun could replace Vitest. The speed upgrades—which I’ll describe later—turned out to be worth it. Converting Sveld’s tests to Bun took three steps:- Uninstall Vite-related dependencies.
- Convert
vi
APIs tojest
APIs. - Install TypeScript definitions for Jest.
Uninstalling Vite-related dependencies.
This was the simplest step. I ran:bun uninstall vite vitest
Updating vi
APIs to jest
APIs
Next, I had to swap Vitest-specific APIs with those supported by Bun’s test runner.
The good news was that my Vitest files were already basically compatible with Bun. This is because Bun and Vitest both implement an API modeled after Jest.
In fact, Bun simply borrows the Jest API. Internally, Bun then maps Jest APIs to equivalent bun:test
APIs.
So in practice, I essentially renamed vi
to jest
throughout my tests:
- vi.fn();
+ jest.fn();
- vi.spyOn(console, "error");
+ jest.spyOn(console, "error");
Installing TypeScript definitions for Jest.
The third and last step to convert my test runner was to install TypeScript definitions forjest
.
Before I uninstalled Vitest, Vitest had provided TypeScript definitions for the global imports (e.g., describe
, test
, expect
) in the test files. Now I had to backfill these definitions.
I simply installed the @types/jest
package:
# The -D flag installs the package as a development dependency.
bun install -D @types/jest
Stage 3: Migrate npm publish
Next, I tackled migrating Sveld’s CI/CD pipeline. The pipeline consists of two GitHub Actions workflows. The first workflow publishes a new Sveld release to npm whenever I push a new Git tag (e.g.v0.1.0
). The workflow builds the Sveld library, then runs npm publish
.
I had already converted Sveld to use Bun as the package manager. So, there were just two more steps to convert this workflow:
- Install Bun on the GitHub Actions runners.
- Update the workflow file to use Bun instead of Yarn.
Installing Bun on the GitHub-hosted runners
To install Bun, I added the official Bun GitHub Action to my workflow.steps:
- uses: oven-sh/setup-bun@v1
- uses: oven-sh/setup-bun@v1
with:
# Subsequent workflow runs will use the cached version of Bun.
bun-version: 1.1.3
Updating the workflow file to use Bun instead of Yarn
Next, to update my GitHub Actions workflow file, I quickly swapped Yarn commands for Bun commands. This was similar to how I’d updated Sveld’spackage.json
earlier.
name: Install dependencies
- run: yarn install
+ run: bun install
name: Build the library
- run: yarn build
+ run: bun run build
name: Run unit tests
- run: yarn test
+ run: bun test
Stage 4: Migrate Render deploy
My final task was to migrate the other GitHub Actions workflow in Sveld’s CI/CD pipeline. This workflow runs unit tests on every pull request and every commit. On every commit, the workflow also deploys the Sveld playground site to Render. To migrate this second workflow, I used the same steps as I used to migrate the first workflow. The main difference is that I also had to update the Render deploy—which was simple. On Render, the Sveld playground is deployed as a static site. To make this site build with Bun on Render, I just updated the static site’s build command:- cd playground; yarn install && yarn run build
+ cd playground; bun install && bun run build
Why TypeScript & npm stayed—and my Bun wishlist
If you’re a particularly astute reader, you might have noticed I didn’t talk about TypeScript and publishing to npm. Both of these components remained in Sveld beyond this migration. There are things Bun can’t do today that kept me on existing tools. Here are a few items on my Bun wishlist:- Type checking: Sveld needs the TypeScript compiler,
tsc
, to perform type checking and to generate TypeScript definitions. Bun can directly execute.ts
and.tsx
files, but can’t check or generate types. - Bundling support for CJS: I was not able to use Bun's native bundling feature because Sveld is distributed in the CommonJS (CJS) format. Bun's bundler currently only supports ES modules (ESM).
- Publishing to npm with provenance: npm remains in my Github Actions workflow. When I publish Sveld to npm, I want to publish with provenance, and I can’t do this with Bun today.
Better readability with Bun’s Glob and Shell APIs
While Bun doesn’t have every feature I want, it does include great new APIs that don’t exist in Node, which help you simplify your code. Here’s an example from Sveld. Sveld includes a script to run end-to-end (e2e) tests. The script symlinks the library to test directories, installs dependencies, builds the library, and runs the tests. This script originally usedchild_process
from Node to execute system commands:
const fs = require("fs");
const path = require("path");
const { exec: child_process_exec } = require("child_process");
const { promisify } = require("util");
const exec = promisify(child_process_exec);
const { name } = require("../package.json");
const execCwd = async (dir, ...args) => await exec(`yarn --cwd ${dir} ${args}`);
await exec("yarn link");
const dirs = fs
.readdirSync("tests/e2e")
.map((file) => path.join("tests/e2e", file))
.filter((file) => fs.lstatSync(file).isDirectory());
for await (const dir of dirs) {
const typesDir = path.join(dir, "types");
if (fs.existsSync(typesDir)) {
fs.rmSync(typesDir, { recursive: true, force: true });
}
await execCwd(dir, `link "${name}"`);
await execCwd(dir, "install");
const build = await execCwd(dir, "build");
console.log(build.stdout + "\n");
const svelteCheck = await execCwd(dir, "svelte-check");
console.log(svelteCheck.stdout + "\n");
}
glob
or fast-glob
.
Bun does support node:child_process
, which would have done the job. However, using Bun’s Glob and Shell made the script much more readable and concise:
import { Glob, $ } from "bun";
import { name } from "../package.json";
// Create a symlink to the library.
await $`bun link`;
// Get a list of directories in the e2e test directory.
const dirs = new Glob("*").scanSync({
cwd: "tests/e2e",
onlyFiles: false,
absolute: true,
});
// Iterate over each directory and run the following commands.
for await (const dir of dirs) {
// Remove previously generated TypeScript definitions.
await $`cd ${dir} && rm -rf types`;
// Link the library to the e2e test directory.
await $`cd ${dir} && bun link ${name}`;
// Install the test directory dependencies and build the library.
await $`cd ${dir} && bun install`;
await $`cd ${dir} && bun run build`;
}
2x speed gains with Bun across build and test
Don’t get me wrong. The speed gains from Bun are great. First, let’s take a look at speedups in the build and test times for Sveld. Here’s an eagle’s-eye view of the diff in combined build and test times on GitHub Actions, between Yarn & Vitest with Node.js v20, versus Bun. Note that Sveld’s unit tests are run against several different OS platforms. A closer look at the time savings showed that, with Bun:- Project dependencies were installed 2-20x faster on GitHub, depending on OS platform.
- Unit tests ran 1.6-2.2x faster on GitHub.
Observed: Package install runs 2-20x faster on GitHub Actions
The main improvements in speed seemed to come from faster package installs. In fact, installing dependencies with Bun without a cache was typically faster than installing with Yarn with a cache. Here’s an example run:macos-latest | 9.48s | 4.08s | 1.52s |
---|---|---|---|
macos-latest-xlarge | 3.69s | 2.08s | 0.440s |
windows-latest | 19.56s | 3.16s | 10.05s |
ubuntu-latest | 5.31s | 0.17s | 0.292s |
- Without cache: If
node_modules
(the cache) is not present, the package manager will resolve and install dependencies from scratch. - With cache: If
node_modules
is present, the GitHub runner will download the cache. Then the package manager will check to see if there are any new dependencies and download any needed packages.
Observed: Package install runs 16x faster locally
It's important to note that network variability affects the times we see from GitHub Actions runners. Thus, I also tested package installs on my local machine using hyperfine, a CLI benchmarking tool. With a cache, Bun was about 16x faster than Yarn:# Run on a 2019 Macbook Pro (Intel Core i9)
hyperfine --warmup 3 'yarn install' --runs 10
Benchmark 1: yarn install
Time (mean ± σ): 300.2 ms ± 7.7 ms [User: 304.8 ms, System: 58.6 ms]
Range (min … max): 293.4 ms … 316.1 ms 10 runs
hyperfine --warmup 3 'bun install' --runs 10
Benchmark 1: bun install
Time (mean ± σ): 18.8 ms ± 0.4 ms [User: 17.4 ms, System: 10.0 ms]
Range (min … max): 18.0 ms … 19.1 ms 10 runs
Observed: Unit tests run 1.6-2x faster on GitHub Actions, 2x faster locally
Bun’s test runner ran Sveld’s unit tests ~2x faster than Vitest, across both GitHub Actions and locally on my machine. Here’s an example run from GitHub Actions:macos-latest | 3.76s | 1.85s (2.03x faster) |
---|---|---|
macos-latest-xlarge | 1.19s | 0.68s (1.75x faster) |
windows-latest | 3.01s | 1.81s (1.66x faster) |
ubuntu-latest | 2.65s | 1.20s (2.20x faster) |
# Run on a 2019 Macbook Pro (Intel Core i9)
hyperfine --warmup 3 'yarn test run' --runs 10
Benchmark 1: yarn test run
Time (mean ± σ): 2.343 s ± 0.153 s [User: 6.899 s, System: 1.153 s]
Range (min … max): 2.194 s … 2.589 s 10 runs
hyperfine --warmup 3 'bun test' --runs 10
Benchmark 1: bun test
Time (mean ± σ): 1.059 s ± 0.038 s [User: 1.653 s, System: 0.225 s]
Range (min … max): 1.020 s … 1.161 s 10 runs
2x faster Render deploys with Bun
Sveld deployed faster with Bun on Render, with and without a build cache. Here are a few example runs:Using Yarn | 124s | 89s |
---|---|---|
Using Bun | 50s (~2.5x faster) | 49s (~1.8x faster) |
Try Bun, on Render
Overall, migrating Sveld to Bun gave me a big bang for my buck. My development workflow sped up 2x, and I simplified Sveld’s stack for package management, testing, and scripting. I'm excited to continue using Bun in future projects. If you’ve made it this far, you might be curious to take Bun for a spin. Here are a few quick ideas:- Build an existing Render service with Bun: Already have a Node service deployed on Render? Try replacing your
npm install
oryarn install
command withbun install
. - Deploy a new Render service: If you don't already have a Node service, you can deploy a web server with Bun on Render in 5 minutes. For example, ElysiaJS is a new web framework designed to use Bun, and it’s now very easy to get it running on Render. Check out our ElysiaJS guide to get started.
- Migrate an entire project: If you have an existing Node project, I hope this guide can help you migrate it.