🔎 Buscar

🧠 Internals de Node.js y V8

V8 (JIT, Maglev, TurboFan, GC Orinoco), libuv y el event loop, worker_threads, clustering, profiling, memoria, debugging y cuándo escalar.

Wiki / Apuntes📖 Contenido

Internals de Node.js y V8

Node.js es un runtime de JavaScript, pero “runtime” no es una caja negra: por debajo hay dos motores bien diferenciados. V8 compila y ejecuta tu JavaScript (con un JIT y un recolector de basura) y libuv gestiona el event loop, el I/O y el thread pool. Entender ambas piezas te convierte de “alguien que escribe Node” a “alguien que entiende por qué su código va lento o rápido”.

V8: cómo se ejecuta tu JavaScript

V8 es el motor de JavaScript de Chrome que Node usa. Tu código no se interpreta línea a línea de forma ingenua: pasa por un pipeline de compilación que optimiza el camino más usado.

  1. Parser: convierte el fuente en un árbol de sintaxis (AST).
  2. Ignition (intérprete): traduce el AST a bytecode y lo ejecuta al instante. Arranque rápido.
  3. Turbofan (compilador JIT): detecta las funciones “calientes” (llamadas muchas veces) y las compila a código máquina optimizado.
  4. Maglev: un optimizador intermedio que entra antes que TurboFan para funciones medianamente calientes, reduciendo la latencia de compilación.

El detalle clave de un JIT son las hidden classes y las inline caches: V8 asume que los objetos de una misma “forma” se acceden igual y optimiza ese acceso.

// Para el JIT, "objeto" = forma + valores
function punto(x, y) {
  return { x, y };        // hidden class compartida
}
const a = punto(1, 2);
const b = punto(3, 4);    // misma forma → acceso optimizado

a.z = 5;                  // ⚠️ añadir props cambia la forma → deopt de la caché

💡 Por eso es mala práctica añadir propiedades a un objeto después de crearlo: rompe la hidden class y V8 tiene que “deoptimizar”. Crea los objetos con la misma forma desde el inicio.

La recolector de basura: Orinoco

V8 usa GC generacional de dos espacios:

Espacio Qué guarda Cómo se limpia
Joven (scavenger) Objetos recién creados, de vida corta Minor GC frecuente y barato
Viejo Objetos que sobreviven varios ciclos Major GC (mark-sweep-compact)

Orinoco es la generación actual de GC con recolección incremental y concurrente que reduce las pausas que bloquean tu programa. Aun así, el GC se activa cuando la memoria supera un umbral; crear basura en loops grandes provoca pausas.

// Genera basura en un bucle → presión de GC
for (let i = 0; i < 1e6; i++) {
  const tmp = { id: i };      // 1M de objetos que el GC debe barrer
  total += tmp.id;
}

💡 Para ver el GC en acción: node --trace-gc script.js. Si ves pausas largas repetidas, tu código está creando demasiada basura temporal.

libuv y el event loop

libuv es la librería C que le da a Node su bucle de eventos y su thread pool. Aunque JavaScript es single-thread, el I/O asíncrono no ocurre en tu hilo: ocurre en el sistema operativo o en el thread pool de libuv.

Las fases del event loop

Cada “tick” del bucle recorre fases en orden fijo:

   ┌───────────────────────┐
┌─►│        timers         │  ← setTimeout / setInterval
│  └──────────┬────────────┘
│  ┌──────────▼────────────┐
│  │   pending callbacks   │  ← callbacks de I/O pendientes
│  └──────────┬────────────┘
│  ┌──────────▼────────────┐
│  │       idle, prepare   │  ← uso interno de libuv
│  └──────────┬────────────┘
│  ┌──────────▼────────────┐
│  │         poll          │  ← recupera nuevos eventos de I/O
│  └──────────┬────────────┘
│  ┌──────────▼────────────┐
│  │        check          │  ← setImmediate
│  └──────────┬────────────┘
│  ┌──────────▼────────────┐
│  │    close callbacks    │  ← socket.on('close', ...)
│  └──────────┬────────────┘
│            └──────────────┘
  • timers: ejecuta setTimeout/setInterval vencidos.
  • poll: el más importante; espera I/O y ejecuta sus callbacks.
  • check: setImmediate.
  • close: cierres de sockets/streams.
setTimeout(() => console.log("timers"), 0);
setImmediate(() => console.log("check"));
// Dentro del poll, el orden entre ambos depende del momento de entrada

⚠️ process.nextTick no es una fase: se ejecuta al final de la fase actual, antes de pasar a la siguiente. Abusar de él puede hambrear el event loop y bloquear el I/O.

El thread pool de libuv

Las operaciones sin API nativa del sistema operativo (y algunas de disco/cripto) se delegan a un thread pool de 4 hilos por defecto (UV_THREADPOOL_SIZE). fs, crypto, zlib y dns.lookup lo usan.

const { exec } = require("node:child_process");

// Trabajo síncrono en 4 hilos de libuv puede competir con fs
for (let i = 0; i < 8; i++) {
  exec("sleep 1", () => console.log("hecho", i));
}
// Con UV_THREADPOOL_SIZE=4, los últimos 4 esperan a que liberen hilos

💡 Si tu app hace mucho I/O de disco o criptografía y notas cuellos de botella, sube UV_THREADPOOL_SIZE (ej. a 8 o 16) según los núcleos de tu máquina.

Cómo se ejecuta un request de punta a punta

Juntamos V8 + libuv en el recorrido de un request:

1. El kernel recibe bytes en el socket TCP.
2. libuv detecta actividad en la fase `poll` y llama al callback de lectura.
3. Node lee los bytes (evento 'data' en el stream `req`).
4. Tu handler (V8) procesa y prepara la respuesta.
5. Si hace I/O (DB, fs), se delega a libuv/thread pool SIN bloquear.
6. Cuando el I/O termina, libuv agenda el callback en `poll`.
7. Tu código escribe en el stream `res`; libuv envía los bytes al socket.
8. El socket se cierra → fase `close`.
const http = require("node:http");

http.createServer((req, res) => {
  setTimeout(() => {                       // timers: no bloquea
    res.end("listo");                      // check/poll lo devuelven
  }, 100);
}).listen(3000);

El punto crucial: tu JavaScript nunca se bloquea en I/O. Mientras la DB responde, el event loop sigue atendiendo otros requests. Esa es la razón por la que Node maneja miles de conexiones con un solo hilo — siempre que no hagas trabajo síncrono pesado (CPU-bound) en ese hilo.

worker_threads y clustering

Cuando necesitas paralelismo real (CPU-bound), un solo hilo no basta. Dos estrategias complementarias:

worker_threads: paralelismo dentro de un proceso

worker_threads ejecuta código en hilos separados del mismo proceso, cada uno con su propio V8. Ideal para tareas de CPU (cripto, parseo, imágenes) que no son I/O.

// worker.js
const { parentPort } = require("node:worker_threads");

parentPort.on("message", (n) => {
  let sum = 0;
  for (let i = 0; i < n; i++) sum += i;   // tarea de CPU pesada
  parentPort.postMessage(sum);
});
// main.js
const { Worker } = require("node:worker_threads");

const worker = new Worker("./worker.js");
worker.postMessage(1e9);                  // reparte el trabajo
worker.on("message", (r) => console.log("resultado:", r));

⚠️ Cada worker tiene su propio aislado de V8: memoria y CPU adicionales. No crees un worker por petición; usa un pool de workers que reutilice hilos.

cluster: múltiples procesos

El módulo cluster replica tu proceso en N procesos (uno por núcleo), cada uno con su event loop, y comparten el puerto. Es el modelo clásico para aprovechar CPUs multi-núcleo en servidores HTTP.

const cluster = require("node:cluster");
const os = require("node:os");

if (cluster.isPrimary) {
  const cpus = os.availableParallelism();
  for (let i = 0; i < cpus; i++) cluster.fork();   // lanza un worker por CPU
  cluster.on("exit", (w) => cluster.fork());       // respawn automático
} else {
  require("./server");                              // cada worker corre la app
}

💡 PM2 hace esto por ti: pm2 start server.js -i max lanza tantos procesos como núcleos y gestiona respawns y logs. El clustering manual solo aporta control fino.

Profiling: encontrar el cuello de botella

Nunca adivines qué es lento: mide. Node trae herramientas integradas y otras del ecosistema.

--prof y el CPU profile

node --prof app.js          # recoge un log de muestras de CPU
node --prof-process isolate-*.log > perfil.txt   # lo convierte a texto

Eso muestra qué funciones consumen más CPU. Para algo más visual, genera un CPU profile:

node --cpu-prof --cpu-prof-dir=./perfil app.js

Ábrelo en Chrome DevTools (Performance → Load profile) y verás un flame graph de llamadas.

clinic.js y 0x

clinic.js envuelve tu app y genera informes de doctor (event loop, memoria, CPU) y de flame graph:

npx clinic doctor -- node app.js
npx clinic flame -- node app.js     # flame graph interactivo
npx clinic heapprofiler -- node app.js

0x genera flame graphs de una pasada:

npx 0x app.js

💡 El flame graph te muestra, en una imagen, dónde se va el tiempo: una “meseta” ancha = función caliente que optimizar. Si la meseta es de una función de librería, busca una alternativa más rápida o delega a un worker.

Memoria: heap snapshots y leaks

Una fuga de memoria (leak) ocurre cuando objetos que ya no necesitas quedan referenciados y el GC no puede liberarlos. Los síntomas: la memoria sube sin parar y el proceso acaba matado por OOM.

// Un leak clásico: cache que nunca se vacía
const cache = new Map();
function guardar(usuario) {
  cache.set(usuario.id, usuario);   // crece para siempre sin límite
}

Tomar un heap snapshot

node --heapsnapshot-near-heap-limit 3 app.js

O en tiempo de ejecución con v8.writeHeapSnapshot():

const v8 = require("node:v8");
v8.writeHeapSnapshot();   // genera un archivo .heapsnapshot

Abre el .heapsnapshot en Chrome DevTools (Memory → Load) y busca los objetos retenidos por referencias inesperadas.

Patrones que causan leaks

Patrón Por qué filtra
Map/Set global que crece Nada lo vacía
Listeners no removidos emitter.on() sin off() acumula callbacks
Closures sobre variables grandes El closure retiene el scope mientras viva
Timers/intervalos sin limpiar setInterval mantiene el proceso vivo
// Correcto: limpiar lo que ya no usas
const timer = setInterval(doWork, 1000);
// ...cuando toca limpiar:
clearInterval(timer);

Debugging avanzado

Para depurar de verdad no uses console.log a lo loco. Node trae herramientas potentes:

node --inspect app.js            # abre el inspector en el puerto 9229
node --inspect-brk app.js        # pausa en la primera línea

Con Chrome DevTools o VS Code conectas, pones breakpoints, inspeccionas variables y navegas el call stack. Para depurar con logs del propio runtime:

node --trace-warnings app.js      # avisos de deprecación y leaks de listeners
node --trace-gc app.js            # trazas del recolector de basura
node --trace-event-categories node.async_hooks app.js  # seguimiento async

💡 --inspect no ralentiza apenas el código; es seguro para debugging en staging. En producción usa logs estructurados y métricas antes que un inspector.

Cómo contribuir a Node (lee el source)

Node es código abierto y su fuente está en lib/ (JavaScript) y src/ (C++). La mejor manera de entender sus internals es leer la implementación real:

node/lib/
├── fs.js          # wrappers de fs sobre libuv
├── http.js        # el servidor HTTP sobre los streams TCP
├── timers.js      # setTimeout/setInterval sobre libuv
└── worker_threads.js

node/src/
├── node.cc        # arranque y el event loop principal
└── libuv/         # el bucle de eventos en C

Contribuir es más sencillo de lo que parece: busca issues etiquetados como good first issue en el repo de Node, clona, haz el cambio en lib/, y abre un PR. Los tests se corren con node test/.

💡 Una forma práctica de “leer el source”: node -p "require('fs')" y ver qué funciones expone, o abrir node/lib/http.js para ver cómo el servidor construye req y res a partir de los streams TCP.

Cuándo escalar: vertical vs horizontal

Estrategia Qué haces Cuándo
Vertical Más CPU/RAM en la misma máquina Tareas CPU-bound, una sola máquina, simplicidad
Horizontal Más procesos/máquinas tras un balanceador I/O-bound, tráfico HTTP alto, alta disponibilidad
Clúster interno Varios procesos en una máquina Aprovechar núcleos con una sola app
Worker threads Hilos dentro del proceso CPU-bound en una sola máquina sin subir instancias

La regla que decide:

¿El problema es I/O-bound (espera de red/disco)?
  → escala horizontalmente (más procesos/instancias).
¿El problema es CPU-bound (cómputo puro)?
  → worker_threads primero, vertical después.

💡 Antes de escalar, perfila. Subir de 2 a 16 núcleos no arregla un N+1 ni una función O(n²). Optimiza el algoritmo, añade índices, y solo entonces escala la infraestructura.

Para profundizar

Estudio · Recursos de todo el mundo (inglés, chino, japonés, español, francés, ruso…) curados y traducidos al español.