🧠 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.
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.
- Parser: convierte el fuente en un árbol de sintaxis (AST).
- Ignition (intérprete): traduce el AST a bytecode y lo ejecuta al instante. Arranque rápido.
- Turbofan (compilador JIT): detecta las funciones “calientes” (llamadas muchas veces) y las compila a código máquina optimizado.
- 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/setIntervalvencidos. - 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.nextTickno 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 maxlanza 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
💡
--inspectno 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 abrirnode/lib/http.jspara ver cómo el servidor construyereqyresa 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
- V8 — JavaScript engine: el blog y la documentación oficial del motor (JIT, hidden classes, GC).
- Node.js — Event loop guide: la guía oficial de las fases del bucle.
- libuv — Documentación: el event loop y el thread pool en detalle.
- Node.js — Diagnostics: profiling, heap y debugging con las herramientas nativas.
- clinic.js: doctor, flame y heapprofiler para encontrar cuellos de botella.