Bun Runtime
When to Use
Use Bun for scripts, standalone API servers, CLI tools, or any new Node-compatible project where startup speed and all-in-one tooling matter. Also use when migrating off Node/pnpm for performance gains.
Core Rules
- Bun is Node-compatible — most npm packages work without changes
- Use
Bun.serve()for HTTP servers (much faster than Node http) Bun.file()is lazy — it doesn't read the file until you call.text(),.json(),.arrayBuffer()bun:sqliteis built-in and fast — no driver needed for SQLiteprepare: falseis needed when using Bun with Supabase connection pooler (PgBouncer)- Bun test runner uses Jest-compatible API — most Jest tests work as-is
Bun vs Node.js Comparison
| Feature | Node.js | Bun |
|---|---|---|
| Runtime | V8 (Chrome) | JavaScriptCore (Safari) |
| Startup | ~50-100ms | ~5ms |
| Package manager | npm/pnpm/yarn | bun install |
| Test runner | jest/vitest | bun test (built-in) |
| Bundler | webpack/esbuild | bun build (built-in) |
| TypeScript | Needs ts-node/tsx | Native (no config) |
| .env loading | dotenv package | Built-in |
| SQLite | better-sqlite3 | bun:sqlite (built-in) |
| Watch mode | nodemon/--watch | --watch (built-in) |
Install & Setup
# Install Bun
curl -fsSL https://bun.sh/install | bash
# Init project
bun init
# Run TypeScript directly (no transpile step)
bun run script.ts
# Watch mode
bun --watch run script.ts
# Install packages (much faster than npm)
bun install
bun add zod drizzle-orm
bun add -d typescript @types/bun
# Run package scripts
bun run dev
bun run build
bunfig.toml
# bunfig.toml — Bun config file
[install]
# Use local registry
# registry = "http://localhost:4873"
production = false # install devDependencies by default
[test]
timeout = 5000 # ms per test
coverage = true
[run]
# bun run --smol uses less memory
bun = ["--smol"]
Bun.serve() — HTTP Server
// server.ts
import { serve, type ServeOptions } from 'bun';
const server = serve({
port: process.env.PORT ? parseInt(process.env.PORT) : 3000,
// Single fetch handler (replaces Express routing)
fetch(req: Request): Response | Promise<Response> {
const url = new URL(req.url);
// Router
if (url.pathname === '/health') {
return Response.json({ status: 'ok', uptime: process.uptime() });
}
if (url.pathname === '/api/users' && req.method === 'GET') {
return handleGetUsers(req);
}
if (url.pathname.startsWith('/api/users/') && req.method === 'GET') {
const id = url.pathname.split('/').at(-1)!;
return handleGetUser(req, id);
}
return new Response('Not Found', { status: 404 });
},
error(err: Error): Response {
console.error(err);
return Response.json({ error: 'Internal Server Error' }, { status: 500 });
},
} satisfies ServeOptions);
console.log(`Listening on http://localhost:${server.port}`);
// Handler function
async function handleGetUsers(req: Request): Promise<Response> {
// Parse query params
const url = new URL(req.url);
const limit = parseInt(url.searchParams.get('limit') ?? '10');
const users = await db.select().from(usersTable).limit(limit);
return Response.json(users);
}
Middleware Pattern
// Compose middleware manually (no framework needed)
type Handler = (req: Request, ctx: RequestContext) => Promise<Response>;
type Middleware = (next: Handler) => Handler;
const withAuth: Middleware = (next) => async (req, ctx) => {
const token = req.headers.get('authorization')?.replace('Bearer ', '');
if (!token) return new Response('Unauthorized', { status: 401 });
const user = verifyJwt(token);
if (!user) return new Response('Forbidden', { status: 403 });
return next(req, { ...ctx, user });
};
const withCors: Middleware = (next) => async (req, ctx) => {
if (req.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
});
}
const response = await next(req, ctx);
response.headers.set('Access-Control-Allow-Origin', '*');
return response;
};
// Apply middleware (right-to-left)
const handler = withCors(withAuth(myHandler));
Bun.file() — File I/O
// Read file (lazy — doesn't load until awaited)
const file = Bun.file('./data/config.json');
const text = await file.text();
const json = await file.json<Config>();
const buffer = await file.arrayBuffer();
const size = file.size;
const type = file.type; // MIME type
// Write file
await Bun.write('./output/result.json', JSON.stringify(data, null, 2));
await Bun.write('./output/file.txt', 'Hello from Bun');
// Copy
await Bun.write(Bun.file('./copy.txt'), Bun.file('./original.txt'));
// Stream large file
const stream = Bun.file('./large-video.mp4').stream();
return new Response(stream, {
headers: { 'Content-Type': 'video/mp4' },
});
// Check if file exists
const exists = await Bun.file('./config.json').exists();
bun:sqlite — Built-in SQLite
import { Database } from 'bun:sqlite';
const db = new Database('./app.db', { create: true });
// Migrations
db.run(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
)
`);
// Prepared statements (fast — reuse for repeated queries)
const insertUser = db.prepare<{ email: string; name: string }, []>(
'INSERT INTO users (email, name) VALUES ($email, $name) RETURNING *'
);
const getUser = db.prepare<{ id: number }, [number]>(
'SELECT * FROM users WHERE id = ?'
);
// Execute
const user = insertUser.get({ email: 'user@example.com', name: 'Example User' });
const found = getUser.get(1);
const all = db.query('SELECT * FROM users').all();
// Transaction
const createUserWithPost = db.transaction((userData: { email: string; name: string; postTitle: string }) => {
const user = insertUser.get({ email: userData.email, name: userData.name })!;
db.run('INSERT INTO posts (title, author_id) VALUES (?, ?)', [userData.postTitle, user.id]);
return user;
});
const newUser = createUserWithPost({ email: 'a@b.com', name: 'A', postTitle: 'First Post' });
bun test — Test Runner
// user.test.ts
import { describe, it, expect, beforeAll, afterAll, mock, spyOn } from 'bun:test';
describe('UserService', () => {
let service: UserService;
beforeAll(() => {
service = new UserService(testDb);
});
it('creates a user', async () => {
const user = await service.create({ email: 'test@test.com', name: 'Test' });
expect(user.id).toBeDefined();
expect(user.email).toBe('test@test.com');
});
it('throws on duplicate email', async () => {
await service.create({ email: 'dup@test.com', name: 'Dup' });
expect(service.create({ email: 'dup@test.com', name: 'Dup 2' })).rejects.toThrow();
});
it('mocks external calls', async () => {
const mockSend = mock(() => Promise.resolve({ id: 'email-1' }));
const emailer = { send: mockSend };
await service.createAndNotify({ email: 'n@test.com', name: 'N' }, emailer);
expect(mockSend).toHaveBeenCalledTimes(1);
expect(mockSend).toHaveBeenCalledWith(expect.objectContaining({ to: 'n@test.com' }));
});
});
// Run
// bun test
// bun test --watch
// bun test --coverage
// bun test user.test.ts
bun build — Bundler
# Bundle for browser
bun build ./src/index.ts --outdir ./dist --target browser --minify
# Bundle for Bun/Node (no bundling of externals)
bun build ./src/server.ts --outdir ./dist --target bun --external 'drizzle-orm' --external 'postgres'
# With sourcemaps
bun build ./src/index.ts --outdir ./dist --sourcemap=external
# Compile to single executable (no runtime needed!)
bun build ./src/cli.ts --compile --outfile ./bin/mycli
Shell Scripting with Bun
// scripts/deploy.ts
import { $ } from 'bun'; // Bun shell — available in Bun 1.0.24+
// Run commands
await $`git pull origin main`;
await $`bun install --frozen-lockfile`;
await $`bun run build`;
// Capture output
const { stdout } = await $`git log --oneline -5`.text();
console.log('Recent commits:', stdout);
// With error handling
try {
await $`npm run test`;
} catch (err) {
console.error('Tests failed:', err.stderr.toString());
process.exit(1);
}
// Bun.spawnSync for synchronous use
const result = Bun.spawnSync(['git', 'status', '--short']);
const output = new TextDecoder().decode(result.stdout);
Migrating from Node.js / pnpm
# Replace package manager
rm -rf node_modules package-lock.json yarn.lock pnpm-lock.yaml
bun install # generates bun.lockb
# Replace scripts (package.json stays the same)
# "scripts": { "dev": "ts-node src/index.ts" }
# → bun run dev (Bun runs TypeScript natively)
# Replace dotenv
# remove: import 'dotenv/config' (Bun loads .env automatically)
# Replace nodemon
# "dev": "nodemon src/index.ts"
# → "dev": "bun --watch run src/index.ts"
Common Gotchas
// ✗ __dirname not available in ESM (same as Node ESM)
// ✓ Use:
import.meta.dir // directory of current file
import.meta.file // full path of current file
// ✗ require() in .ts files
// ✓ Use import statements
// Bun loads .env automatically — no dotenv needed
// But: only in the main entry, not in workers
Quick Reference
| Task | Command / API |
|---|---|
| Run TypeScript | bun run file.ts |
| Install packages | bun install / bun add pkg |
| Dev server | bun --watch run server.ts |
| Tests | bun test |
| Build | bun build src/index.ts --outdir dist |
| Compile binary | bun build --compile --outfile app |
| HTTP server | Bun.serve({ fetch(req) {} }) |
| Read file | await Bun.file('path').text() |
| Write file | await Bun.write('path', data) |
| SQLite | import { Database } from 'bun:sqlite' |
| Shell command | await $\command`` (Bun shell) |
| Current dir | import.meta.dir |
Related Skills
typescript-expert— TypeScript with Bunfastapi-expert— alternative backendedge-computing-patterns— edge deployment
GitNexus Index
This skill is indexed by GitNexus for knowledge graph traversal. Index path: /Users/localuser/.claude/skills/bun-runtime/.gitnexus Last indexed: 2026-05-23