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
I've worked with both runtimes, and the biggest lesson is this:
Choosing a runtime isn't just choosing a faster JavaScript engine. You're choosing an entire runtime ecosystem.
Let's get into the parts that actually matter.
1. 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.
2. Running TypeScript is not the same as type-checking TypeScript
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.
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
while:
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.
3. 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
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
- Serverless platforms
the Bun approach can feel much more familiar.
4. 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.
5. 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.
6. 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.
7. 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.
8. 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.
9. 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.
10. 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: "hussain@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.
11. 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.
12. 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.
13. 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.
14. 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.
15. 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.
16. 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.
17. 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) {
throw new Error("Response body is unavailable");
}
while (true) {
const { done, value } =
await reader.read();
if (done) {
break;
}
console.log(
`Received ${value.length} bytes`,
);
}
Bun's APIs lean heavily toward modern Web APIs.
This can make interoperability with fetch, Request, Response, and Web Streams feel more natural.
But older Node applications may have a lot of Node-specific stream code.
That's another migration consideration.
18. Workers and concurrency
Neither runtime magically makes JavaScript multithreaded.
Your application still has a primary event loop.
For CPU-heavy work, you need a strategy.
Node provides:
import {
Worker,
} from "node:worker_threads";
For example:
import {
Worker,
} from "node:worker_threads";
const worker = new Worker(
"./worker.ts",
);
worker.on("message", (result) => {
console.log("Result:", result);
});
worker.postMessage({
operation: "calculate",
value: 1000000,
});
The lesson here isn't:
"Bun is faster, therefore CPU-heavy work becomes free."
It doesn't.
If you're processing:
- large images
- video
- cryptography
- huge JSON documents
- CPU-heavy algorithms
you still need to think about concurrency and workload isolation.
19. Benchmarking can be extremely misleading
Let's say someone posts:
Node.js: 100k requests/sec
Bun: 300k requests/sec
Looks impressive.
But what does the benchmark actually do?
Maybe:
return new Response("OK");
That's not your production application.
Your actual endpoint might do:
HTTP request
↓
JWT verification
↓
Validation
↓
PostgreSQL
↓
Redis
↓
External API
↓
Business logic
↓
Logging
↓
Response
Now your bottleneck might be PostgreSQL.
Or Redis.
Or an external API.
Or network latency.
Or JSON serialization.
Or an AI provider.
If your request takes 700ms because an external API takes 500ms, the difference between two runtimes might be almost irrelevant to your users.
So benchmark your workload.
Not somebody else's synthetic benchmark.
20. Where Bun actually shines for me
For a new TypeScript backend, Bun has a really attractive combination:
TypeScript
+
Runtime
+
Package manager
+
Testing
+
Bundling
+
Web APIs
You can start a project without immediately installing ten different tools.
And if you're using a TypeScript-first framework such as Elysia, the pieces fit together naturally.
A simple stack can look like:
Bun
|
+-- Elysia
|
+-- Drizzle
|
+-- PostgreSQL
|
+-- Redis
|
+-- TypeScript
That's a pretty clean backend stack.
21. Where Node still makes more sense
I'd choose Node without hesitation when:
You're maintaining a mature application
If the application already works and has a large dependency tree, the migration cost needs to be justified.
You depend on Node-specific packages
Compatibility becomes a real concern.
Your infrastructure is deeply standardized around Node
If your CI, monitoring, deployment, and operations are already built around Node, changing the runtime has a cost.
You need maximum ecosystem compatibility
This is probably Node's biggest advantage.
22. Where I'd seriously consider Bun
For a new project:
New TypeScript API
↓
Bun
↓
Elysia
↓
Drizzle
↓
PostgreSQL
I'd absolutely consider it.
Especially if:
- I control the dependencies
- The team is comfortable with it
- The deployment platform supports it
- The project benefits from Bun's integrated tooling
- I've tested the production workload
That's a much more useful decision framework than:
"Bun is faster."
23. The architecture matters more than the runtime
This is probably the biggest lesson.
A badly designed Bun application is still a badly designed application.
A well-designed Node application can be extremely fast and reliable.
You can write:
Bun
↓
terrible architecture
↓
slow database queries
↓
blocking CPU work
↓
bad caching
↓
production disaster
Or:
Node
↓
good architecture
↓
proper caching
↓
efficient queries
↓
queues
↓
horizontal scaling
↓
happy users
The runtime doesn't save bad engineering.
24. So, Node.js or Bun?
I don't think the right answer is:
"Bun replaces Node."
And I don't think the right answer is:
"Node is mature, so never use anything else."
The better question is:
What does this particular application need?
If you're starting a new TypeScript service, Bun is absolutely worth evaluating.
If you're migrating a large production Node application, I'd start with dependency compatibility and operational concerns, not benchmarks.
And if your current Node application is working perfectly?
You might not need to migrate at all.
That's an important answer too.
Final thoughts
The biggest difference between Node.js and Bun isn't:
Node = slow
Bun = fast
It's more like:
Node.js
|
+-- Mature ecosystem
+-- Huge compatibility surface
+-- Established production tooling
+-- Extremely battle-tested
Bun
|
+-- Modern runtime APIs
+-- Integrated tooling
+-- TypeScript-friendly workflow
+-- Built-in testing
+-- Built-in WebSocket support
+-- Fast startup and execution
Neither one eliminates the need for good architecture.
Neither one eliminates the need for profiling.
Neither one eliminates the need for integration tests.
And neither one should be selected purely because someone posted a benchmark on Twitter.
The runtime is part of your architecture. Treat it like an architectural decision, not a popularity contest.
What about Express vs Elysia?
That's actually the comparison I'd make next.
Because once you get past the runtime, the framework choice gets even more interesting.
Express gives you decades of ecosystem maturity and an incredibly flexible middleware model.
Elysia takes a much more TypeScript-first approach with schemas, inference, validation, and Bun integration.
That comparison deserves its own deep dive.
Express vs Elysia: routing, middleware, validation, type inference, error handling, plugins, WebSockets, performance, and production architecture.
If you're building TypeScript backends in 2026, that's a conversation worth having.
Top comments (0)