<!-- Canonical: https://lucioduran.com/es/blog/bun-v2-runtime-internals-deep-dive
Language: es
Content-SHA256: 7194cfa8215d253c1483630a43aac983986304a2faa59b5e5e4a517ed1d1c1e1
-->

JavaScript Runtimes

# Internals de Bun v2: JavaScriptCore, Allocators de Zig y Batching de Syscalls con io\_uring

2025-10-0410 min lectura

Lucio Durán
Engineering Manager & AI Solutions Architect

También disponible en: [English](<https://lucioduran.com/blog/bun-v2-runtime-internals-deep-dive>)

## La Apuesta por JavaScriptCore

La decisión más contrarian de la arquitectura de [Bun](<https://bun.sh/>) es usar JavaScriptCore (JSC) en vez de V8. Todos los otros runtimes de JavaScript, [Node.js](<https://nodejs.org/>), [Deno](<https://deno.com/>), [Cloudflare Workers](<https://workers.cloudflare.com/>), 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](<https://ziglang.org/>), 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.

```zig
// 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:

```javascript
// 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 `:

```bash
$ 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.

```bash
# 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](<https://aws.amazon.com/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

```bash
# 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.

## Preguntas frecuentes

### ¿Bun v2 está listo para producción en workloads de servidor?

Para servidores HTTP y workloads de API, sí. Despliegues en producción desde noviembre 2025 en servicios API de tráfico medio (~2k req/s) han reportado cero crashes en runtime. La compatibilidad del ecosistema está al ~98% de las APIs de Node.js. Los gaps restantes son mayormente edge cases de streams y ciertos métodos crypto.

### ¿Por qué Bun usa JavaScriptCore en vez de V8?

La compilación en tiers de JSC (LLInt → Baseline → DFG → FTL) produce código optimizado más rápido para procese es de vida corta. TurboFan de V8 produce mejor throughput pico para hot loops de larga duración, pero el tiempo de warm-up es 2-4x más largo. Para herramientas CLI y serverless, el perfil de JSC es mejor.

### ¿Cómo funciona la integración de io\_uring en Bun en Linux?

Bun v2 batchea syscalls de filesystem y red en colas de submission de io\_uring, submitiéndolas en un solo syscall. Para un handler HTTP típico que lee un archivo y consulta una base de datos, esto puede reducir el conteo de syscalls de 8-12 a 2-3, con el kernel manejando el resto asincrónicamente.

## Tips

1. **El cache de resolución de módulos de Bun está respaldado por fd:** Cuando ves imports rápidos repetidos, es porque Bun mantiene un ` HashMap(u64, *Module) ` indexado por número de inode, no por string de path. Esto significa que los symlinks resuelven al mismo cache entry gratis, y los renames no invalidan el cache incorrectamente.
2. **Utilizar ` Bun.mmap() ` para archivos de configuración grandes:** En vez de ` fs.readFileSync `, ` Bun.mmap() ` devuelve un ` Uint8Array ` respaldado por un archivo memory-mapped. El kernel maneja el paging, de modo que solo se paga por los bytes que realmente se acceden. Ventaja significativa para configuraciones JSON grandes donde se analiza un subconjunto.
3. **El transpiler de Bun v2 omite el AST para patrones conocidos:** Si un archivo coincide con un conjunto de heurísticas de fast-path (sin top-level await, sin decorators, sin imports ` with `), Bun usa un transpiler streaming de un solo pase que emite bytecode directo sin construir un AST completo. Es posible verificar con ` BUN_DEBUG_TRANSPILER=1 ` qué path toman los archivos.
4. **El flag ` --smol ` intercambia throughput por memoria:** Cambia del allocator general de Zig a una arena page-mapped que devuelve memoria al SO agresivamente. En serverless, esto puede reducir la memoria idle de ~45MB a ~12MB, haciéndolo viable para funciones Lambda de 128MB.

- bun
- javascript
- runtime-internals
- zig
- io\_uring
- rendimiento

[Volver al Blog](<https://lucioduran.com/es/blog>)
