Skip to content
FLAVIO COPES
flaviocopes.com

Bun: a faster JavaScript runtime

By

Learn Bun from scratch: install the runtime, run TypeScript, manage packages, create an HTTP server, write tests, bundle code, and use SQLite.

~~~

Bun is a fast JavaScript runtime, like Node.js and Deno. You can use it to run JavaScript and TypeScript outside the browser.

But calling Bun a runtime only tells part of the story.

The same bun command is also a package manager, a test runner, a bundler, and a project runner. It can read files, start an HTTP server, connect to SQLite, and turn a TypeScript program into a standalone executable.

You get all of this from one binary.

This is what first made Bun interesting to me. A lot of JavaScript development consists of choosing tools, installing them, and making them work together. Bun gives you good defaults before you install anything.

In this tutorial we’ll install Bun, create a project, and explore those tools one at a time.

What is a JavaScript runtime?

JavaScript started as a language for web browsers.

The browser gives JavaScript an engine and a collection of useful APIs. The engine runs the language. The APIs let your code work with things like the page, timers, network requests, and storage.

A server-side JavaScript runtime does something similar outside the browser. It gives JavaScript an engine plus APIs for files, processes, networking, and other operating system features.

Node.js is the best-known example. Bun is a newer alternative.

Bun uses JavaScriptCore, the JavaScript engine used by Safari. Node.js uses V8, the engine used by Chrome. Bun’s runtime and tooling are mostly written in Zig, with a strong focus on startup time and performance.

That different foundation is one reason Bun can be very fast. But speed is not the only reason to use it.

The bigger difference is the amount of tooling included with the runtime.

What comes with Bun?

Installing Bun gives you several tools:

With Node.js, you normally combine Node with npm, a TypeScript runner, a test library, a watcher, and a bundler.

That is not a criticism of Node. Its ecosystem is enormous, stable, and proven. Bun makes a different tradeoff: it puts many common tools in one place.

The current Bun 1.3 series also includes a full-stack development server and more built-in database clients. Bun is no longer just a faster way to run a script. It wants to cover most of the development workflow.

Installing Bun

On macOS and Linux, the simplest installation method is Bun’s official script:

curl -fsSL https://bun.sh/install | bash

If you use Homebrew on macOS, you can install it this way:

brew tap oven-sh/bun
brew install bun

On Windows, open PowerShell and run:

powershell -c "irm bun.sh/install.ps1 | iex"

You can also install Bun through npm if Node.js is already installed:

npm install -g bun

Now verify the installation:

bun --version

You can also print the exact source revision:

bun --revision

To upgrade Bun later, run:

bun upgrade

Running your first JavaScript program

Let’s start with the smallest possible program.

Create a file named hello.js:

console.log('Hello from Bun')

Run it with Bun:

bun hello.js

You can include the optional run word too:

bun run hello.js

Both commands do the same thing.

This is the Bun equivalent of running node hello.js. Bun loads the file, executes the JavaScript, prints the message, and exits.

You can also evaluate a short expression without creating a file:

bun -e "console.log(2 + 2)"

This is useful for quick experiments.

Creating a Bun project

A real project needs a package.json, TypeScript settings, and a few other files. Bun can create them for you.

Make a new folder and run bun init:

mkdir bun-demo
cd bun-demo
bun init

Bun asks which kind of project you want to create. Pick the blank project for this tutorial.

The generated package.json is small. It identifies the project and points to its main file. Bun also creates a TypeScript configuration and installs Bun’s type definitions.

Run the generated entry point:

bun run index.ts

This is already a TypeScript project. We did not install a separate TypeScript runner.

Running TypeScript directly

Bun can execute .ts and .tsx files without a build step.

Create greet.ts:

function greet(name: string) {
  return `Hello ${name}`
}

console.log(greet('Flavio'))

Run it:

bun greet.ts

Bun removes the TypeScript syntax and runs the resulting JavaScript.

There is an important detail here: Bun does not type-check your program before running it.

For example, this file can still start:

const age: number = 'twenty'

console.log(age)

Bun’s job is to transpile and execute it. Your editor may show the type error, but Bun does not stop the program because of it.

Use the TypeScript compiler when you want a separate type-checking step:

bun add -d typescript @types/bun
bunx tsc --noEmit

My advice is to run that check in your test or build process. Fast execution is useful, but it should not replace type checking.

ES modules and CommonJS

JavaScript has two module systems in common use.

New projects normally use ES modules with import and export:

import { join } from 'node:path'

console.log(join('posts', 'bun.md'))

Older Node.js projects often use CommonJS and require():

const { join } = require('node:path')

console.log(join('posts', 'bun.md'))

Bun supports both. It can also load many CommonJS packages from ES modules, which helps when you use the existing npm ecosystem.

I still recommend ES modules for new projects. They are the JavaScript standard and work in browsers too.

Installing npm packages

Bun is compatible with the npm package ecosystem. You still use package.json, node_modules, semantic versions, and the npm registry.

Install all dependencies listed in package.json:

bun install

Add a package:

bun add hono

Add a development dependency:

bun add -d typescript

Remove a package:

bun remove hono

Update one package:

bun update hono

Or update all packages:

bun update

The commands are different from npm, but the files and packages are familiar.

The Bun lockfile

The first bun install creates bun.lock.

This file records the exact dependency versions Bun resolved. Commit it to Git. Your computer, your teammates, and your deployment system should all install the same versions.

In CI, use:

bun ci

This is equivalent to bun install --frozen-lockfile. It fails if package.json and bun.lock disagree instead of silently changing the lockfile.

When you run bun install in an existing npm, Yarn, or pnpm project, Bun can migrate the existing lockfile into bun.lock. It preserves the old file, so you can review the result before deleting anything.

A note about install scripts

Some npm packages run code during installation. Native packages often use a postinstall script to download or build a binary.

Bun does not run arbitrary lifecycle scripts from dependencies by default. This is a security feature, but it can surprise you when a package depends on that script.

If you trust the package, allow it explicitly:

bun pm trust package-name

Then install again. Do not trust a package only to make an error disappear. Check what its install script does first.

Running package.json scripts

Suppose your package.json contains these scripts:

{
  "scripts": {
    "dev": "bun server.ts",
    "test": "bun test",
    "typecheck": "tsc --noEmit"
  }
}

Run one with bun run:

bun run dev

I prefer keeping run. bun run dev makes it clear that dev comes from package.json.

This distinction matters for built-in commands. bun run test runs the test script from package.json, while bun test starts Bun’s test runner directly. In our example they lead to the same command, but that is not always true.

Bun also adds local package executables from node_modules/.bin to the script path. You do not need to install those tools globally.

Running a package with bunx

bunx runs a package executable without installing it globally. It plays the same role as npx.

For example:

bunx cowsay 'Hello from Bun'

This downloads the package if needed, caches it, and runs its command.

You will often use bunx for project generators and one-off tools:

bunx tsc --noEmit

If a tool belongs to your project, add it as a development dependency instead. That gives everyone the same version.

Environment variables without dotenv

Bun automatically reads .env files.

Create a .env file:

APP_NAME=Bun demo
PORT=3000

Read the values through process.env:

console.log(process.env.APP_NAME)
console.log(process.env.PORT)

Or use Bun.env:

console.log(Bun.env.APP_NAME)

Bun loads .env, an environment-specific file such as .env.development, and .env.local. Later files take priority over earlier files.

This means you usually do not need the dotenv package.

Be careful with secrets. Add .env.local to .gitignore, and use your hosting provider’s secret storage in production.

Watch mode and hot reload

During development, you want the program to react when a file changes.

Bun has two modes for this.

Use --watch to restart the process:

bun --watch server.ts

Bun follows the imported files. When one changes, it starts the process again.

Use --hot to reload modules without restarting the whole process:

bun --hot server.ts

Hot reload keeps global state and can keep an HTTP server alive. That can make server development feel almost instant.

Start with --watch. A complete restart is easier to reason about. Move to --hot when keeping the process alive gives you a real benefit.

Notice where the flag goes when you run a package script:

bun --watch run dev

Bun flags go immediately after bun. A flag at the end may be passed to the script instead.

Using Web APIs in Bun

Bun implements many APIs you already know from browsers.

You can make an HTTP request with fetch():

const response = await fetch('https://api.github.com/users/flaviocopes')
const user = await response.json()

console.log(user.name)

The response is a standard Response object. You can also use Request, Headers, URL, Blob, FormData, and Web Streams.

This makes code easier to move between runtimes. It also explains the shape of Bun’s HTTP server: a request comes in, and your handler returns a Response.

Reading and writing files

The Bun.file() API gives you a lazy reference to a file.

Read package.json as text:

const file = Bun.file('package.json')
const text = await file.text()

console.log(text)

You can parse JSON directly:

const file = Bun.file('package.json')
const packageJson = await file.json()

console.log(packageJson.name)

Write a file with Bun.write():

await Bun.write('message.txt', 'Hello from Bun')

Copy a file by passing one BunFile to Bun.write():

const source = Bun.file('message.txt')

await Bun.write('message-copy.txt', source)

Bun.file() does not read the whole file when you create the reference. Bun waits until you ask for its contents or send it somewhere.

You can still use Node’s node:fs APIs. They are useful for operations such as creating directories or reading a directory:

import { readdir } from 'node:fs/promises'

const files = await readdir('.')
console.log(files)

Creating an HTTP server

Bun includes an HTTP server, so we can build a small API without installing Express.

Create server.ts:

const server = Bun.serve({
  port: 3000,
  fetch() {
    return new Response('Hello from Bun')
  },
})

console.log(`Listening on ${server.url}`)

Run it:

bun server.ts

Open http://localhost:3000 in your browser.

The fetch() handler runs for every request. It returns the same standard Response object used by the browser Fetch API and Cloudflare Workers.

Adding routes

You can define routes directly inside Bun.serve():

const server = Bun.serve({
  port: 3000,
  routes: {
    '/': new Response('Home'),

    '/api/status': Response.json({ ok: true }),

    '/hello/:name': request => {
      return new Response(`Hello ${request.params.name}`)
    },
  },

  fetch() {
    return new Response('Not found', { status: 404 })
  },
})

console.log(`Listening on ${server.url}`)

A request to /hello/Flavio returns Hello Flavio.

The final fetch() handler is the fallback. Bun calls it when no route matches.

Handling different HTTP methods

A route can have a different handler for each HTTP method:

const notes = ['Learn Bun']

Bun.serve({
  port: 3000,
  routes: {
    '/api/notes': {
      GET: () => Response.json(notes),

      POST: async request => {
        const body = await request.json()
        notes.push(body.text)

        return Response.json(body, { status: 201 })
      },
    },
  },
})

Test the GET route:

curl http://localhost:3000/api/notes

Create a note with POST:

curl -X POST http://localhost:3000/api/notes \
  -H 'Content-Type: application/json' \
  -d '{"text":"Build something"}'

This example keeps the notes in memory. They disappear when the server restarts, but the request and response flow is real.

For bigger applications, I like adding a framework such as Hono. Bun gives you the runtime and server. Hono adds a clean layer for routing, middleware, validation, and application structure.

Testing with bun:test

Bun includes a test runner with an API inspired by Jest.

Create math.ts:

export function add(a: number, b: number) {
  return a + b
}

Now create math.test.ts:

import { expect, test } from 'bun:test'
import { add } from './math'

test('adds two numbers', () => {
  expect(add(2, 3)).toBe(5)
})

Run the test:

bun test

Bun finds files with names such as *.test.ts, *.spec.ts, and their JavaScript or JSX equivalents.

Run only tests whose path contains math:

bun test math

Run tests again when files change:

bun --watch test

The test runner also supports describe(), lifecycle hooks, mocks, snapshots, and coverage.

It aims for Jest compatibility, but it is not Jest. A large Jest suite may use APIs or environment behavior Bun does not support yet. Try the migration on your real suite before changing CI.

Bundling JavaScript and TypeScript

Running a server file does not normally require a bundle. Bun can execute the source directly.

Bundling is useful when you want to combine browser modules, reduce the number of files, or prepare code for another runtime.

Bundle an entry point for the browser:

bun build ./src/app.ts --outdir ./dist

The browser is the default target. For Bun server code, set the target explicitly:

bun build ./server.ts --outdir ./dist --target bun

For Node.js, use:

bun build ./server.ts --outdir ./dist --target node

Add minification for a production browser bundle:

bun build ./src/app.ts --outdir ./dist --minify

Bun’s bundler understands JavaScript, TypeScript, JSX, and TSX. Like the runtime, it transpiles TypeScript but does not type-check it.

Creating a standalone executable

Bun can bundle your program together with the Bun runtime.

Create cli.ts:

const name = process.argv[2] ?? 'friend'

console.log(`Hello ${name}`)

Run it normally first:

bun cli.ts Flavio

Now compile it:

bun build --compile ./cli.ts --outfile hello

Run the generated executable:

./hello Flavio

The other person does not need Bun or Node.js installed. The executable contains the runtime and your program.

The tradeoff is file size. You are shipping a runtime, not turning JavaScript into a tiny native program.

Using the built-in SQLite driver

Bun includes a native SQLite driver in bun:sqlite.

This makes SQLite a great choice for a small Bun application. You do not need an npm package or a native addon.

Create database.ts:

import { Database } from 'bun:sqlite'

const db = new Database('notes.sqlite')

db.run(`
  CREATE TABLE IF NOT EXISTS notes (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    text TEXT NOT NULL
  )
`)

const insert = db.query('INSERT INTO notes (text) VALUES (?)')
insert.run('Learn Bun')

const notes = db.query('SELECT * FROM notes').all()
console.log(notes)

db.close()

Run it:

bun database.ts

The first run creates notes.sqlite, creates the table, inserts a note, and reads it back.

Use bound parameters, as we did with ?, instead of building SQL strings from user input. Bound parameters protect the query from SQL injection.

Bun also has a unified SQL client for PostgreSQL, MySQL, and SQLite. I would still start with the dedicated SQLite API for a small local database because its purpose is very clear.

How compatible is Bun with Node.js?

Bun wants to run Node.js applications and npm packages with little or no change.

It supports many important Node APIs, including node:fs, node:path, Buffer, process, streams, and much of Node-API for native addons. It also understands package.json and installs a normal node_modules folder.

But Bun is not Node.js.

The engines are different. Some Node APIs are incomplete. Timing, edge cases, native packages, and tools that inspect Node internals can behave differently.

This distinction matters when someone says Bun is a “drop-in replacement.” It can feel like one for many projects. It is not a promise that every Node program will work unchanged.

Check Bun’s Node.js compatibility page when you depend on a specific API.

Moving an existing Node.js project to Bun

You do not need to migrate everything at once.

The safest first step is to use Bun only as the package manager:

bun install

Your application can still run with Node. Bun creates a Node-compatible node_modules folder.

Review the new bun.lock, run the existing tests, and check any dependency that uses an install script.

Next, try the package scripts:

bun run dev
bun run build
bun test

Then run the application itself with Bun:

bun run src/index.ts

Test the parts that touch files, networking, child processes, databases, and native modules. Those boundaries are more likely to reveal runtime differences than ordinary JavaScript code.

When everything works locally, use bun ci in CI and test the production build in the same operating system you deploy to.

My advice is to keep the migration boring. Change one layer, verify it, and then change the next one.

When should you use Bun?

Bun is a good fit when:

Node.js is still the safer default when you depend on unusual native modules, Node internals, or a platform that only supports Node.

You can also mix the two. Using bun install and bun run does not force you to use Bun in production.

The commands you’ll use most

Here is the short list I keep in mind:

bun init                 # create a project
bun index.ts             # run a file
bun --watch index.ts     # restart when files change
bun install              # install dependencies
bun add hono             # add a dependency
bun add -d typescript    # add a development dependency
bun remove hono          # remove a dependency
bun update               # update dependencies
bun run dev              # run a package.json script
bunx tsc --noEmit        # run a package executable
bun test                 # run tests
bun build ./app.ts       # create a bundle
bun upgrade              # update Bun

You do not need to memorize all of them. Start with bun init, bun install, and bun run.

What’s next

Bun gives us a fast runtime and a lot of built-in tools. We can run TypeScript, install packages, watch files, test code, serve HTTP, bundle an app, and use SQLite without assembling a toolchain first.

That is the real appeal of Bun to me. The speed is nice. The reduced setup is even better.

Next in this series we’ll look at Hono, a small HTTP framework that pairs beautifully with Bun.

~~~

Related posts about js: