DEV Community

Cover image for Node.js vs Bun: What Actually Changes When You Switch?
Hussain Ahmad
Hussain Ahmad

Posted on

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

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
Enter fullscreen mode Exit fullscreen mode

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}`);
Enter fullscreen mode Exit fullscreen mode

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}`);
Enter fullscreen mode Exit fullscreen mode

You can execute it directly with Bun.

bun run src/index.ts
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

For example:

{
  "scripts": {
    "dev": "bun --watch src/index.ts",
    "typecheck": "tsc --noEmit",
    "test": "bun test"
  }
}
Enter fullscreen mode Exit fullscreen mode

So my mental model is:

Bun
  |
  +-- Execute TypeScript
  |
  +-- Run tests
  |
  +-- Install dependencies
  |
  +-- Build/bundle
Enter fullscreen mode Exit fullscreen mode

while:

TypeScript
  |
  +-- Static type checking
Enter fullscreen mode Exit fullscreen mode

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");
});
Enter fullscreen mode Exit fullscreen mode

The API is very Node-specific.

You have:

IncomingMessage
ServerResponse
Enter fullscreen mode Exit fullscreen mode

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}`,
);
Enter fullscreen mode Exit fullscreen mode

Bun leans heavily into Web APIs:

Request
Response
Headers
fetch()
URL
WebSocket
Enter fullscreen mode Exit fullscreen mode

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}`,
);
Enter fullscreen mode Exit fullscreen mode

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");
});
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Then separately:

const createUserSchema = z.object({
  name: z.string(),
  email: z.string(),
  age: z.number(),
});
Enter fullscreen mode Exit fullscreen mode

Then separately:

const validatedBody = createUserSchema.parse(body);
Enter fullscreen mode Exit fullscreen mode

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}`);
Enter fullscreen mode Exit fullscreen mode

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,
);
Enter fullscreen mode Exit fullscreen mode

Bun:

const file = Bun.file("./config.json");

const content = await file.text();

await Bun.write(
  "./output.txt",
  content,
);
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Bun exposes:

const databaseUrl = Bun.env.DATABASE_URL;
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

Bun:

const result = Bun.spawnSync([
  "git",
  "status",
]);

console.log(
  result.stdout.toString(),
);
Enter fullscreen mode Exit fullscreen mode

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}`);
Enter fullscreen mode Exit fullscreen mode

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");
  });
});
Enter fullscreen mode Exit fullscreen mode

Run:

bun test
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Bun includes its own:

bun install
Enter fullscreen mode Exit fullscreen mode

Adding a dependency:

bun add elysia
Enter fullscreen mode Exit fullscreen mode

Removing one:

bun remove elysia
Enter fullscreen mode Exit fullscreen mode

Updating:

bun update
Enter fullscreen mode Exit fullscreen mode

This creates a much tighter toolchain:

Runtime
   +
Package manager
   +
Test runner
   +
Bundler
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

isn't enough.

You need to actually run:

bun test
Enter fullscreen mode Exit fullscreen mode

and your integration tests.

Then test:

Database
Redis
Queues
HTTP
WebSockets
File uploads
Authentication
Background jobs
Monitoring
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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`,
  );
});
Enter fullscreen mode Exit fullscreen mode

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`,
  );
}
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Looks impressive.

But what does the benchmark actually do?

Maybe:

return new Response("OK");
Enter fullscreen mode Exit fullscreen mode

That's not your production application.

Your actual endpoint might do:

HTTP request
      ↓
JWT verification
      ↓
Validation
      ↓
PostgreSQL
      ↓
Redis
      ↓
External API
      ↓
Business logic
      ↓
Logging
      ↓
Response
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Or:

Node
 ↓
good architecture
 ↓
proper caching
 ↓
efficient queries
 ↓
queues
 ↓
horizontal scaling
 ↓
happy users
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)