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.
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:
- La función de enrichment consume 12-18ms de CPU sincrónico por request
ajv.validate()para el schema de 40+ campos: ~3-5ms- Parsing y normalización de campos anidados: ~4-6ms
- Geo-lookup con búsqueda en un Map de 200K entries: ~2-3ms
- Serialización del evento enriquecido: ~3-4ms
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:
- Parsea con
serde_jsona structs tipados — sin árbol genérico, sin allocaciones intermedias innecesarias - Valida el schema directamente sobre los structs. La validación compilada en Rust elimina el overhead del intérprete de JavaScript
- 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 - Normaliza y calcula campos derivados sobre el struct
- 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:
- serde_json parsea JSON a structs tipados sin crear objetos intermedios en un heap gestionado. V8 crea objetos JavaScript con overhead de propiedades dinámicas, hidden classes y GC tracking
- La validación de schema en Rust es una serie de comparaciones directas sobre structs. En JavaScript, ajv genera código de validación dinámico que V8 eventualmente optimiza con el JIT, pero el startup y la presión de GC reducen la eficiencia
- El geo-lookup con
memmap2accede a la memoria directamente. En JavaScript, el Map de 200K entries tiene overhead de hash table dinámica, resizing y GC de los entries - Rust no tiene garbage collector. No hay pausas, no hay presión de memoria, no hay degradación progresiva
Alternativas al addon nativo
| Alternativa | Enrichment time | Complejidad | Trade-off |
|---|---|---|---|
| Worker threads (JS) | 12-18ms + 3ms overhead | Media | No reduce el tiempo de cómputo, solo desbloquea el event loop. Overhead de serialización |
| WebAssembly (WASM) | 2-4ms | Media | Má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-rs | 0.8-1.2ms | Media-alta | Máximo rendimiento. Requiere compilar el addon para la plataforma target |
| Microservicio Go/Rust separado | 0.8-1.2ms + 1-3ms red | Alta | Agrega 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étrica | Antes (JS enrichment) | Después (Rust via napi-rs) |
|---|---|---|
| Enrichment time por evento | 12-18ms | 0.8-1.2ms |
| Event loop utilization a 80 RPS/instancia | 96-144% (saturado) | 6.4-9.6% |
| Instancias necesarias a 1,200 RPS | 12 | 3 |
| Costo de infraestructura mensual | $10,800/mes | $2,700/mes |
| Latencia p50 | 85ms | 12ms |
| Latencia p99 | 2,100ms | 45ms |
| 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
- El servicio Node.js tiene trabajo CPU-bound sincrónico que bloquea el event loop >60% del tiempo (medible con
clinic.jsomonitorEventLoopDelay) - La función CPU-bound es determinística: recibe datos, devuelve datos, sin I/O ni estado externo
- El throughput es >200 RPS donde el costo por request se multiplica
- La lógica es estable — no cambia cada sprint. Compilar y mantener un addon nativo tiene sentido si la función no se reescribe cada dos semanas
Cuándo no
- Si el servicio es I/O bound (esperando base de datos, APIs externas, filesystem), el event loop no es el cuello de botella. Node.js maneja bien el I/O concurrente — agregar instancias o mejorar el backend sí ayuda en ese caso
- Si el CPU work es <2ms por request, el event loop no se satura a throughputs razonables. A 200 RPS con 2ms de CPU por request, el event loop usa 400ms de 1,000ms — hay margen. El overhead del addon puede no justificarse
- Si el equipo necesita iterar rápidamente en la lógica de enrichment, Rust tiene tiempos de compilación más largos. Si las reglas de transformación cambian cada sprint, mantener el addon se vuelve un costo operativo
- Si worker threads con
SharedArrayBufferresuelven el problema sin la complejidad de un addon nativo, es la opción más simple. Esto funciona cuando el objetivo es desbloquear el event loop y el tiempo de enrichment (12-18ms) es aceptable
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
- Node.js Event Loop, Timers, and
process.nextTick()— documentación oficial del modelo de ejecución single-threaded de Node.js - libuv Design Overview — implementación del event loop de Node.js y su delegación de I/O a threads del sistema operativo
- Node.js
worker_threadsmodule — documentación oficial de worker threads, incluyendo limitaciones de serialización ySharedArrayBuffer - clinic.js — suite de diagnóstico de performance para Node.js que incluye flame graphs y detección de event loop delay
- napi-rs — framework para construir addons nativos de Node.js en Rust con bindings ergonómicos a la N-API
- serde_json — docs.rs — parser JSON de Rust usado en el addon para deserialización a structs tipados
- memmap2 — docs.rs — crate de Rust para archivos memory-mapped, usado para el geo-lookup sin allocaciones adicionales
- ajv — JSON Schema Validator — validador de JSON Schema para JavaScript, usado en el servicio original
- Amdahl's Law — Wikipedia — la fórmula de speedup máximo limitado por la fracción serializada del trabajo