Llevas años trabajando con Node.js y de repente aparece Bun prometiendo ser tres veces más rápido. Lo primero que piensas es «ya veremos». Yo también lo pensé. Pero después de usarlo en proyectos reales durante varios meses, tengo que admitir que la promesa se cumple.

Bun no es solo un runtime. Es un ecosistema completo: gestor de paquetes, bundler, test runner y servidor HTTP, todo en un solo binario escrito en Zig. Esta guía te lleva desde la instalación hasta tener una API REST funcional, pasando por todo lo que necesitas saber para adoptarlo en 2026.

Si ya conoces Node.js y TypeScript, vas a sentirte cómodo enseguida. Bun mantiene compatibilidad con la API de Node y soporta TypeScript de forma nativa, sin configuración adicional.

¿Qué es Bun y por qué importa en 2026?

Bun es un runtime de JavaScript construido sobre el motor JavaScriptCore de WebKit (el mismo que usa Safari), a diferencia de Node.js que usa V8 (Chrome). Esto, combinado con que está escrito en Zig en lugar de C++, le da una ventaja de rendimiento significativa en operaciones de I/O y arranque.

Los benchmarks de la comunidad muestran consistentemente que Bun arranca entre 3 y 5 veces más rápido que Node.js en aplicaciones simples, y su servidor HTTP nativo es considerablemente más rápido que Express para cargas de trabajo típicas. En producción, los números dependen mucho del tipo de aplicación, pero la diferencia es real y medible.

Lo que más me sorprendió no fue la velocidad, sino eliminar la fricción del tooling. Con Bun corres TypeScript sin compilar, sin ts-node, sin esbuild preconfigurado. Solo bun run archivo.ts y funciona.

Instalación y primeros pasos

La instalación es directa. En Linux y macOS:

curl -fsSL https://bun.sh/install | bash

En Windows puedes usar PowerShell:

powershell -c "irm bun.sh/install.ps1 | iex"

Tras la instalación, verifica que todo funciona:

bun --version
# 1.x.x

bun upgrade  # para mantenerlo actualizado

Crea tu primer proyecto:

mkdir mi-api-bun
cd mi-api-bun
bun init

El comando bun init te pregunta el nombre del proyecto y genera un package.json, un tsconfig.json y un archivo de entrada index.ts. Todo listo para TypeScript sin instalar nada más.

El servidor HTTP nativo de Bun

Bun incluye un servidor HTTP integrado en su API global. No necesitas Express, Fastify ni ninguna librería externa para levantar un servidor funcional. Aquí tienes el servidor más simple posible:

// index.ts
const server = Bun.serve({
  port: 3000,
  fetch(request) {
    const url = new URL(request.url);
    
    if (url.pathname === "/") {
      return new Response("Hola desde Bun!", {
        headers: { "Content-Type": "text/plain" },
      });
    }

    return new Response("Not Found", { status: 404 });
  },
});

console.log(`Servidor corriendo en http://localhost:${server.port}`);

Ejecútalo con bun run index.ts. No hay compilación, no hay proceso previo. Bun lee el TypeScript directamente.

Lo que me gusta de esta API es que usa los estándares web modernos: Request, Response y URL son las mismas APIs que usarías en el navegador o en un Service Worker. Si conoces la Fetch API, ya sabes cómo funciona el servidor de Bun.

Construyendo una API REST completa

Vamos a construir algo más realista: una API de gestión de tareas con operaciones CRUD. Primero la estructura del proyecto:

mi-api-bun/
├── src/
│   ├── routes/
│   │   └── tareas.ts
│   ├── data/
│   │   └── store.ts
│   └── types.ts
├── index.ts
└── package.json

Definimos los tipos en src/types.ts:

// src/types.ts
export interface Tarea {
  id: string;
  titulo: string;
  completada: boolean;
  creadaEn: Date;
}

export type CrearTareaDTO = Pick<Tarea, "titulo">;
export type ActualizarTareaDTO = Partial<Pick<Tarea, "titulo" | "completada">>;

Un store en memoria para no depender de base de datos en este ejemplo:

// src/data/store.ts
import type { Tarea } from "../types";

export const tareas: Map<string, Tarea> = new Map();

// Datos iniciales para probar
tareas.set("1", {
  id: "1",
  titulo: "Aprender Bun",
  completada: false,
  creadaEn: new Date(),
});

tareas.set("2", {
  id: "2",
  titulo: "Migrar proyecto de Node",
  completada: false,
  creadaEn: new Date(),
});

Las rutas de la API:

// src/routes/tareas.ts
import { tareas } from "../data/store";
import type { CrearTareaDTO, ActualizarTareaDTO } from "../types";

function jsonResponse(data: unknown, status = 200) {
  return new Response(JSON.stringify(data), {
    status,
    headers: { "Content-Type": "application/json" },
  });
}

export async function handleTareas(request: Request): Promise<Response | null> {
  const url = new URL(request.url);
  const method = request.method;

  // GET /tareas - Obtener todas
  if (url.pathname === "/tareas" && method === "GET") {
    const lista = Array.from(tareas.values());
    return jsonResponse(lista);
  }

  // GET /tareas/:id - Obtener una
  const matchGet = url.pathname.match(/^/tareas/(.+)$/);
  if (matchGet && method === "GET") {
    const tarea = tareas.get(matchGet[1]);
    if (!tarea) return jsonResponse({ error: "Tarea no encontrada" }, 404);
    return jsonResponse(tarea);
  }

  // POST /tareas - Crear
  if (url.pathname === "/tareas" && method === "POST") {
    const body: CrearTareaDTO = await request.json();
    if (!body.titulo) return jsonResponse({ error: "El título es requerido" }, 400);

    const nueva = {
      id: crypto.randomUUID(),
      titulo: body.titulo,
      completada: false,
      creadaEn: new Date(),
    };
    tareas.set(nueva.id, nueva);
    return jsonResponse(nueva, 201);
  }

  // PUT /tareas/:id - Actualizar
  const matchPut = url.pathname.match(/^/tareas/(.+)$/);
  if (matchPut && method === "PUT") {
    const tarea = tareas.get(matchPut[1]);
    if (!tarea) return jsonResponse({ error: "Tarea no encontrada" }, 404);

    const body: ActualizarTareaDTO = await request.json();
    const actualizada = { ...tarea, ...body };
    tareas.set(actualizada.id, actualizada);
    return jsonResponse(actualizada);
  }

  // DELETE /tareas/:id - Eliminar
  const matchDelete = url.pathname.match(/^/tareas/(.+)$/);
  if (matchDelete && method === "DELETE") {
    const existe = tareas.has(matchDelete[1]);
    if (!existe) return jsonResponse({ error: "Tarea no encontrada" }, 404);
    tareas.delete(matchDelete[1]);
    return new Response(null, { status: 204 });
  }

  return null; // No es una ruta de tareas
}

Y el servidor principal que lo une todo:

// index.ts
import { handleTareas } from "./src/routes/tareas";

const server = Bun.serve({
  port: 3000,

  async fetch(request) {
    const url = new URL(request.url);

    // CORS básico
    const corsHeaders = {
      "Access-Control-Allow-Origin": "*",
      "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
      "Access-Control-Allow-Headers": "Content-Type",
    };

    if (request.method === "OPTIONS") {
      return new Response(null, { status: 204, headers: corsHeaders });
    }

    // Intentar manejar rutas de tareas
    const tareaResponse = await handleTareas(request);
    if (tareaResponse) {
      // Añadir CORS headers a la respuesta existente
      const headers = new Headers(tareaResponse.headers);
      Object.entries(corsHeaders).forEach(([k, v]) => headers.set(k, v));
      return new Response(tareaResponse.body, {
        status: tareaResponse.status,
        headers,
      });
    }

    // Ruta raíz
    if (url.pathname === "/") {
      return Response.json({
        mensaje: "API de Tareas con Bun",
        version: "1.0.0",
        endpoints: ["/tareas"],
      });
    }

    return Response.json({ error: "Ruta no encontrada" }, { status: 404 });
  },
});

console.log(`🚀 API corriendo en http://localhost:${server.port}`);

Gestión de paquetes con Bun

El gestor de paquetes de Bun es compatible con npm pero mucho más rápido. Instalar dependencias de un proyecto mediano pasa de 20-30 segundos con npm a 2-3 segundos con Bun. La diferencia se nota especialmente en CI/CD.

Los comandos son prácticamente los mismos que npm:

# Instalar dependencias
bun install

# Añadir una dependencia
bun add hono
bun add -d @types/node

# Eliminar
bun remove hono

# Ejecutar scripts del package.json
bun run dev
bun run build

# Ejecutar directamente sin definir script
bun run src/index.ts

Bun genera un archivo bun.lockb en lugar de package-lock.json. Es binario (de ahí la extensión .lockb), lo que lo hace mucho más rápido de leer y escribir. Commitéalo en tu repositorio igual que harías con el lockfile de npm.

Una ventaja que uso mucho: puedes ejecutar un script de npm directamente con bun x, equivalente a npx:

# Equivalente a npx create-next-app
bun x create-next-app@latest mi-proyecto

# Ejecutar un binario local
bun x tsc --noEmit

Testing integrado sin dependencias

Una de mis funcionalidades favoritas de Bun es el test runner integrado. Tiene una API compatible con Jest, así que si ya tienes tests con Jest los puedes correr con Bun sin cambiar nada (o casi nada).

Crea un archivo de test para nuestra API:

// src/routes/tareas.test.ts
import { describe, it, expect, beforeEach } from "bun:test";
import { tareas } from "../data/store";
import { handleTareas } from "./tareas";

function makeRequest(method: string, path: string, body?: unknown): Request {
  return new Request(`http://localhost:3000${path}`, {
    method,
    headers: { "Content-Type": "application/json" },
    body: body ? JSON.stringify(body) : undefined,
  });
}

describe("API de Tareas", () => {
  beforeEach(() => {
    tareas.clear();
    tareas.set("test-1", {
      id: "test-1",
      titulo: "Tarea de prueba",
      completada: false,
      creadaEn: new Date(),
    });
  });

  it("GET /tareas devuelve lista de tareas", async () => {
    const req = makeRequest("GET", "/tareas");
    const res = await handleTareas(req);
    
    expect(res).not.toBeNull();
    expect(res!.status).toBe(200);
    
    const data = await res!.json();
    expect(Array.isArray(data)).toBe(true);
    expect(data).toHaveLength(1);
  });

  it("POST /tareas crea una nueva tarea", async () => {
    const req = makeRequest("POST", "/tareas", { titulo: "Nueva tarea" });
    const res = await handleTareas(req);

    expect(res!.status).toBe(201);
    const data = await res!.json();
    expect(data.titulo).toBe("Nueva tarea");
    expect(data.completada).toBe(false);
    expect(data.id).toBeDefined();
  });

  it("POST /tareas sin título devuelve 400", async () => {
    const req = makeRequest("POST", "/tareas", {});
    const res = await handleTareas(req);

    expect(res!.status).toBe(400);
  });

  it("DELETE /tareas/:id elimina la tarea", async () => {
    const req = makeRequest("DELETE", "/tareas/test-1");
    const res = await handleTareas(req);

    expect(res!.status).toBe(204);
    expect(tareas.has("test-1")).toBe(false);
  });

  it("GET /tareas/:id con id inexistente devuelve 404", async () => {
    const req = makeRequest("GET", "/tareas/no-existe");
    const res = await handleTareas(req);

    expect(res!.status).toBe(404);
  });
});

Para correr los tests:

bun test

# Con watch mode para desarrollo
bun test --watch

# Filtrar tests por nombre
bun test --test-name-pattern "POST"

# Ver cobertura
bun test --coverage

Los tests son notablemente más rápidos que con Jest. Un suite de 50 tests que tardaba 4 segundos con Jest tarda menos de 1 segundo con Bun. Para proyectos con muchos tests, esto marca diferencia en el feedback loop de desarrollo.

Variables de entorno y configuración

Bun carga automáticamente los archivos .env, .env.local, .env.production y similares sin necesidad de instalar dotenv. Es una de esas pequeñas cosas que simplifican el día a día.

# .env
PORT=3000
NODE_ENV=development
DB_URL=sqlite:./data.db
JWT_SECRET=mi-secreto-super-seguro

Acceder a las variables en el código es igual que siempre:

// index.ts
const PORT = Number(process.env.PORT) || 3000;
const IS_DEV = process.env.NODE_ENV === "development";

console.log(`Entorno: ${process.env.NODE_ENV}`);

const server = Bun.serve({
  port: PORT,
  fetch(request) {
    return Response.json({ status: "ok", env: IS_DEV ? "dev" : "prod" });
  },
});

También puedes usar la API de Bun para acceder a las variables con tipado:

// Bun expone las variables directamente
const port = Bun.env.PORT ?? "3000";
const dbUrl = Bun.env.DB_URL;

if (!dbUrl) {
  throw new Error("DB_URL no está definida en las variables de entorno");
}

Usando Hono con Bun para un routing más limpio

El router manual que construimos antes funciona, pero para proyectos más grandes conviene usar un framework de routing. Hono es la opción más popular del ecosistema Bun: es ultraligero, usa la misma API de Request/Response estándar y está diseñado para funcionar perfectamente con Bun.

bun add hono

Reescribamos la API de tareas con Hono:

// index.ts con Hono
import { Hono } from "hono";
import { cors } from "hono/cors";
import { logger } from "hono/logger";

const app = new Hono();

// Middlewares globales
app.use("*", cors());
app.use("*", logger());

// Datos en memoria
interface Tarea {
  id: string;
  titulo: string;
  completada: boolean;
}

const tareas = new Map<string, Tarea>();

// Rutas
app.get("/tareas", (c) => {
  return c.json(Array.from(tareas.values()));
});

app.get("/tareas/:id", (c) => {
  const tarea = tareas.get(c.req.param("id"));
  if (!tarea) return c.json({ error: "No encontrada" }, 404);
  return c.json(tarea);
});

app.post("/tareas", async (c) => {
  const body = await c.req.json<{ titulo: string }>();
  if (!body.titulo) return c.json({ error: "Título requerido" }, 400);

  const nueva: Tarea = {
    id: crypto.randomUUID(),
    titulo: body.titulo,
    completada: false,
  };
  tareas.set(nueva.id, nueva);
  return c.json(nueva, 201);
});

app.put("/tareas/:id", async (c) => {
  const id = c.req.param("id");
  const tarea = tareas.get(id);
  if (!tarea) return c.json({ error: "No encontrada" }, 404);

  const body = await c.req.json<Partial<Tarea>>();
  const actualizada = { ...tarea, ...body, id };
  tareas.set(id, actualizada);
  return c.json(actualizada);
});

app.delete("/tareas/:id", (c) => {
  const id = c.req.param("id");
  if (!tareas.has(id)) return c.json({ error: "No encontrada" }, 404);
  tareas.delete(id);
  return new Response(null, { status: 204 });
});

// Arrancar
export default {
  port: 3000,
  fetch: app.fetch,
};

Mucho más limpio. Hono te da routing declarativo, middlewares como cors() y logger(), y gestión de parámetros de ruta sin escribir regex manualmente. Y sigue siendo igual de rápido porque Hono está diseñado para no añadir overhead significativo.

Base de datos con Bun SQLite integrado

Bun incluye soporte nativo para SQLite sin instalar ningún paquete adicional. Para muchos proyectos pequeños o medianos, SQLite es más que suficiente y la integración de Bun la hace especialmente conveniente.

// src/database.ts
import { Database } from "bun:sqlite";

const db = new Database("tareas.db");

// Crear tabla si no existe
db.run(`
  CREATE TABLE IF NOT EXISTS tareas (
    id TEXT PRIMARY KEY,
    titulo TEXT NOT NULL,
    completada INTEGER DEFAULT 0,
    creada_en TEXT DEFAULT CURRENT_TIMESTAMP
  )
`);

// Preparar queries (más eficiente que ejecutarlas directamente)
const queries = {
  getAll: db.prepare("SELECT * FROM tareas"),
  getById: db.prepare("SELECT * FROM tareas WHERE id = ?"),
  create: db.prepare(
    "INSERT INTO tareas (id, titulo) VALUES (?, ?) RETURNING *"
  ),
  update: db.prepare(
    "UPDATE tareas SET titulo = COALESCE(?, titulo), completada = COALESCE(?, completada) WHERE id = ? RETURNING *"
  ),
  delete: db.prepare("DELETE FROM tareas WHERE id = ?"),
};

export { db, queries };

Las queries preparadas de Bun SQLite son síncronas por defecto, lo que puede sorprender si vienes de pg o mysql2. Para operaciones simples esto está bien; para operaciones más pesadas puedes usar el modo de base de datos en un worker separado.

// Uso en una ruta
import { queries } from "../database";

app.get("/tareas", (c) => {
  const tareas = queries.getAll.all();
  // Convertir el campo completada de 0/1 a boolean
  const resultado = tareas.map((t: any) => ({
    ...t,
    completada: t.completada === 1,
  }));
  return c.json(resultado);
});

app.post("/tareas", async (c) => {
  const { titulo } = await c.req.json<{ titulo: string }>();
  if (!titulo) return c.json({ error: "Título requerido" }, 400);
  
  const [nueva] = queries.create.all(crypto.randomUUID(), titulo) as any[];
  return c.json({ ...nueva, completada: false }, 201);
});

Bun vs Node.js: ¿cuándo elegir cada uno?

Después de usarlo en producción, mi visión es que Bun no reemplaza a Node.js en todos los casos, pero hay escenarios donde claramente gana. Para proyectos nuevos sin dependencias legacy, elegiría Bun sin pensarlo. Para scripts de automatización, herramientas CLI y APIs nuevas, es la opción obvia en 2026.

Donde Node.js sigue siendo la elección más segura es en proyectos que usan dependencias nativas compiladas (addons de C++) o que requieren soporte garantizado de largo plazo en entornos corporativos donde la estabilidad es prioritaria. El ecosistema de Node.js también es más maduro en términos de soluciones de monitoring y APM.

La compatibilidad de Bun con Node.js ha mejorado enormemente. La mayoría de los paquetes de npm funcionan sin problemas. Donde pueden aparecer fricciones es con paquetes que dependen de APIs muy específicas del runtime de Node que Bun aún no implementa al 100%, aunque esto es cada vez menos frecuente.

Despliegue en producción

Para producción, el proceso es sencillo. Puedes compilar tu aplicación a un solo ejecutable binario con bun build:

# Compilar a JavaScript optimizado
bun build ./index.ts --outdir ./dist --target bun

# O compilar a un ejecutable standalone (sin necesitar Bun instalado en el servidor)
bun build ./index.ts --compile --outfile mi-api

El ejecutable compilado incluye el runtime de Bun y tu código, por lo que puedes ejecutarlo en cualquier servidor Linux sin instalar nada. Esto es especialmente útil para contenedores Docker:

# Dockerfile multi-stage optimizado
FROM oven/bun:1 AS builder
WORKDIR /app
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun build ./index.ts --compile --outfile mi-api

FROM debian:bookworm-slim
WORKDIR /app
COPY --from=builder /app/mi-api ./mi-api
EXPOSE 3000
CMD ["./mi-api"]

La imagen resultante es muy pequeña porque no necesita Node.js ni Bun instalado. Solo el binario compilado y las dependencias del sistema operativo.

Siguiente paso

Con lo que has visto aquí ya tienes todo para arrancar un proyecto real con Bun. Mi recomendación: toma un proyecto pequeño que tengas en Node.js (un script, una API interna, una herramienta CLI) y prueba a migrarlo. La compatibilidad es alta y el proceso no debería llevarte más de una tarde.

Si quieres profundizar, el siguiente nivel natural es añadir autenticación JWT a la API que construimos (Bun incluye crypto nativo para esto), conectar con PostgreSQL usando bun-pg o Prisma, y configurar un pipeline de CI/CD que aproveche la velocidad de instalación de Bun. Cada uno de esos temas merece su propio artículo, que iremos publicando en el blog.