La Apuesta por JavaScriptCore

La decisión más contrarian de la arquitectura de Bun es usar JavaScriptCore (JSC) en vez de V8. Todos los otros runtimes de JavaScript, Node.js, Deno, Cloudflare Workers, usan V8. Jarred eligió JSC, y en Bun v2, esa apuesta ha rendido más que nunca.

Un aspecto que suele pasarse por alto: V8 y JSC optimizan para perfiles de workload distintos.

Pipeline de V8: Ignition (intérprete) → Sparkplug (baseline rápido) → Maglev (mid-tier) → TurboFan (compilador optimizador)

Pipeline de JSC: LLInt (intérprete) → Baseline JIT → DFG (data flow graph) → FTL (faster than light, vía B3/LLVM)

La diferencia clave no es el rendimiento pico, TurboFan y FTL producen código comparable para hot loops. Es el time-to-optimized-code. El Baseline JIT de JSC arranca después de ~6 invocaciones de una función. Sparkplug de V8 necesita ~100. DFG arranca a ~60 invocaciones; Maglev a ~400.

Para una función serverless que hace cold-start, maneja 10 requests, y se evicciona? JSC está corriendo código máquina optimizado mientras V8 todavía está en el intérprete para la mitad de tus code paths.

Un benchmark con una cadena de middleware realista estilo Express (8 middlewares, parsing JSON, validación, mock de query a DB) revela la diferencia:

Cold start + primer request:
 Node.js 22 (V8): 147ms
 Bun v2 (JSC): 48ms

Después de 100 requests:
 Node.js 22 (V8): 3.2ms/req
 Bun v2 (JSC): 2.8ms/req

Después de 10,000 requests:
 Node.js 22 (V8): 1.1ms/req
 Bun v2 (JSC): 1.3ms/req

El crossover es claro. V8 gana en throughput sostenido porque las optimizaciones especulativas de TurboFan son más agresivas. Sin embargo, Bun gana en todos los escenarios donde importa el startup y el warm-up, que es la mayor parte de los despliegues reales.

El Allocator de Zig: Por Qué Importa

Bun está escrito en Zig, y la elección tiene implicaciones de ingeniería concretas. El modelo de memoria de Zig le da a Bun capacidades que serían extremadamente difíciles en C++ o Rust.

El GeneralPurposeAllocator estándar de Zig es adecuado, pero Bun v2 usa un allocator personalizado basado en arenas para el manejo de requests. La observación clave es la siguiente: la mayoría del procesamiento de requests HTTP asigna una gran cantidad de objetos pequeños, los usa por unos milisegundos, y después descarta todo. Un allocator tradicional (malloc/free) tiene que rastrear cada asignación individualmente. Un arena allocator simplemente avanza un puntero, y libera todo de una cuando el request termina.

// Versión simplificada del allocator per-request de Bun
const RequestArena = struct {
 buffer: [*]u8,
 offset: usize,
 capacity: usize,

 pub fn alloc(self: *RequestArena, size: usize, alignment: u8) ?[*]u8 {
 const aligned_offset = std.mem.alignForward(usize, self.offset, alignment);
 if (aligned_offset + size > self.capacity) return null;
 const ptr = self.buffer + aligned_offset;
 self.offset = aligned_offset + size;
 return ptr;
 }

 pub fn reset(self: *RequestArena) void {
 // Una operación libera TODO
 self.offset = 0;
 }
};

Esto es extremadamente rápido. Sin free lists, sin coalescing, sin fragmentación. La llamada a reset() al final de cada request es literalmente setear un entero a cero. Al perfilar un servidor HTTP de Bun manejando 50k req/s, el overhead del allocator se ubica debajo del 0.3% del tiempo total de CPU. En Node.js, el garbage collector de V8 toma 4-7% en el mismo workload.

El tradeoff es que los objetos alocados en arena no pueden sobrevivir al request. Bun maneja esto detectando alocaciones que "escapan", objetos que se guardan en globals o closures, y promoviéndolos al heap de propósito general. Esta detección pasa a nivel de compilación de Zig usando anotaciones de lifetime que el sistema de tipos de Zig hace naturales.

io_uring: La Ventaja Linux

En este punto es donde Bun v2 obtiene la mayor ventaja en Linux. io_uring es una interfaz del kernel que permite agrupar múltiples operaciones de I/O en un solo syscall. En vez de:

syscall: open("config.json") → fd 5
syscall: fstat(5) → {size: 1024}
syscall: read(5, buf, 1024) → 1024 bytes
syscall: close(5)
syscall: socket(AF_INET, ...) → fd 6
syscall: connect(6, addr) → 0
syscall: write(6, request) → 256 bytes
syscall: read(6, response) → 512 bytes

Eso son 8 syscalls, cada uno involucrando un context switch user-kernel que cuesta ~1-3 microsegundos. Con io_uring, Bun hace:

// Submit queue: batchear todas las operaciones
sqe[0] = { .op = OPENAT, .path = "config.json" }
sqe[1] = { .op = READ, .fd = CHAIN_PREV, .buf = ... }
sqe[2] = { .op = SOCKET, .domain = AF_INET, ... }
sqe[3] = { .op = CONNECT, .fd = CHAIN_PREV, .addr = ... }
sqe[4] = { .op = SEND, .fd = CHAIN_PREV, .buf = request }
sqe[5] = { .op = RECV, .fd = CHAIN_PREV, .buf = response }

// Un syscall para submitir todo, uno para cosechar completions
syscall: io_uring_enter(ring_fd, 6, 6, IORING_ENTER_GETEVENTS)

Dos syscalls en vez de ocho. El flag CHAIN_PREV es crucial, le dice al kernel que la operación N depende del fd de la operación N-1, así que el kernel las encadena automáticamente. El event loop de Bun v2 está construido alrededor de esto: recolecta todas las operaciones I/O pendientes durante un checkpoint de microtask, las batchea en una submission, y entra al kernel una sola vez.

Un test que simula un endpoint de API típico, leer un archivo, consultar una base de datos, escribir una respuesta, ilustra esto:

// test-io-pattern.js
import { serve } from "bun";

const server = serve({
 port: 3000,
 async fetch(req) {
 const config = await Bun.file("config.json").json();
 const dbResult = await db.query("SELECT * FROM users WHERE id = $1", [config.defaultUser]);
 return Response.json(dbResult);
 },
});

Corriendo bajo strace -c:

Runtime Syscalls totales/request Tipos de syscall únicos
Node.js 22 23 11
Deno 2.1 18 9
Bun v2 (sin io_uring) 14 8
Bun v2 (io_uring) 6 3

El path de io_uring es dramáticamente más eficiente. Y esto no es un benchmark sintético, es un patrón de handler realista.

El Pipeline del Transpiler

Bun no delega a un transpiler separado. Tiene el suyo propio, escrito en Zig, y en v2 tuvo una remodelación importante. La innovación clave es el modo "AST-skip".

Para la mayoría de archivos JavaScript y TypeScript, el pipeline completo es:

  1. Lexer → tokens
  2. Parser → AST
  3. Transform → AST modificado (sacar tipos, bajar sintaxis)
  4. Printer → fuente JavaScript
  5. Compilador de bytecode JSC → bytecode

Bun v2 identificó que para archivos que matchean ciertas heurísticas, sin top-level await, sin decorators, sin imports with, sin JSX fuera de archivos .jsx/.tsx, los pase es 2-4 pueden reemplazarse con un transpiler streaming que va directo de tokens a instrucciones de bytecode:

Estándar: Lexer → Parser → AST → Transform → Printer → JSC Bytecode
Fast path: Lexer → Streaming Bytecode Emitter

Esto es posible porque la mayoría de las transformaciones TypeScript-a-JavaScript son borrados sintácticos (anotaciones de tipo, interfaces, enums con valores conocidos). Si solo se eliminan tokens, no se necesita un árbol, sino una máquina de estados basada en stack.

Es posible verificar qué path toman los archivos estableciendo BUN_DEBUG_TRANSPILER=1:

$ BUN_DEBUG_TRANSPILER=1 bun run server.ts
[transpiler] server.ts: fast-path (no AST), 0.12ms
[transpiler] routes/api.ts: fast-path (no AST), 0.08ms
[transpiler] middleware/auth.ts: full-AST (top-level await), 0.41ms
[transpiler] utils/decorators.ts: full-AST (decorators), 0.38ms

El fast path es 3-4x más rápido por archivo. Para un proyecto con 200 archivos fuente, esto recorta 40-60ms del cold start. No es enorme en términos absolutos, pero se compone, son 40ms que se suman a cada cold start serverless, cada invocación CLI, cada corrida de bun test.

El Package Manager: Características de Rendimiento

El package manager de Bun era rápido en v1, y v2 lo mejoró sustancialmente. El rendimiento proviene de una combinación de:

  1. Hardlinks en vez de copias. El cache global de Bun en ~/.bun/install/cache/ guarda una copia de cada versión de paquete. Los node_modules de tu proyecto contienen hardlinks, no copias. En APFS/Btrfs/ext4, los hardlinks son gratis, comparten los mismos inodes.

  2. Resolución paralela con HTTP batcheado. Bun resuelve el árbol de dependencias usando un thread pool work-stealing (std.Thread.Pool de Zig), y batchea requests al registry usando multiplexing HTTP/2 sobre una sola conexión a npmjs.org.

  3. Lockfile binario. bun.lockb es un formato binario, no YAML ni JSON. Parsear un package-lock.json de 2MB toma ~15ms. Parsear el bun.lockb equivalente toma ~0.3ms, es esencialmente un array de structs memory-mapped.

# Instalación limpia de una app Next.js 15 con 847 dependencias
$ time npm install # 28.4s
$ time pnpm install # 12.1s
$ time bun install # 3.2s

# Instalación subsiguiente (cacheada, lockfile existe)
$ time npm install # 8.7s
$ time pnpm install # 2.4s
$ time bun install # 0.4s

El install subsiguiente de 0.4s está dominado por la creación de hardlinks y verificación de integridad. El tiempo real de resolución y descarga es esencialmente cero.

Perfil de Memoria: El Flag --smol

Bun v2 introdujo --smol, una funcionalidad que se vuelve significativa al operar en infraestructura con recursos limitados. Su comportamiento es el siguiente:

Por default, Bun (como todos los runtimes basados en JSC) pre-asigna una región de memoria virtual de 256MB para el compilador JIT, más ~32MB para el heap del GC. La mayor parte es virtual (no residente), pero algunas plataformas, en particular AWS Lambda, cuentan la memoria virtual contra el límite configurado.

--smol cambia tres cosas:

  1. La región JIT se achica a 64MB
  2. El heap del GC empieza en 4MB en vez de 32MB, creciendo bajo demanda
  3. El arena allocator devuelve páginas al SO vía madvise(MADV_DONTNEED) después de cada request en vez de mantenerlas calientes
# Uso de memoria después de manejar 100 requests, luego idle
$ bun run server.ts # RSS: 47MB, VSZ: 312MB
$ bun --smol run server.ts # RSS: 13MB, VSZ: 89MB

La penalidad de throughput es real, aproximadamente 15-20% más lento en workloads sostenidos porque el JIT tiene menos espacio de code cache y el GC corre más seguido. Pero para funciones serverless facturadas por memoria-milisegundo, la reducción de 3.5x en RSS se paga fácil.

Lo Que Todavía Falta

En aras del balance, Bun v2 todavía no es un reemplazo completo de Node.js:

  • Worker threads están soportados pero son ~40% más lentos que Node.js debido al modelo de threading diferente de JSC
  • Native addons (archivos .node) funcionan a través de una capa de compatibilidad, pero módulos napi-rs a veces tienen diferencias sutiles de comportamiento
  • Tooling de diagnóstico está inmaduro, no hay equivalente del protocolo --inspect de Node.js para integración con Chrome DevTools. Bun tiene bun --inspect pero el soporte del protocolo es parcial.
  • Rendimiento en ARM64 Linux queda atrás de x86_64 porque el backend FTL de JSC no usa instrucciones NEON tan agresivamente como V8 usa SIMD x86 equivalente

Todos estos son problemas solucionables, y el equipo de Bun está trabajando en ellos. Sin embargo, al evaluar Bun v2 para producción, se recomienda probar el workload específico en vez de confiar en benchmarks genéricos.

El Panorama General

Lo que hace interesante a Bun no es ninguna optimización individual. Es el efecto compuesto de docenas de decisiones que todas favorecen el tiempo de startup y la eficiencia por request: los tiers JIT más rápidos de JSC, el modelo de alocación de Zig, el batching de syscalls con io_uring, lockfiles binarios, transpilación AST-skip.

Cada una aporta aproximadamente un 10-30% de mejora en aislamiento. Al combinarlas todas, se obtiene un runtime que hace cold-start de un servidor TypeScript en 48ms en vez de 147ms, instala dependencias en 3 segundos en vez de 28, y usa 13MB de RAM en vez de 47MB.

Esto no representa una mejora marginal. Es un cambio cualitativo en lo que es posible con JavaScript en el servidor.