Switching from Node to Bun - A Migration Success Story
April 29, 2025 @ 2 AM
Over the past few days, I've been refactoring this blog's codebase to transition from Node.js to Bun as the primary runtime and build tool. The results have been remarkable - build times reduced from 37 seconds to around 20 seconds (a 46% improvement), and significantly cleaner code throughout the project. This post details the migration process, key advantages I've discovered, and a few challenges worth noting.
The migration wasn't a single giant leap but a methodical series of steps, starting with package management, then build tooling, and finally leveraging Bun's native APIs. What began as a simple performance experiment quickly became a full migration that transformed both the codebase and development workflow.
Why Make the Switch?
Node.js has served me well, but Bun offers compelling advantages:
- Native speed: Built on JavaScriptCore (WebKit's JS engine) instead of V8
- Integrated tooling: Package manager, bundler, test runner, and more in one binary
- TypeScript support: First-class TypeScript without extra dependencies
- Modern APIs: Native File and Glob implementations that outperform Node alternatives
- TOML support: Built-in parser for cleaner configuration files
The migration promised both performance improvements and code quality benefits - exactly what I look for in technology upgrades.
Migration Highlights: File Operations
The most impactful changes involved replacing Node's file system operations with Bun's native File API. Here's a representative example from my serve-single-domain.ts
file:
Before (Node.js with fs-extra):
// Check if file paths exist
if (await fs.pathExists(withSlash)) {
filePath = withSlash;
} else if (await fs.pathExists(withoutSlash)) {
filePath = withoutSlash;
}
// Read file content
const content = await fs.readFile(filePath);
let htmlContent = content.toString();
After (Bun's File API):
// Check if file paths exist
if (await Bun.file(withSlash).exists()) {
filePath = withSlash;
} else if (await Bun.file(withoutSlash).exists()) {
filePath = withoutSlash;
}
// Read file content
const bunFile = Bun.file(filePath);
let htmlContent = await bunFile.text();
The Bun version is not only more concise but also significantly faster. The native File API handles both checking file existence and reading content with less overhead than Node's implementation.
Embracing Bun's Glob API
File searching was another area where Bun offered substantial improvements. I replaced complex recursive directory traversal with Bun's native Glob API:
Before (Node.js recursion):
async function getMarkdownFilesRecursively(dir: string): Promise<string[]> {
const entries = await fs.readdir(dir, { withFileTypes: true });
const files = await Promise.all(entries.map(async (entry) => {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
return getMarkdownFilesRecursively(fullPath);
} else if (entry.isFile() && entry.name.endsWith('.md')) {
return [fullPath];
}
return [];
}));
return files.flat();
}
After (Bun's Glob API):
async function getMarkdownFilesRecursively(dir: string): Promise<string[]> {
const glob = new Bun.Glob("**/*.md");
const files: string[] = [];
for await (const file of glob.scan({
cwd: dir,
absolute: true
})) {
files.push(file);
}
return files;
}
The Bun implementation is not only more efficient but also dramatically simpler - the code is easier to read and maintain, with fewer potential edge cases to worry about.
Switching from YAML to TOML
Another benefit was transitioning from YAML to TOML for configuration files. Bun supports TOML out of the box, and I find TOML more readable for structured data. My tags configuration now looks like this:
# src/tags.toml
ai = "In-depth analysis of artificial intelligence and its transformative impact across industries."
"ai assistant" = "Comprehensive exploration of AI-powered digital assistants transforming productivity and workflows."
# ...and so on
This replaces a more verbose YAML structure and eliminates a dependency while improving readability.
The Migration Journey: Step by Step
The migration process was more methodical than I initially expected:
Phase 1: Package Management (April 28)
The first step was transitioning from npm to Bun's package manager:
// Before (package.json scripts)
"scripts": {
"start": "ts-node src/index.ts",
"build": "tsc",
"dev": "nodemon --watch src --exec ts-node src/index.ts",
// ...other scripts
}
// After
"scripts": {
"start": "bun src/index.ts",
"build": "bun build ./src/index.ts --outdir ./dist --target=bun",
"dev": "bun --watch src/index.ts",
// ...other scripts using 'bun' instead of 'ts-node'
}
This simple change immediately sped up command execution, as Bun runs TypeScript files directly without a separate compilation step.
Phase 2: Build Configuration
Next came transitioning the build process from TypeScript's compiler (tsc
) to Bun's native bundler:
// bun.config.js - Replacing complex webpack configuration
export default {
// Bun's built-in CSS processing
};
The simplicity is striking - Bun's sensible defaults eliminated dozens of lines of configuration.
Phase 3: CI Integration
For continuous integration, I updated GitHub Actions to use Bun:
# .github/workflows/ci.yml
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: oven-sh/setup-bun@v1
with:
bun-version: 1.2.11
- run: bun install
- run: bun run build
- run: bun run typecheck
This also eliminated package-lock.json (4,500+ lines removed!) in favor of bun.lock.
Phase 4: Refactoring File Operations
The most impactful changes came from converting Node's fs operations to Bun's native File API.
The Secret to Bun's Speed
After completing the migration, I wanted to understand why Bun delivers such impressive performance gains. There are several key factors:
- JavaScriptCore engine: Bun uses Apple's JSC engine, which is typically faster than V8 for many operations
- Native implementations: File, HTTP, and other core APIs are written in Zig rather than JavaScript, eliminating layers of abstraction
- Optimized I/O: Bun's native file operations bypass Node's libuv layer
- Single binary: All tools (runtime, bundler, package manager) share the same process, eliminating inter-process communication overhead
- TypeScript performance: Built-in TypeScript support without separate compilation steps
- Zero-copy architecture: Bun's design minimizes memory copies, which is especially beneficial for file operations
- Streamlined dependency handling: Bun's dependency resolution is faster and more efficient than npm
These architectural differences add up to significant performance improvements, especially for I/O-intensive tasks like static site generation.
Using Claude Code to Drive the Migration
I used Claude Code extensively to help with this migration, asking it to identify file system operations that could be replaced with Bun's native APIs. The most effective approach was:
- Having Claude identify patterns like
fs.readFile
andfs.pathExists
across the codebase - Creating a migration plan for each file based on usage patterns
- Asking for side-by-side rewrites with explanations of the Bun-specific benefits
- Gradually applying these changes file by file
Claude was particularly helpful for understanding the Bun-specific implementation details and suggesting optimizations I wouldn't have considered, like using arrayBuffer()
for binary file operations.
Beyond File Operations: TOML and Cleaned Dependencies
A less obvious but equally important aspect of the migration was transitioning from YAML to TOML for configuration. Bun has built-in TOML support, which allowed me to eliminate additional dependencies.
I also took the opportunity to clean up unnecessary dependencies:
// Removed from dependencies
- "sass": "^1.72.0"
- "sass-loader": "^13.3.2"
// Specialized for Bun
+ "@types/bun": "latest"
Challenges and Limitations
While the migration has been largely positive, there are a few downsides worth mentioning:
- Ecosystem maturity: Not all Node packages are fully compatible with Bun yet
- Documentation gaps: While improving rapidly, Bun's docs aren't as comprehensive as Node's
- Error messages: Sometimes Bun's error reporting is less informative than Node's
- Native extensions: Some C/C++ native extensions might not work with Bun out of the box
Despite these challenges, the benefits have far outweighed the drawbacks for my use case.
The Results: Faster Builds, Cleaner Code
The most tangible outcome has been build performance. My site now builds in approximately 20 seconds with Bun, compared to 37 seconds with Node - a 46% improvement. This makes the development cycle much more pleasant, especially when previewing content changes.
Some specific performance metrics worth highlighting:
Operation | Node.js | Bun | Improvement |
---|---|---|---|
Cold start | 1.2s | 0.3s | 75% faster |
Build time | 37s | 20s | 46% faster |
File operations | Moderate | Very fast | ~3-4x faster |
TypeScript execution | Requires transpilation | Native support | Significant |
Beyond raw performance, the codebase is now cleaner, with fewer dependencies and more consistent patterns throughout. The migration forced me to reconsider implementation details I'd taken for granted, leading to better solutions across the board.
Here's a before/after comparison of the codebase:
Metric | Before (Node) | After (Bun) | Change |
---|---|---|---|
Dependencies | 20+ | 15 | 25% reduction |
Configuration files | Multiple complex files | Minimal configs | Significantly simpler |
Boilerplate code | Considerable | Minimal | Much cleaner |
File system code | Complex | Direct and simple | Easier to maintain |
Should You Switch to Bun?
If you're working on a project that could benefit from improved build times, simpler tooling, or more modern APIs, Bun is definitely worth considering. It's particularly well-suited for:
- Static site generators and content-heavy applications
- TypeScript projects that want to eliminate compilation overhead
- Environments where build performance is a bottleneck
- New projects that can fully embrace Bun's ecosystem
- Applications with significant file I/O operations
For existing projects, consider a gradual migration approach, starting with build tooling and then moving to runtime code as you validate compatibility. My own migration followed this path:
- Start with package management and simple commands
- Progress to build configuration
- Update CI/CD pipelines
- Refactor file operations to use native APIs
- Clean up dependencies and refine configs
The JavaScript ecosystem continues to evolve rapidly, and Bun represents an exciting step forward in both performance and developer experience. My migration has been a clear success, and I'm looking forward to leveraging more of Bun's capabilities as the platform continues to mature.
If you're curious about Bun, start small - try it for a specific task or script before committing to a full migration. The official documentation is improving rapidly, and the growing community provides valuable support for those making the transition from Node.js.