Node.js vs Bun: What Actually Changes When You Switch?
If you've been building JavaScript or TypeScript backends for a while, you've probably seen the same Node.js vs Bun comparison a hundred times. "Bun is faster." "Bun has a built-in package manager." "Bun can run TypeScript." "Node has a bigger ecosystem." All true. But none of those answers really explain what happens when you take an actual TypeScript backend and move it from Node.js to Bun.
The interesting differences show up in places benchmarks usually don't cover:
- Module resolution
- TypeScript execution
- Web APIs
- HTTP servers
- Streams
- WebSockets
- Workers
- Child processes
- Native dependencies
- Testing
- Environment variables
- Docker
- Graceful shutdown
- Package compatibility
- Production debugging
Choosing a runtime isn't just choosing a faster JavaScript engine. You're choosing an entire runtime ecosystem.
Node.js and Bun Are Solving a Bigger Problem Than "Running JavaScript"
At the simplest level, both runtimes execute JavaScript and TypeScript. But the runtime sits underneath almost everything your application does. Think about a backend:
Your Application
+------------------+------------------+
| | |
| HTTP | Database |
| Filesystem | WebSocket |
| Redis | Processes |
| | |
+------------------+------------------+
|
Runtime
|
OS
The runtime controls or influences how all of these pieces work. Node.js is built around Google's V8 JavaScript engine. Bun uses JavaScriptCore, the engine developed for WebKit. But the JavaScript engine isn't the whole story. The runtime also provides APIs for networking, files, processes, streams, workers, environment variables, and more. That's where the practical differences start appearing.
Running TypeScript Is Not the Same as Type-Checking
This is one of the first things people misunderstand about Bun. With Node.js, you generally need a tool to execute TypeScript. For example:
// src/index.ts
const port: number = 3000;
console.log(`Starting server on port ${port}`);
You might run it with a tool such as tsx. With Bun:
// src/index.ts
const port: number = 3000;
console.log(`Starting server on port ${port}`);
You can execute it directly with bun run src/index.ts. That's great. But there's an important distinction. Bun executing TypeScript does not mean Bun has replaced TypeScript's type checker.
I still want this in my project: tsc --noEmit. For example:
{
"scripts": {
"dev": "bun --watch src/index.ts",
"typecheck": "tsc --noEmit",
"test": "bun test"
}
}
So my mental model is:
- Bun: Execute TypeScript, Run tests, Install dependencies, Build/bundle
- TypeScript: Static type checking
They're different jobs. I wouldn't remove tsc from a serious TypeScript codebase just because the runtime can execute .ts files.
HTTP Servers Feel Very Different
This is one of the most obvious differences. A basic Node.js HTTP server:
import { createServer } from "node:http";
const server = createServer((request, response) => {
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({ message: "Hello from Node.js" }));
});
server.listen(3000, () => {
console.log("Server running on http://localhost:3000");
});
The API is very Node-specific. You have IncomingMessage and ServerResponse. Now look at Bun:
const server = Bun.serve({
port: 3000,
fetch(_request) {
return Response.json({ message: "Hello from Bun" });
},
});
console.log(`Server running on http://localhost:${server.port}`);
Bun leans heavily into Web APIs: Request, Response, Headers, fetch(), URL, WebSocket. That matters because these APIs are increasingly common across modern runtimes. If you've worked with Cloudflare Workers, Deno, edge runtimes, or serverless platforms, the Bun approach can feel much more familiar.
This Is Where Elysia Becomes Interesting
If you're building APIs with Bun, you've probably come across Elysia. A basic Elysia application:
import { Elysia } from "elysia";
const app = new Elysia()
.get("/", () => {
return { message: "Hello from Elysia" };
})
.listen(3000);
console.log(`Server running on http://localhost:${app.server?.port}`);
Now compare that to Express:
import express from "express";
const app = express();
app.get("/", (_request, response) => {
response.json({ message: "Hello from Express" });
});
app.listen(3000, () => {
console.log("Server running on http://localhost:3000");
});
The important difference isn't that one has fewer lines. It's the philosophy. Express is intentionally minimal. Elysia is much more opinionated around TypeScript, schemas, validation, and type inference.
Type Inference Becomes a First-Class Part of Your API
Consider this Elysia route:
import { Elysia, t } from "elysia";
const app = new Elysia()
.post(
"/users",
({ body }) => {
return { message: "User created", user: body };
},
{
body: t.Object({
name: t.String(),
email: t.String(),
age: t.Number(),
}),
},
)
.listen(3000);
The schema describes the request body. And TypeScript understands the resulting type. You don't have to manually create a separate interface and then separately configure runtime validation. That's a big deal in larger APIs.
With a traditional approach, you might end up with:
interface CreateUserInput {
name: string;
email: string;
age: number;
}
Then separately:
const createUserSchema = z.object({
name: z.string(),
email: z.string(),
age: z.number(),
});
Then separately:
const validatedBody = createUserSchema.parse(body);
That's not inherently bad. Libraries like Zod are excellent. But Elysia's approach makes the schema itself part of the framework's type system.
WebSockets Are Another Interesting Difference
Node doesn't provide a high-level WebSocket server API out of the box. You typically bring in a library or use framework integrations. Bun has WebSocket support built into the runtime:
const server = Bun.serve({
port: 3000,
fetch(request, server) {
if (server.upgrade(request, { data: { connectedAt: Date.now() } })) {
return;
}
return new Response("WebSocket upgrade required", { status: 426 });
},
websocket: {
open(socket) {
socket.send("Connected");
},
message(socket, message) {
socket.send(`Echo: ${message}`);
},
close(socket) {
console.log("Client disconnected");
},
},
});
console.log(`WebSocket server running on ${server.port}`);
There's no separate WebSocket package here. The runtime knows what a WebSocket is. That's a recurring Bun pattern.
File I/O Gets a Much Cleaner API
Node:
import { readFile, writeFile } from "node:fs/promises";
const content = await readFile("./config.json", "utf8");
await writeFile("./output.txt", content);
Bun:
const file = Bun.file("./config.json");
const content = await file.text();
await Bun.write("./output.txt", content);
You can also inspect the file:
const file = Bun.file("./config.json");
console.log(file.size);
console.log(file.type);
const content = await file.text();
This is one of those APIs that isn't revolutionary. But when you use it hundreds of times across scripts and services, simplicity matters.
Environment Variables
Node applications commonly use:
const databaseUrl = process.env.DATABASE_URL;
Bun exposes:
const databaseUrl = Bun.env.DATABASE_URL;
You can also use the familiar process.env approach in Bun. Personally, I wouldn't let the runtime leak throughout the application. Instead:
const config = {
port: Number(process.env.PORT ?? 3000),
databaseUrl: process.env.DATABASE_URL,
redisUrl: process.env.REDIS_URL,
};
if (!config.databaseUrl) {
throw new Error("DATABASE_URL is required");
}
Then your application code doesn't care whether it is running on Node or Bun. That's a useful architectural principle: Keep runtime-specific code at the edges of your application.
Child Processes
Node:
import { exec } from "node:child_process";
exec("git status", (error, stdout) => {
if (error) {
console.error(error);
return;
}
console.log(stdout);
});
Bun:
const result = Bun.spawnSync(["git", "status"]);
console.log(result.stdout.toString());
For developer tooling, CLIs, migration scripts, and automation, this can be really convenient. You can also use asynchronous processes:
const process = Bun.spawn(["git", "status"]);
const exitCode = await process.exited;
console.log(`Process exited with ${exitCode}`);
Again, this isn't about one API being universally superior. It's about how much functionality the runtime gives you without installing another abstraction.
Testing Is Built In
Bun includes a test runner:
import { describe, expect, test } from "bun:test";
describe("User service", () => {
test("creates a user", () => {
const user = { name: "Hussain", email: "h******@example.com" };
expect(user.name).toBe("Hussain");
});
});
Run: bun test. You don't need to install a separate test runner just to get started. For a new project, this is genuinely nice.
But there's an important caveat. If your organization already has a mature Jest or Vitest setup, migrating everything to Bun's test runner isn't automatically worth doing. Built-in doesn't mean automatically better.
Package Management Is Part of the Runtime Experience
With Node, you typically choose a package manager:
-
npm -
pnpm -
Yarn
Bun includes its own: bun install. Adding a dependency: bun add elysia. Removing one: bun remove elysia. Updating: bun update.
This creates a much tighter toolchain: Runtime + Package manager + Test runner + Bundler, instead of assembling multiple tools. That's one of the reasons Bun feels particularly good for greenfield projects.
But Dependency Compatibility Is Where Things Get Real
This is probably the biggest thing I would investigate before migrating an existing Node application. Your application might have:
- 200 npm packages
- HTTP libraries
- Database drivers
- Native modules
- CLI tools
- Build tools
- Monitoring
- Authentication
Most packages will probably work. But "probably" isn't a production strategy. Some packages can depend on:
- Node-specific APIs
- Native Node addons
-
node-gyp - Specific module resolution behavior
- Undocumented runtime behavior
- Node-specific globals
A package can install successfully and still behave differently at runtime. So before migrating: bun install isn't enough. You need to actually run bun test and your integration tests. Then test:
- Database
- Redis
- Queues
- HTTP
- WebSockets
- File uploads
- Authentication
- Background jobs
- Monitoring
Your dependency tree matters more than the benchmark chart.
Native Dependencies Are Where Migrations Can Hurt
Pure JavaScript packages are generally easier. Native modules are more complicated. If a dependency includes native code, you're no longer dealing with just JavaScript compatibility. You're dealing with:
JavaScript
↓
Native binding
↓
Operating system
This is why I'd be much more cautious migrating an existing application that relies heavily on native Node packages. For a new project where you control the dependencies? Much easier.
Node's Ecosystem Is Still a Huge Advantage
This shouldn't be understated. Node.js has been around for years. There are:
- Massive community resources
- Mature libraries
- Monitoring integrations
- A huge ecosystem
- Established deployment patterns
- Tons of production experience
If you hit a weird Node problem, there's a very good chance somebody has already hit it. That's valuable. "Modern" doesn't automatically mean "better." Sometimes maturity is the feature.
Docker Is Another Practical Difference
A Node application might use:
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["node", "dist/index.js"]
A Bun application can be:
FROM oven/bun:1
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
CMD ["bun", "src/index.ts"]
For a TypeScript backend, the second approach can be pleasantly simple. But don't forget the production questions:
- Does your hosting platform support Bun?
- Does your monitoring agent support Bun?
- Does your CI environment support it?
- Do your database drivers work correctly?
- Do your health checks work?
- Do your shutdown signals behave correctly?
The runtime is only one part of the deployment.
Graceful Shutdown Still Matters
Here's something benchmarks almost never talk about. Your server needs to shut down correctly. For Node:
import { createServer } from "node:http";
const server = createServer();
server.listen(3000);
const shutdown = async () => {
console.log("Shutting down...");
server.close(() => {
console.log("HTTP server closed");
process.exit(0);
});
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
Why care? Because production environments terminate processes. Containers restart. Deployments happen. Machines go down. And your application may have:
- HTTP server
- Redis connection
- PostgreSQL connection
- BullMQ workers
- WebSocket clients
- Background jobs
You don't want to kill all of that instantly. A runtime migration that passes unit tests but breaks graceful shutdown is not a successful migration.
Streams Are Another Place Where the Difference Matters
Node has had streams for a very long time. For example:
import { createReadStream } from "node:fs";
const stream = createReadStream("./large-file.json");
stream.on("data", (chunk) => {
console.log(`Received ${chunk.length} bytes`);
});
Modern JavaScript also has Web Streams:
const response = await fetch("https://example.com/large-file");
const reader = response.body?.getReader();
if (!reader) {
Comments
No comments yet. Start the discussion.