La arquitectura fundamental de Node.js se cimienta sobre una simbiosis de alto rendimiento entre el motor de ejecución JavaScript Google V8 y la biblioteca multiplataforma de E/S asíncrona libuv. El motor V8 en sí mismo es estrictamente monohilo: opera una única pila de llamadas (Call Stack) y administra una memoria de montículo contigua (Heap). No obstante, los servidores basados en Node.js gestionan de forma concurrente decenas de miles de conexiones TCP sin necesidad de crear un hilo del sistema operativo por cada solicitud.
Esta escalabilidad se alcanza gracias al patrón Reactor (Reactor Pattern) integrado en libuv:
+-------------------------------------------------------------+
| Aplicación Node.js |
| (Código JavaScript: Lógica de Negocio, Fastify, RSC) |
+-------------------------------------------------------------+
|
v
+-------------------------------------------------------------+
| Núcleo Node.js (Bindings C++ / JS) |
| (fs, net, http, crypto, buffer, stream, etc.) |
+-------------------------------------------------------------+
/ \
v v
+-----------------------+ +-----------------------+
| Google V8 | | libuv |
| - Call Stack | | - Event Loop (6 Ph) |
| - Memory Heap | | - Epoll/Kqueue/IOCP |
| - Microtask Queue | | - Thread Pool (4-1024|
+-----------------------+ +-----------------------+
|
v
+--------------------------------------+
| Kernel del SO (Syscalls) |
| Red no bloqueante / Sockets / IPC |
+--------------------------------------+libuv abstrae los mecanismos nativos de multiplexación de E/S suministrados por los núcleos de los distintos sistemas operativos:
Los sockets de red, canales de comunicación entre procesos (IPC) y flujos TCP/UDP son gestionados por el kernel del sistema operativo de manera completamente asíncrona y no bloqueante. Al ingresar paquetes de red, el kernel del sistema notifica a libuv a través de la llamada al sistema de demultiplexación (epoll_wait, kevent o GetQueuedCompletionStatusEx) y transmite los descriptores de archivo correspondientes.
No obstante, los sistemas operativos no ofrecen soporte asíncrono no bloqueante universal para todas las operaciones. En particular, las operaciones del sistema de archivos POSIX (open, read, write, stat) son bloqueantes a nivel de descriptores en la mayoría de plataformas. Para solventar este límite, así como para tareas criptográficas intensivas en CPU (crypto.pbkdf2, crypto.scrypt), algoritmos de compresión (zlib) y resolución de nombres DNS mediante getaddrinfo (dns.lookup), libuv administra un pool de hilos (Thread Pool) interno.
Por defecto, la dimensión del pool es de 4 hilos de trabajo. Su capacidad se ajusta mediante la variable de entorno global UV_THREADPOOL_SIZE (con un techo de 1024 hilos), la cual debe declararse antes del inicio del proceso Node.js.
El bucle de eventos (Event Loop) en libuv es un ciclo síncrono infinito desarrollado en lenguaje C que se ejecuta mientras existan manejadores de eventos o temporizadores activos (uv_loop_alive). Cada rotación completa por todas las fases se denomina tick.
A lo largo de un tick, libuv procesa secuencialmente 6 fases rigurosamente ordenadas:
+------------------------------------+
| INICIO |
+------------------------------------+
|
v
+------------------------------------+
| 1. Timers (setTimeout/Interval) | <--- [Drenaje microtareas: nextTick, Promise]
+------------------------------------+
|
v
+------------------------------------+
| 2. Pending I/O Callbacks | <--- [Drenaje microtareas: nextTick, Promise]
+------------------------------------+
|
v
+------------------------------------+
| 3. Idle, Prepare (interno) | <--- [Drenaje microtareas: nextTick, Promise]
+------------------------------------+
|
v
+------------------------------------+
| 4. Poll (epoll_wait / I/O Read) | <--- [Drenaje microtareas: nextTick, Promise]
+------------------------------------+
|
v
+------------------------------------+
| 5. Check (setImmediate) | <--- [Drenaje microtareas: nextTick, Promise]
+------------------------------------+
|
v
+------------------------------------+
| 6. Close Callbacks (socket close)| <--- [Drenaje microtareas: nextTick, Promise]
+------------------------------------+
|
v
+------------------------------------+
| ¿Loop sigue vivo? (Sí -> Iterar) |
+------------------------------------+perf_hooks.monitorEventLoopDelay para alimentar políticas de escalado horizontal (HPA) en Kubernetes.UV_THREADPOOL_SIZE en microservicios con elevado volumen de verificación de firmas JWT o distribución de archivos estáticos locales.setImmediate, permitiendo a la fase Poll procesar solicitudes HTTP entrantes y asegurar un TTFB óptimo.libuv examina su montículo binario (min-heap) de temporizadores registrados con setTimeout y setInterval. Si el reloj monótono del ciclo ha superado el umbral fijado, libuv transfiere la ejecución del callback a V8.ECONNREFUSED emitidos por el sistema operativo).setImmediate, el hilo se bloquea en la llamada epoll_wait (o equivalente) esperando eventos del kernel.setImmediate(). Permite ejecutar código inmediatamente después de la fase Poll del tick en curso.uv_close (por ejemplo, socket.on('close', ...)).Antes de Node.js 11, las colas de microtareas (process.nextTick y promesas) se vaciaban únicamente en las transiciones entre fases de libuv. Esto generaba discrepancias con la especificación de Event Loop de los navegadores.
Desde Node.js 11+, el motor se alinea con la especificación HTML5/ECMAScript: las colas de microtareas se vacían de inmediato tras completarse cada callback individual de JavaScript, sin importar en qué fase de libuv se encuentre el proceso.
Jerarquía de prioridad de microtareas:
nextTickQueue (process.nextTick): goza siempre de prioridad absoluta.microtaskQueue (Promise.then(), queueMicrotask()): se ejecuta únicamente tras vaciarse completamente la nextTickQueue.El código invoca fs.promises.readFile. Se crea una Promise pendiente y los argumentos se transfieren a los bindings en C++.
Dado que la E/S de archivos en POSIX es bloqueante, libuv empaqueta la tarea en un struct uv_fs_t y la encola en el pool de hilos.
Un hilo desocupado del pool ejecuta la llamada read() bloqueante y vuelca los bytes del disco en un búfer de memoria.
El hilo trabajador emite una notificación vía uv_async_send, despertando al hilo principal de su espera en epoll_wait.
En la fase Poll, el hilo principal recoge el resultado y resuelve la Promise. La cola de microtareas ejecuta los callbacks de .then().
Para la monitorización de salud en tiempo real, la clase IntervalHistogram del módulo node:perf_hooks permite supervisar percentiles de retardo del bucle de eventos:
import { monitorEventLoopDelay, IntervalHistogram } from 'node:perf_hooks';
import { EventEmitter } from 'node:events';
export interface EventLoopMetrics {
minMs: number;
maxMs: number;
meanMs: number;
p50Ms: number;
p90Ms: number;
p99Ms: number;
exceedsThreshold: boolean;
}
export class EventLoopMonitor extends EventEmitter {
private histogram: IntervalHistogram | null = null;
private timer: NodeJS.Timeout | null = null;
private readonly resolutionMs: number;
private readonly intervalMs: number;
private readonly warningThresholdMs: number;
constructor(options?: {
resolutionMs?: number;
intervalMs?: number;
warningThresholdMs?: number;
}) {
super();
this.resolutionMs = options?.resolutionMs ?? 10;
this.intervalMs = options?.intervalMs ?? 1000;
this.warningThresholdMs = options?.warningThresholdMs ?? 50;
}
public start(): void {
if (this.histogram) {
return;
}
// Inicializar el histograma con la resolución configurada
this.histogram = monitorEventLoopDelay({
resolution: this.resolutionMs,
});
this.histogram.enable();
this.timer = setInterval(() => {
this.collectMetrics();
}, this.intervalMs);
// Evitar que el temporizador mantenga el proceso activo artificialmente
this.timer.unref();
}
public stop(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
if (this.histogram) {
this.histogram.disable();
this.histogram.reset();
this.histogram = null;
}
}
private collectMetrics(): void {
if (!this.histogram) return;
// Los valores de monitorEventLoopDelay se expresan en nanosegundos (1e6 ns = 1 ms)
const nsToMs = 1_000_000;
const metrics: EventLoopMetrics = {
minMs: Number((this.histogram.min / nsToMs).toFixed(2)),
maxMs: Number((this.histogram.max / nsToMs).toFixed(2)),
meanMs: Number((this.histogram.mean / nsToMs).toFixed(2)),
p50Ms: Number((this.histogram.percentile(50) / nsToMs).toFixed(2)),
p90Ms: Number((this.histogram.percentile(90) / nsToMs).toFixed(2)),
p99Ms: Number((this.histogram.percentile(99) / nsToMs).toFixed(2)),
exceedsThreshold: false,
};
metrics.exceedsThreshold = metrics.p99Ms > this.warningThresholdMs;
this.emit('metrics', metrics);
if (metrics.exceedsThreshold) {
this.emit('lag-warning', {
message: `Latencia crítica Event Loop P99: ${metrics.p99Ms} ms (umbral: ${this.warningThresholdMs} ms)`,
metrics,
timestamp: Date.now(),
});
}
// Reiniciar histograma para asegurar métricas limpias en el siguiente ciclo
this.histogram.reset();
}
}Al procesar colecciones voluminosas en el hilo principal de Node.js, conviene segmentar la ejecución cediendo el control a la fase Check mediante setImmediate:
/**
* Procesa un arreglo en bloques cooperativos sin provocar inanición en el Event Loop.
* Cede el control con setImmediate entre lotes, permitiendo a libuv
* atender E/S de red en la fase Poll.
*/
export async function processInCooperativeBatches<T, R>(
items: T[],
batchSize: number,
processor: (item: T) => Promise<R> | R
): Promise<R[]> {
const results: R[] = [];
let index = 0;
while (index < items.length) {
const chunk = items.slice(index, index + batchSize);
// Procesar el lote actual sincrónicamente
for (const item of chunk) {
results.push(await processor(item));
}
index += batchSize;
// Ceder el control al Event Loop antes del siguiente bloque
if (index < items.length) {
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
}
}
return results;
}Es común emplear process.nextTick para diferir ejecuciones. No obstante, si se encadena recursivamente, V8 vacía nextTickQueue indefinidamente. El flujo nunca retorna a las fases de libuv, lo que produce inanición total (Starvation) de sockets de red, temporizadores y peticiones HTTP. Emplee setImmediate para permitir que libuv intercale eventos de E/S.
Ejecutar fs.readFileSync, fs.writeFileSync, crypto.pbkdf2Sync o parsear documentos extensos con JSON.parse dentro de manejadores HTTP paraliza el hilo monohilo de V8. Mientras transcurre dicha operación, el proceso no puede aceptar paquetes de red en la fase Poll, lo que eleva drásticamente la latencia P99 de todos los clientes. Utilice streams (fs.createReadStream), APIs criptográficas asíncronas o derive el trabajo intensivo a Worker Threads.
El método dns.lookup recurre a la llamada síncrona getaddrinfo(3), la cual se ejecuta en el pool de hilos de libuv (UV_THREADPOOL_SIZE = 4). Al procesar cientos de solicitudes HTTP concurrentes hacia dominios externos diversos, el pool se satura al instante. Cualquier lectura de archivos (fs) u operación de cifrado queda bloqueada en espera. Emplee los resolvers nativos de node:dns/promises (dns.resolve4, dns.resolve6), que operan de forma no bloqueante mediante sockets del kernel sin consumir hilos del pool.
Definir process.env.UV_THREADPOOL_SIZE = '16' dentro del código JavaScript no tiene efecto alguno si libuv ya se inicializó antes de procesar el archivo. UV_THREADPOOL_SIZE debe declararse antes del arranque del proceso: por ejemplo, UV_THREADPOOL_SIZE=16 node dist/main.js o mediante directivas de entorno en el Dockerfile.
libuv verifica si persisten handles activos, temporizadores o sockets abiertos. Si el recuento es cero, el proceso finaliza con código 0.
Se actualiza el reloj monótono y se despachan los callbacks de temporizadores vencidos (setTimeout, setInterval) desde el min-heap.
Se ejecutan callbacks de sistema diferidos de la iteración previa, como errores de red específicos reportados por el kernel.
Operaciones de mantenimiento interno de libuv para sincronizar estructuras antes de la espera bloqueante del kernel.
Cálculo de timeout y ejecución de epoll_wait/kqueue/IOCP. El hilo aguarda eventos de sockets o retorna si hay tareas en setImmediate.
Ejecución de callbacks registrados vía setImmediate inmediatamente después de concluir el procesamiento de Poll.
Emisión de eventos de cierre de recursos (uv_close) tras la desconexión de sockets y handles.