Introducing the new Render CLI and refreshed Render Dashboard.

Learn more
How-to
April 23, 2024

Hello Bun: How Sveld now deploys 2x faster on GitHub and Render

Eric Liu
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:
  • 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.
Bun is designed to be easy to adopt. Where possible, Bun borrows APIs you already use in Node and other libraries. For example, Bun remaps Node’s fs to its own implementation:
import fs from "node:fs";

fs.readFileSync("file.txt", "utf8");
I won’t do Bun full justice here, so please check out the Bun site to learn more.

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.
After the migration, the main change was:
  • 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:
  1. Replace Yarn with Bun as the package manager.
  2. Replace Vitest with Bun as the test runner.
  3. Migrate the CI/CD workflow that publishes releases to npm.
  4. Migrate the CI/CD workflow that deploys the demo site to Render.
Overall, each stage was straightforward, except for a minor gotcha. Let’s get into the details.

Stage 1: Replace Yarn, the package manager

Replacing Yarn with Bun as the package manager took two steps. I had to:
  1. Update package.json.
  2. 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
For the most part, swapping in 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, and bun add. If you want to use these keywords as scripts in your package.json, you’ll need to invoke bun 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.
Sample Bun command
Description
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 existing yarn.lock file and ran bun install:
rm yarn.lock && bun install
This command created a bun.lockb file:
- yarn.lock
+ bun.lockb
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.

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:
  1. Uninstall Vite-related dependencies.
  2. Convert vi APIs to jest APIs.
  3. 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 for jest. 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
With these three steps, my tests were migrated off Vitest.

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:
  1. Install Bun on the GitHub Actions runners.
  2. 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
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:
- 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’s package.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
One workflow done; one to go.

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
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.

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 used child_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");
}
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 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`;
}
While Bun’s speed gains get the most attention, I think these code-level improvements are just as notable.

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.
Example run: total build and test times on GitHub Actions
Example run: total build and test times on GitHub Actions
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:
GitHub-hosted runner
Yarn (no cache)
Yarn (with cache)
Bun (no cache)
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
What does “cache” mean in this context?
  • 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:
GitHub-hosted runner
Vitest
Bun
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)
And an example run on my machine:
# 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
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.

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:
Sveld static site
No build cache
With cache
Using Yarn
124s
89s
Using Bun
50s (~2.5x faster)
49s (~1.8x faster)
As in the GitHub Actions workflow, most of the speed gains came from faster package installs. Not bad for a simple migration.

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 or yarn install command with bun 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.