HU

Humano

← Blog

16 de marzo de 2026

Cuando el event loop de Node.js es el cuello de botella

12 instancias de Node.js donde el enrichment CPU-bound bloquea el event loop. Extraer el hot path a código nativo redujo las instancias a 3.

node.jsevent-looprustlatenciaanti-patrón

Un servicio de enriquecimiento de eventos en Node.js (Express) recibe 1,200 RPS. Cada request trae un JSON de ~12KB, aplica campos calculados, geo-lookups desde un cache local, validación de schema con ajv y normalización de campos. El resultado se envía a la siguiente etapa del pipeline.

El equipo tiene 12 instancias. La latencia p50 es 85ms, la p99 es 2.1 segundos. El costo de infraestructura es $10,800/mes. La presión es reducir la latencia p99 a menos de 200ms y soportar crecimiento a 2,000 RPS sin triplicar el gasto.

La reacción habitual: agregar más instancias. El problema: ya llevan 12 y la mejora es estrictamente lineal. Pasar de 12 a 24 duplica el costo pero no cambia la latencia de cada request individual. La latencia p99 sigue en 2 segundos porque el problema no es capacidad agregada — es qué pasa dentro de cada instancia.

Cómo funciona el event loop de Node.js

Node.js ejecuta JavaScript en un solo hilo. Toda la lógica de la aplicación — handlers de Express, callbacks, promises — corre en el event loop, un ciclo que procesa eventos uno a uno. El I/O (red, disco, DNS) se delega a libuv, que usa threads del sistema operativo por debajo. Pero el código JavaScript siempre se ejecuta en un solo hilo.

Esto funciona bien cuando el servicio es I/O bound: la mayor parte del tiempo se gasta esperando respuestas de bases de datos, APIs externas o el filesystem. El event loop despacha el I/O, libera el hilo, y atiende otros requests mientras espera. Un solo proceso de Node.js puede manejar miles de conexiones concurrentes si el trabajo por request es liviano.

El modelo se quiebra cuando hay trabajo CPU-bound sincrónico. Mientras una función JavaScript ejecuta cómputo — parsing, validación, transformaciones — el event loop está bloqueado. Ningún otro request puede procesarse en esa instancia hasta que la función termine.

El diagnóstico: event loop bloqueado por enrichment

Un flame graph de 30 segundos con clinic.js flame muestra dónde se gasta el CPU. En este caso:

Los 12-18ms son trabajo sincrónico continuo. Durante ese tiempo, el event loop no puede hacer nada más.

La aritmética del bloqueo

A 80 RPS por instancia (1,200 RPS / 12 instancias, suponiendo distribución uniforme):

Tiempo bloqueado por segundo = 80 RPS × 15ms promedio = 1,200ms

El event loop de una instancia tiene 1,000ms por segundo. Con 1,200ms de trabajo CPU-bound, está sobresaturado. Los requests se encolan esperando que el event loop los atienda, y esa cola es lo que produce la latencia p99 de 2.1 segundos.

Esto es Amdahl's Law en su forma más directa: el 100% del trabajo de enrichment es serializado (un solo hilo). Agregar instancias distribuye los requests entre más event loops, pero cada event loop individual sigue bloqueado 15ms por request. La latencia por request no mejora — solo la capacidad total.

Lo que el equipo ya intentó

Worker threads

Node.js ofrece worker_threads para ejecutar código JavaScript en hilos separados. El equipo implementó un pool de 4 workers que recibían el evento y ejecutaban el enrichment fuera del event loop principal.

El problema: el evento (~12KB de JSON) debe serializarse para pasar al worker y deserializarse del otro lado. Eso agrega ~3ms de overhead por request. Con SharedArrayBuffer se puede evitar la copia, pero la lógica de enrichment necesita acceso al Map de geo-lookup, que no es trivial de compartir entre hilos sin serialización.

Resultado: la latencia p99 bajó de 2.1s a 1.4s. El overhead de serialización inter-thread se come parte de la ganancia. Y la complejidad del pool de workers — manejo de errores, reinicio de workers que fallan, balanceo de carga entre ellos — agregó superficie de bugs.

Clustering con PM2

Correr múltiples procesos Node.js en la misma máquina. Funcionalmente equivalente a agregar instancias: escala linealmente, costo lineal. Cada proceso tiene su propio event loop, pero cada uno sigue bloqueado 15ms por request.

Migrar de Express a Fastify

El equipo probó Fastify por su menor overhead de framework. El resultado: ~20% de mejora en el tiempo de routing y serialización de respuesta. Pero el bottleneck — la función de enrichment — no tiene nada que ver con el framework HTTP. El enrichment sigue tomando 12-18ms.

Optimizar el framework cuando el 85% del CPU se gasta en lógica de negocio es optimizar el 15% equivocado.

La intervención: extraer el hot path a un addon nativo

Si la función de enrichment es el problema, y esa función es pura computación (recibe JSON, devuelve JSON, sin I/O, sin estado externo), se puede extraer a código que no tenga la limitación del single-thread de V8.

La opción: una biblioteca nativa en Rust compilada como addon de Node.js vía napi-rs. El servicio la invoca como una función normal de JavaScript, pero la ejecución ocurre en código nativo fuera del event loop de V8.

Qué hace el addon

Input:  Buffer (payload JSON crudo)
Output: Buffer (evento enriquecido, serializado como JSON)

Internamente:

  1. Parsea con serde_json a structs tipados — sin árbol genérico, sin allocaciones intermedias innecesarias
  2. Valida el schema directamente sobre los structs. La validación compilada en Rust elimina el overhead del intérprete de JavaScript
  3. Geo-lookup desde un archivo memory-mapped con memmap2 — el sistema operativo maneja el cache de páginas, sin copias adicionales, compartido entre todos los requests sin allocación extra
  4. Normaliza y calcula campos derivados sobre el struct
  5. Serializa el resultado de vuelta a bytes JSON

Todo se ejecuta en código nativo. El event loop de Node.js solo invoca la función y recibe el resultado — el tiempo de bloqueo del event loop se reduce al overhead de napi-rs para cruzar la frontera JS/nativo, que es ~20-50μs.

Por qué 12-18ms se convierte en 0.8-1.2ms

La diferencia es de ejecución: el mismo algoritmo compilado a código máquina nativo corre entre 10x y 15x más rápido que interpretado/JIT-compiled en V8 para este tipo de workload. Las razones principales:

Alternativas al addon nativo

AlternativaEnrichment timeComplejidadTrade-off
Worker threads (JS)12-18ms + 3ms overheadMediaNo reduce el tiempo de cómputo, solo desbloquea el event loop. Overhead de serialización
WebAssembly (WASM)2-4msMediaMás rápido que JS pero más lento que nativo por restricciones del sandbox de WASM. No puede usar memmap2
Rust addon via napi-rs0.8-1.2msMedia-altaMáximo rendimiento. Requiere compilar el addon para la plataforma target
Microservicio Go/Rust separado0.8-1.2ms + 1-3ms redAltaAgrega latencia de red y complejidad operativa (deploy, monitoreo, retry logic)

El addon nativo tiene el mejor ratio rendimiento/complejidad para este caso. WASM es una opción válida si el equipo prefiere evitar la compilación nativa por plataforma, aceptando ~3x menos rendimiento. Worker threads son la opción más simple si el objetivo es solo desbloquear el event loop sin optimizar el tiempo de enrichment.

El resultado

MétricaAntes (JS enrichment)Después (Rust via napi-rs)
Enrichment time por evento12-18ms0.8-1.2ms
Event loop utilization a 80 RPS/instancia96-144% (saturado)6.4-9.6%
Instancias necesarias a 1,200 RPS123
Costo de infraestructura mensual$10,800/mes$2,700/mes
Latencia p5085ms12ms
Latencia p992,100ms45ms
Throughput máximo por instancia~80 RPS~800 RPS

El event loop de cada instancia pasó de estar saturado a usar menos del 10% en CPU-bound work. Eso deja margen para manejar picos de tráfico, absorber requests concurrentes sin encolamiento, y crecer 3-4x antes de necesitar otra instancia.

El ahorro en infraestructura es $8,100/mes — $97,200/año. El addon en Rust son ~450 líneas de código.

Cuándo este patrón aplica

Cuándo no

La pregunta correcta no es "¿debería usar Rust?" sino "¿el event loop está saturado por trabajo CPU-bound, y ese trabajo es estable, autocontenido y lo suficientemente costoso como para justificar una extracción?"

Si la respuesta es sí, el addon nativo es una de las intervenciones con mejor ratio de impacto por complejidad. Si la respuesta es no, hay opciones más simples.


Si tu servicio de Node.js tiene latencia p99 alta que no mejora al agregar instancias y sospechas que el event loop está saturado, podemos revisar el flame graph y la utilización del event loop en una conversación de 15 minutos sin costo.

Referencias