El Event Loop, Worker Threads y la Concurrencia en Node.js
Loop de eventos de Node.js, pool de hilos libuv, worker threads y cluster explicados con guía clara sobre concurrencia I/O vs CPU.
Node.js ejecuta tu JavaScript en un único hilo impulsado por el event loop; la concurrencia proviene de delegar la espera — libuv entrega el I/O bloqueante a un pool de hilos en segundo plano — no de ejecutar tu JS en paralelo. Cuando necesitas ejecutar JavaScript en paralelo para trabajo intensivo en CPU, ese es un mecanismo separado: worker threads, cada uno un isolate de V8 completo con su propio event loop. Esos tres elementos — el hilo JS único, el pool de hilos de libuv y los worker threads — se confunden constantemente, y esa confusión es el origen de los endpoints lentos y los servidores bloqueados.
Este artículo separa las capas con precisión. Explica las fases del event loop y la distinción entre microtareas y macrotareas, qué sirve realmente el pool de hilos de libuv (y qué no), cuándo los worker threads superan al simple async/await, en qué se diferencia el clustering, y una regla de decisión para elegir entre ellos. El código está escrito para Node.js 24 (Active LTS) con Node.js 26 como la línea Current; los worker threads son estables, no experimentales.
Puntos Clave
- Node ejecuta tu JavaScript en un único hilo; el event loop logra la concurrencia entregando el I/O bloqueante al pool de hilos en segundo plano de libuv, no ejecutando tu código en paralelo.
- El pool de hilos de libuv tiene por defecto 4 hilos y puede ampliarse hasta un máximo de 1024 mediante
UV_THREADPOOL_SIZE; sirve trabajo de sistema de archivos,dns.lookup, crypto y zlib — pero no los sockets de red, que utilizan directamente epoll/kqueue/IOCP del sistema operativo. - Un worker thread no es “simplemente un hilo del sistema operativo” — cada uno es un isolate de V8 separado con su propio event loop y su propio loop de libuv, razón por la cual los workers no pueden compartir objetos ordinarios y deben comunicarse mediante paso de mensajes.
- Las microtareas no son una fase del event loop: los callbacks de
process.nextTickse vacían primero, luego la cola de microtareas de promesas, y solo entonces el loop avanza a la siguiente macrotarea. - Elige según el cuello de botella:
async/awaitpara trabajo intensivo en I/O, worker threads para JavaScript intensivo en CPU, y cluster para escalar la carga intensiva en I/O entre núcleos.
El modelo central: un hilo JS, un event loop y libuv
Node.js ejecuta tu JavaScript en un único hilo, y ese hilo ejecuta el event loop. El runtime está construido sobre el motor V8 de Google para ejecutar JavaScript y sobre libuv, una biblioteca en C que proporciona el event loop y el I/O asíncrono. El truco que permite a un único hilo gestionar miles de conexiones concurrentes es la delegación: cuando tu código llama a una operación bloqueante como la lectura de un archivo, Node no espera. Registra la operación con libuv, retorna inmediatamente, y tu callback se ejecuta más tarde cuando el resultado está listo.
La concurrencia en Node es delegar la espera. Mientras una lectura de archivo o una consulta DNS está pendiente, el único hilo JS es libre de ejecutar otros callbacks. Nada en tu JavaScript se ejecuta en paralelo — existe exactamente una pila de llamadas — pero muchas operaciones pueden estar en vuelo a la vez porque las partes lentas ocurren en otro lugar.
Por eso “Node es de un solo hilo” es una verdad a medias que vale la pena corregir. La ejecución de JavaScript es de un solo hilo. El runtime no lo es: libuv mantiene un pool de hilos en segundo plano, y el sistema operativo gestiona los sockets de red en nombre de Node. Trata “de un solo hilo” como una afirmación sobre dónde se ejecuta tu código, no sobre el proceso en su conjunto.
Mito común: “Node es de un solo hilo.” Tu JavaScript se ejecuta en un hilo; el proceso de Node utiliza varios. Esa distinción es el punto central de este artículo.
Concurrencia vs. paralelismo, definidos con precisión
La concurrencia significa que múltiples tareas avanzan durante el mismo período intercalándose en un recurso compartido; el paralelismo significa que múltiples tareas se ejecutan en el mismo instante en núcleos separados. Una máquina de un solo núcleo ejecutando Node es concurrente pero no paralela: el event loop alterna rápidamente entre las operaciones en vuelo, pero solo se ejecuta un fragmento de JavaScript en cada momento. Los worker threads y el clustering añaden paralelismo genuino al introducir contextos de ejecución adicionales que el sistema operativo puede programar en diferentes núcleos.
La consecuencia práctica: la concurrencia resuelve problemas de espera (I/O), el paralelismo resuelve problemas de cómputo (CPU). Recurrir al mecanismo equivocado es la causa raíz de la mayoría de los errores de rendimiento en Node.
Las seis fases del event loop y la distinción de microtareas
Discover how at OpenReplay.com.
El event loop se ejecuta en un ciclo fijo de seis fases, y cada fase tiene su propia cola de callbacks que vacía completamente antes de pasar a la siguiente. Según la guía oficial del event loop de Node.js, las fases en orden son:
- Timers — ejecuta callbacks programados por
setTimeout()ysetInterval()cuyo umbral ha transcurrido. - Pending callbacks — ejecuta ciertos callbacks a nivel de sistema diferidos de un ciclo anterior.
- Idle, prepare — solo para uso interno.
- Poll — recupera nuevos eventos de I/O y ejecuta sus callbacks; el loop se bloqueará aquí esperando I/O si no hay nada más que hacer.
- Check — ejecuta los callbacks de
setImmediate(). - Close callbacks — ejecuta los manejadores de cierre como
socket.on('close', ...).
Nota la tercera fase: muchas explicaciones listan solo cinco y omiten idle/prepare, que existe pero está reservada para la gestión interna de libuv. Es real; simplemente nunca programas en ella directamente.
Las microtareas no son una fase
Las microtareas no son una fase del event loop. Los callbacks de process.nextTick se vacían primero, luego se vacía la cola de microtareas de promesas, y solo entonces el loop avanza a la siguiente macrotarea — por lo que process.nextTick tiene prioridad sobre Promise.then, que tiene prioridad sobre setTimeout. La guía de Node es explícita en que process.nextTick técnicamente no forma parte del event loop; su cola se procesa después de que la operación actual se completa, independientemente de la fase actual, y la cola de promesas la sigue — ambas antes de que el loop avance.
Esto da una prioridad clara de tres niveles: process.nextTick → microtareas de promesas → macrotareas (timers, I/O, setImmediate).
Una demostración concreta del orden de ejecución
La confusión clásica es setImmediate vs. setTimeout(0). Dentro de un callback de I/O el orden es determinista; en el nivel superior no lo es.
// Run on Node.js 24.16.0
const fs = require('node:fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('1: setTimeout(0)'), 0);
setImmediate(() => console.log('2: setImmediate'));
Promise.resolve().then(() => console.log('3: promise'));
process.nextTick(() => console.log('4: nextTick'));
});
Imprime:
4: nextTick
3: promise
2: setImmediate
1: setTimeout(0)
nextTick y la promesa se vacían antes de que el loop avance, con nextTick primero. Luego, como los callbacks se programaron desde dentro de un ciclo de I/O (la fase poll), el loop llega a la fase check a continuación, por lo que setImmediate se dispara antes de que el loop regrese a la fase timers. La guía de Node confirma que setImmediate() siempre se ejecuta antes que un timer cuando ambos se programan dentro de un ciclo de I/O. Si programas los mismos dos en el nivel superior, el orden es no determinista — no dependas de él fuera de un callback de I/O.
El pool de hilos de libuv: 4 por defecto, máximo 1024 — y qué lo usa realmente
El pool de hilos de libuv es un conjunto fijo de hilos en segundo plano — 4 por defecto, ampliable hasta un máximo de 1024 — que libuv utiliza para ejecutar operaciones que no tienen una primitiva de sistema operativo no bloqueante. Según la documentación del pool de hilos de libuv, ese máximo se establece mediante la variable de entorno UV_THREADPOOL_SIZE. (El límite aumentó de 128 a 1024 en libuv 1.30.0 — los artículos más antiguos que citan “128” están desactualizados.) El pool es compartido por todos los event loops de un proceso.
Lo que se ejecuta en él es una lista específica y finita. La documentación de CLI de Node.js para UV_THREADPOOL_SIZE nombra los consumidores: las APIs de fs (excepto los watchers y las variantes explícitamente síncronas), dns.lookup(), y las operaciones asíncronas de crypto y zlib como crypto.pbkdf2(), crypto.scrypt(), crypto.randomBytes(), crypto.generateKeyPair(), y la compresión de zlib.
Lo más importante aquí es lo que no lo usa. El I/O de red no toca el pool de hilos. Como explica la guía oficial Don’t Block the Event Loop, los sockets de red son gestionados por el mecanismo de polling del sistema operativo — epoll en Linux, kqueue en macOS/BSD, IOCP en Windows — y emergen directamente en la fase poll. También hay una sutileza con DNS: dns.lookup() (que llama a getaddrinfo) usa el pool, pero la familia dns.resolve*() (que usa c-ares) no, por lo que afirmar genéricamente que “DNS usa el pool” es incorrecto.
# Set before Node starts — the pool is preallocated on first use.
UV_THREADPOOL_SIZE=8 node server.js
Un problema operativo a tener en cuenta: libuv preasigna el número máximo de hilos en el primer uso del pool, por lo que UV_THREADPOOL_SIZE debe establecerse antes de ese momento — en la práctica, antes de que Node arranque. Mutar process.env.UV_THREADPOOL_SIZE después de que el pool ha sido utilizado no tiene ningún efecto.
Mito común: “Aumenta
UV_THREADPOOL_SIZEpara acelerar el trabajo de CPU.” AumentarUV_THREADPOOL_SIZEacelera el I/O concurrente, nunca el JavaScript intensivo en CPU — para trabajo de CPU necesitas un worker thread, porque el pool no ejecuta tu JavaScript. Ejecuta las operaciones a nivel C de libuv, no tus funciones JS.
Worker threads: paralelismo real para JavaScript intensivo en CPU
Los worker threads ejecutan JavaScript en paralelo en hilos separados por una razón — trabajo intensivo en CPU que de otro modo bloquearía el único hilo JS — y no ayudan con trabajo intensivo en I/O, que el I/O asíncrono integrado de Node ya gestiona de forma más eficiente. La documentación de worker_threads establece que los workers son útiles para realizar operaciones JavaScript intensivas en CPU y no ayudan mucho con trabajo intensivo en I/O, porque el I/O asíncrono integrado de Node es más eficiente de lo que los workers pueden ser.
El punto de precisión clave que la mayoría de los artículos pasan por alto: un worker thread no es “simplemente un hilo del sistema operativo.” Cada uno es un isolate de V8 separado con su propio event loop y su propio loop de libuv. Ese aislamiento es exactamente por qué los workers no pueden compartir objetos JavaScript ordinarios y por qué todo lo que envías mediante postMessage se copia.
Mito común: “Los workers son simplemente hilos del sistema operativo.” Cada worker es un isolate de V8 completo más su propio event loop y loop de libuv ejecutándose en un hilo — por eso no hay globales compartidos ni ámbito de clausura compartido entre el hilo principal y el worker.
Aislamiento y paso de mensajes
Los workers se comunican mediante paso de mensajes, y el payload se copia en profundidad. Los datos que pasas a través de workerData o postMessage() se clonan según el algoritmo de clonación estructurada de HTML — las funciones, los prototipos de clases y las referencias en vivo no sobreviven el viaje. La única escapatoria de la copia es la memoria compartida: los worker threads pueden compartir memoria solo a través de un SharedArrayBuffer (o transfiriendo un ArrayBuffer, lo que mueve la propiedad en lugar de copiarla). Todo lo demás se clona de forma estructurada.
// main.js — Run on Node.js 24.16.0
const { Worker } = require('node:worker_threads');
const worker = new Worker('./fib-worker.js', { workerData: { n: 42 } });
worker.on('message', (result) => console.log('fib(42) =', result));
worker.on('error', (err) => console.error(err));
// fib-worker.js
const { parentPort, workerData } = require('node:worker_threads');
function fib(n) {
return n < 2 ? n : fib(n - 1) + fib(n - 2);
}
parentPort.postMessage(fib(workerData.n));
isMainThread permite que un único archivo bifurque entre los dos roles cuando prefieres no dividir archivos. Para la superficie completa de la API — MessageChannel, MessagePort, listas de transferencia, receiveMessageOnPort — consulta la documentación de worker_threads. Comparado con los hilos en C++ o Java, el modelo intercambia la memoria compartida por defecto (y los locks, mutexes y condiciones de carrera que conlleva) por el aislamiento por defecto: más seguro, con la copia como coste.
Usa un pool, no un worker por tarea
Crear un Worker para cada solicitud es ineficiente. La documentación de Node es explícita en que debes usar un pool de workers en la práctica, porque la sobrecarga de crear workers probablemente superaría sus beneficios de otro modo. El pool estándar de la comunidad es piscina. Piscina es una implementación rápida y eficiente de pool de worker threads para Node.js; su última versión es la 5.2.0. Escribe 5.x en tu rango de package.json y fija la versión de punto en CI.
Hay un corolario que sorprende a muchos: no muevas trabajo ya asíncrono a un worker. Las operaciones asíncronas de crypto, fs y zlib ya se ejecutan en los hilos en segundo plano de libuv, por lo que envolverlas en un worker simplemente programa un hilo para programar otro hilo, sin ningún beneficio. Los workers justifican su uso solo para JavaScript síncrono e intensivo en CPU.
Ejemplo práctico: una ruta intensiva en CPU que bloquea a todos
Una tarea de CPU síncrona en el hilo principal congela el event loop para cada solicitud en vuelo, no solo para la que la desencadenó. Aquí está el fallo en un manejador de Express:
// server-blocking.js — Run on Node.js 24.16.0, express 5.x
const express = require('express');
const app = express();
function fib(n) {
return n < 2 ? n : fib(n - 1) + fib(n - 2);
}
app.get('/report', (req, res) => {
res.json({ value: fib(45) }); // blocks the event loop for seconds
});
app.get('/health', (req, res) => res.send('ok'));
app.listen(3000);
Mientras fib(45) se ejecuta, /health no devuelve nada — el único hilo está ocupado calculando, y cada solicitud concurrente se encola detrás de ella. La solución es delegar el trabajo de CPU a un pool y esperar el resultado con await, liberando el event loop para seguir sirviendo:
// server-pooled.js — Run on Node.js 24.16.0, express 5.x, piscina 5.2.0
const express = require('express');
const Piscina = require('piscina');
const path = require('node:path');
const pool = new Piscina({ filename: path.resolve(__dirname, 'fib-task.js') });
const app = express();
app.get('/report', async (req, res) => {
const value = await pool.run({ n: 45 }); // runs on a worker; loop stays free
res.json({ value });
});
app.get('/health', (req, res) => res.send('ok'));
app.listen(3000);
// fib-task.js
module.exports = ({ n }) => {
const fib = (x) => (x < 2 ? x : fib(x - 1) + fib(x - 2));
return fib(n);
};
Ahora fib(45) se ejecuta en un worker, el event loop permanece responsivo, y /health responde inmediatamente mientras la ruta pesada calcula en segundo plano.
Un event loop bloqueado tiene una firma característica en producción: como una tarea de CPU síncrona congela el único hilo JS para cada solicitud en vuelo, se manifiesta como muchos usuarios concurrentes bloqueándose en el mismo instante de tiempo real — no como la mala red de un único usuario. Ese patrón de congelamiento correlacionado es exactamente lo que la reproducción de sesiones muestra en sesiones simultáneas, y puedes confirmarlo en tiempo de ejecución con monitorEventLoopDelay de node:perf_hooks, donde un p99 alto indica que el loop (o el pool de hilos) está saturado.
Clustering y múltiples procesos: escalando I/O entre núcleos
El clustering escala una aplicación Node entre núcleos de CPU bifurcando múltiples procesos, cada uno ejecutando la aplicación completa con su propio event loop y memoria, compartiendo un socket de escucha. El módulo cluster es la forma integrada de hacer esto; es la herramienta adecuada cuando tu cuello de botella es el rendimiento intensivo en I/O y quieres utilizar todos los núcleos de la máquina en lugar de uno.
La distinción respecto a los worker threads es importante. Los workers de cluster son procesos separados con memoria completamente aislada que se comunican mediante IPC; los worker threads son hilos separados dentro de un proceso que pueden compartir memoria mediante SharedArrayBuffer. El cluster trata de gestionar más solicitudes concurrentes entre núcleos; los worker threads tratan de sacar el JavaScript intensivo en CPU del hilo de solicitudes. En la práctica, un servicio de alto rendimiento a menudo usa ambos: cluster (o un gestor de procesos / réplicas de contenedores) para abarcar núcleos, y un pool de workers dentro de cada proceso para absorber picos ocasionales de CPU.
Una guía de decisión: async, pool, worker o cluster
Elige según tu cuello de botella: async/await para trabajo intensivo en I/O, worker threads para JavaScript intensivo en CPU, cluster (o múltiples procesos) para escalar la carga intensiva en I/O entre núcleos, y un pool de workers como piscina cuando de otro modo pagarías el coste de arranque del worker en cada solicitud.
| Herramienta | ¿Ejecuta JS en paralelo? | ¿Comparte memoria? | Mejor para | Coste principal |
|---|---|---|---|---|
async/await + event loop | No | N/A (un hilo) | Trabajo intensivo en I/O (red, BD, archivos) | Bloquea si haces trabajo de CPU |
| Pool de hilos de libuv | No (ejecuta C, no tu JS) | N/A | fs/dns.lookup/crypto/zlib concurrentes | Tamaño fijo; no para tu JS |
| Worker threads (+ pool) | Sí | Solo mediante SharedArrayBuffer | JavaScript intensivo en CPU | Arranque + copia por clonación estructurada |
| Cluster / múltiples procesos | Sí | No (solo IPC) | Escalar carga intensiva en I/O entre núcleos | Sobrecarga de proceso; sin estado compartido |
Reglas rápidas:
- ¿Intensivo en I/O y aún no paralelizado? Usa
async/await. El pool de hilos y el sistema operativo ya te dan concurrencia de forma gratuita. - ¿Una ruta realiza cómputo síncrono pesado? Delégalo a un worker thread, detrás de un pool para su reutilización.
- ¿Saturando un núcleo bajo tráfico concurrente? Escala con cluster entre núcleos (o ejecuta múltiples réplicas de contenedores).
- ¿Tentado de aumentar
UV_THREADPOOL_SIZEpara ganar velocidad? Solo si tienes un cuello de botella de I/O en operaciones respaldadas por el pool (muchas operaciones concurrentes defs/crypto). Nunca acelerará el JavaScript intensivo en CPU.
Una nota prospectiva para el código que escribes hoy: a partir de Node.js 27 en octubre de 2026, el proyecto pasa a una versión mayor por año, poniendo fin a la cadencia par/impar. Sigue el calendario de versiones de Node.js y compila contra el LTS actual.
El modelo mental que mantiene todo esto claro es dejar de preguntar “¿es Node de un solo hilo?” y empezar a preguntar “¿cuál es mi cuello de botella?” La espera es el trabajo del event loop; el cómputo en paralelo es el del pool de workers; distribuir la carga entre núcleos es el del cluster. Perfila el endpoint lento, identifica si está atascado esperando o atascado calculando, y la herramienta correcta se desprende directamente — luego verifica la corrección con monitorEventLoopDelay antes de desplegarlo.
Preguntas Frecuentes
¿Cuál es la diferencia entre worker threads y el módulo cluster en Node.js?
Los worker threads son hilos separados dentro de un proceso, cada uno un isolate de V8 con su propio event loop, que pueden compartir memoria mediante un SharedArrayBuffer; el cluster bifurca procesos separados con memoria completamente aislada que se comunican mediante IPC y comparten un socket de escucha. Usa worker threads para mover el JavaScript intensivo en CPU fuera del hilo de solicitudes, y cluster para escalar la carga intensiva en I/O entre núcleos de CPU. Los servicios de alto rendimiento a menudo combinan ambos.
¿Por qué aumentar UV_THREADPOOL_SIZE no hace mi código intensivo en CPU más rápido?
El pool de hilos de libuv ejecuta operaciones a nivel C como llamadas al sistema de archivos, dns.lookup, crypto asíncrono y zlib, no tu JavaScript. Aumentar UV_THREADPOOL_SIZE solo incrementa cuántas de esas operaciones de tipo I/O se ejecutan concurrentemente; nunca acelera el JavaScript intensivo en CPU porque ese código sigue ejecutándose en el único hilo JS. Para trabajo de CPU necesitas un worker thread, que ejecuta JavaScript en paralelo en su propio isolate de V8.
¿Gestiona Node.js las solicitudes de red en el pool de hilos de libuv?
No. Los sockets de red son gestionados por el mecanismo de polling del sistema operativo, epoll en Linux, kqueue en macOS y BSD, e IOCP en Windows, y emergen directamente en la fase poll del event loop. El pool de hilos de libuv sirve operaciones de sistema de archivos, dns.lookup mediante getaddrinfo, crypto asíncrono y zlib, no el I/O de red. Ten en cuenta que las funciones dns.resolve utilizan la biblioteca c-ares y también evitan el pool, por lo que 'DNS usa el pool' solo es cierto para dns.lookup.
¿Pueden los worker threads compartir objetos JavaScript con el hilo principal?
No. Los workers se comunican mediante paso de mensajes, y los datos pasados a través de workerData o postMessage se copian en profundidad usando el algoritmo de clonación estructurada de HTML, por lo que las funciones, los prototipos de clases y las referencias en vivo no sobreviven el viaje. La única forma de compartir memoria es mediante un SharedArrayBuffer, o transfiriendo un ArrayBuffer, lo que mueve la propiedad en lugar de copiarla. Este aislamiento por defecto evita los locks y las condiciones de carrera de la programación con memoria compartida en lenguajes como C++ o Java.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k