Getting Started with Bun: Package Management, Web Server, Bundler and Tests

Key takeaways

How Bun replaces several Node.js tools at once: installing packages, serving HTTP, bundling, testing, and working with files and environment variables, with notes on where compatibility matters.

Introduction

A typical Node.js project pulls in a separate package manager, a bundler, a TypeScript compiler, and a test runner, each with its own configuration. Bun folds all of these into one binary. This article covers installing Bun, package management, the built-in HTTP server, bundler and test runner, file and environment APIs, hot reload, and a small end-to-end example, along with the places where Node.js compatibility still falls short.


What is Bun?

Core Features

Bun is a JavaScript runtime that also ships a package manager, bundler, and test runner. Key Advantages:

  • Speed-focused design: startup, installs, and the test runner are all built with speed as the main goal
  • All-in-One: Runtime + Package Manager + Bundler + Test
  • Node.js Compatible: Mostly compatible
  • TypeScript: Native support
  • Web API: Built-in fetch, WebSocket

Background

Bun was created by Jarred Sumner, is written in Zig, and runs JavaScript on Apple’s JavaScriptCore engine rather than V8. It reached 1.0 in 2023, which makes it much younger than Node.js. Most of its speed comes from those two choices plus a global install cache and parallel downloads; how much faster it is for your project depends on the workload, so measure with your own install and test suite before switching.

Ecosystem Compatibility:

  • Express, Fastify, and Hono work out of the box
  • React, Vue, and Svelte projects install and build
  • Most npm packages work
  • Native addons require recompilation, and some fail
  • Some Node-specific APIs are still incomplete

Good fit: new projects, monorepos with slow installs, scripts and CLIs, and local development environments.

Test carefully first: production services, because the API surface is still evolving.

Not yet a good fit: apps with many native addons, and organizations that require an LTS release line.


Installation and Basic Usage

Installation

# macOS/Linux
curl -fsSL https://bun.sh/install | bash
# Windows
powershell -c "irm bun.sh/install.ps1 | iex"

Basic Commands

# Run file
bun run index.ts
# REPL
bun
# Check version
bun --version

bun run index.ts (or just bun index.ts) executes TypeScript directly: Bun’s transpiler strips the types on the fly, with no tsc step and no ts-node. That is a transpile, not a type check — a type error does not stop the program from running. Keep tsc --noEmit in CI (or your editor) if you rely on the type checker to catch mistakes; people switching from ts-node, which type-checks by default, are often surprised that broken types “work” under Bun.

bun run <script> also runs package.json scripts, and it is noticeably quicker to start than npm run because it does not spawn a Node process just to look up the script. By default, though, a script whose command is itself a Node CLI ("dev": "vite") still runs that tool’s shebang — i.e. under Node, if Node is installed. Add --bun (bun --bun run dev) to force the tool onto the Bun runtime, and expect the occasional tool that does not cope with that.


Package Management

Package Installation

# Install dependencies
bun install
# Add package
bun add express
bun add -d typescript
# Remove package
bun remove express
# Global install
bun add -g typescript

bun install reads the same package.json and installs into an ordinary node_modules folder, so tools that expect Node’s layout keep working. The speed comes from a global cache of extracted packages that are hard-linked (or copy-on-write cloned, on macOS) into each project, parallel network fetches, and a resolver written in native code. The gap over npm is largest on a warm cache, where Bun barely touches the network; on a cold cache in CI it narrows to the time it takes to download the tarballs. Measure on your own project rather than trusting any published number.

The lockfile is Bun’s own. Since Bun 1.2 it is a text file, bun.lock; older versions wrote a binary bun.lockb, which you will still see in many repositories and which produced unreadable diffs in code review. If you migrate, Bun can read an existing package-lock.json or yarn.lock on the first install, but after that the team should use one package manager — two lockfiles drifting apart is the classic source of “works on my machine” dependency bugs.

One behavior difference worth knowing: Bun does not run dependencies’ postinstall scripts by default, for security. Packages that build native binaries or download assets on install (older sharp, esbuild in some setups, Prisma’s engines) then fail at runtime with a missing-binary error. Add them to "trustedDependencies" in package.json to allow their scripts.


Web Server

HTTP Server

// server.ts
const server = Bun.serve({
  port: 3000,
  fetch(req) {
    const url = new URL(req.url);
    if (url.pathname === '/') {
      return new Response('Hello Bun!');
    }
    if (url.pathname === '/api/users') {
      return Response.json([
        { id: 1, name: 'John' },
        { id: 2, name: 'Jane' },
      ]);
    }
    return new Response('Not Found', { status: 404 });
  },
});
console.log(`Server running on http://localhost:${server.port}`);

Bun.serve uses the web-standard Request/Response objects instead of Node’s req/res streams — the same model as Cloudflare Workers and Deno, so a handler written this way ports between those runtimes with little change. The fetch function receives each request and must return a Response (or a promise of one). If it throws, Bun returns a 500 and logs the error; add an error(err) handler to the options object to control what the client sees.

Manual if (url.pathname === ...) chains get unwieldy quickly. Recent Bun versions (1.2.3+) accept a routes object with path parameters ('/api/users/:id'), and frameworks such as Hono or Elysia build routing, middleware, and validation on top of Bun.serve. Keep in mind that Response.json sets Content-Type: application/json for you, while new Response('...') with a string defaults to text/plain;charset=utf-8.

Using Express

import express from 'express';
const app = express();
app.get('/', (req, res) => {
  res.send('Hello Bun with Express!');
});
app.listen(3000, () => {
  console.log('Server running on :3000');
});

Express runs through Bun’s implementation of Node’s http module, so existing apps usually start without changes. You keep the Express middleware ecosystem, but you also keep Express’s overhead: most of the throughput advantage people quote for Bun comes from Bun.serve itself, not from running Express on Bun. Migrating an Express app to Bun is therefore mainly a tooling win (faster installs, TypeScript without a build step) rather than a performance one.


Bundler

Basic Bundling

// build.ts
await Bun.build({
  entrypoints: ['./src/index.ts'],
  outdir: './dist',
  target: 'browser',
  minify: true,
  sourcemap: 'external',
});

React Bundling

await Bun.build({
  entrypoints: ['./src/index.tsx'],
  outdir: './dist',
  target: 'browser',
  minify: true,
  splitting: true,
  loader: {
    '.png': 'file',
    '.svg': 'file',
  },
});

target decides which built-ins are available: 'browser' treats node:fs and friends as unavailable, 'node' leaves Node built-ins as external imports, and 'bun' additionally allows Bun.* APIs and bun: modules. splitting: true emits shared chunks for code imported by several entry points or loaded with dynamic import(), which only works with ESM output. The 'file' loader copies the asset to outdir with a content hash in its name and replaces the import with its path.

Before Bun 1.2, a failed Bun.build did not throw; it resolved to { success: false, logs, outputs }, and a build script that ignored success exited with status 0 while writing nothing — a silent CI pass. Current versions throw an AggregateError by default, but if you pin an older Bun or pass throw: false, check if (!result.success) { console.error(result.logs); process.exit(1); } explicitly.


Testing

Basic Tests

// math.test.ts
import { expect, test, describe } from 'bun:test';
describe('Math', () => {
  test('add', () => {
    expect(1 + 2).toBe(3);
  });
  test('multiply', () => {
    expect(2 * 3).toBe(6);
  });
});

Async Tests

import { expect, test } from 'bun:test';
test('fetch users', async () => {
  const response = await fetch('https://api.example.com/users');
  const users = await response.json();
  expect(users).toBeArray();
  expect(users.length).toBeGreaterThan(0);
});

Run

bun test

bun test finds files matching *.test.*, *_test.*, *.spec.*, and *_spec.*, and its API mirrors Jest (describe, test, expect, mock, beforeEach, snapshot testing), so many Jest suites run unchanged; toBeArray() above is one of the extra matchers Bun adds. The async example hits a real URL, which is fine as an illustration but makes a poor unit test — it fails when the network is down and slows the suite. Mock fetch (spyOn(globalThis, 'fetch')) or point it at a local Bun.serve started in beforeAll.

Where migrations from Jest stall is usually the environment rather than the matchers. Bun does not ship a DOM; component tests need happy-dom registered through a preload file (bunfig.toml → [test] preload = [...]). Jest-specific configuration such as moduleNameMapper or custom Babel transforms has no direct equivalent, and module mocking via mock.module() behaves differently from jest.mock hoisting. Running both runners side by side in CI for a while, as the FAQ suggests, shows which tests depend on those differences.


File System

Reading Files

// Text file
const text = await Bun.file('data.txt').text();
// JSON file
const json = await Bun.file('data.json').json();
// Binary file
const buffer = await Bun.file('image.png').arrayBuffer();

Writing Files

await Bun.write('output.txt', 'Hello Bun!');
await Bun.write('data.json', JSON.stringify({ name: 'John' }));

Bun.file() is lazy: it returns a BunFile handle (a Blob) without touching the disk, and the read happens when you call .text(), .json(), or .arrayBuffer(). A consequence is that Bun.file('missing.txt') does not throw; the error only arrives at read time (ENOENT), and await Bun.file(path).exists() is the way to check first. Bun.write accepts strings, Blobs, Response objects, and other BunFiles, and picks the fastest system call for the job (for example, copying one BunFile to another can use a kernel-level copy). It overwrites the target without warning, so guard anything that must not be clobbered. The node:fs module works too, which matters for libraries written against Node.


Environment Variables

# .env
DATABASE_URL=postgresql://localhost:5432/mydb
API_KEY=secret123
// Usage
console.log(process.env.DATABASE_URL);
console.log(Bun.env.API_KEY);

Bun loads .env, then .env.<NODE_ENV> and .env.local automatically, so dotenv is unnecessary. The convenience cuts both ways: a stray .env in the working directory is picked up silently, and a test run can connect to the database named in your development .env unless .env.test overrides it. process.env and Bun.env are the same object. Values are always strings — process.env.PORT is "3000", not 3000 — so parse and validate them once at startup (a small schema check with Zod or manual Number() checks) rather than scattering conversions through the code. In Docker or systemd deployments, the platform’s real environment variables take precedence over the file.


Hot Reload

bun --watch server.ts
# or: soft reload without restarting the process
bun --hot server.ts

--watch restarts the whole process when an imported file changes — simple and predictable, since all state starts fresh. --hot instead re-evaluates changed modules inside the running process and swaps the fetch handler of an existing Bun.serve without dropping the listening socket. That is faster, but global state survives across reloads: a setInterval or database pool created at module top level gets created again on every save unless you stash it on globalThis. When a hot-reloaded server starts logging duplicated timers or runs out of DB connections after an hour of editing, that is almost always the cause, and switching back to --watch confirms it.


Real-World Example

REST API

// api/server.ts
const server = Bun.serve({
  port: 3000,
  async fetch(req) {
    const url = new URL(req.url);
    if (url.pathname === '/api/users' && req.method === 'GET') {
      const users = await db.select().from(usersTable);
      return Response.json(users);
    }
    if (url.pathname === '/api/users' && req.method === 'POST') {
      const body = await req.json();
      const user = await db.insert(usersTable).values(body).returning();
      return Response.json(user[0], { status: 201 });
    }
    return new Response('Not Found', { status: 404 });
  },
});

db and usersTable stand for a query builder such as Drizzle ORM, which runs on Bun (Bun also ships its own bun:sqlite driver and, in recent versions, Bun.sql for Postgres). Two things are missing that a real endpoint needs. First, await req.json() throws a SyntaxError on a malformed body, which Bun turns into a 500 — wrap it in try/catch and return 400. Second, passing body straight to .values() lets the client set any column, including id or an isAdmin flag; validate and pick the allowed fields first. These are the same concerns as in any Node server — Bun changes the toolchain, not the security model.


Summary and Checklist

Key Summary

  • Bun: JavaScript runtime designed around speed
  • All-in-One: Runtime + Package Manager + Bundler + Test
  • TypeScript: Native support
  • Node.js Compatible: Mostly compatible
  • Web API: Built-in fetch, WebSocket

Implementation Checklist

  • Install Bun
  • Initialize project
  • Implement web server
  • Configure bundling
  • Write tests
  • Use file system
  • Deploy

Frequently Asked Questions (FAQ)

Q. Can it completely replace Node.js?

A. Possible in most cases, but some native modules may not be compatible.

Q. Is it safe to use in production?

A. Bun is in the 1.x series with frequent minor releases, and many teams run it in production. There is no LTS release line like Node.js has, so pin the exact Bun version in CI and Docker images, and upgrade deliberately. If your organization requires LTS support windows, Node.js remains the safer choice.

Q. Can I use npm packages?

A. Yes, most npm packages are compatible.

Q. Does it support Windows?

A. Yes, Windows is supported.

Q. Should I use Bun’s built-in bundler or keep Vite/webpack for a React app?

A. For small apps, Bun.build() with target: 'browser' covers what the Bundler section shows: entry points, minification, code splitting, source maps and file loaders. What Vite adds on top is a much larger plugin ecosystem, a mature React dev server with HMR, and ready-made framework integrations. A common middle ground is to use Bun as the package manager and script runner (bun install, bun run dev) while Vite keeps doing the bundling, and to try Bun.build() first on simpler targets such as CLIs or server code.