🧪 Problem-Driven Systems Lab

🟢 Node.js#

Versión fijada: 22 (LTS) · Imagen base: node:22-alpine · Hub: :8300 · Casos operativos: 20 / 20

⬅️ Volver a los perfiles de lenguaje · 🗺️ Mapa de stacks · 🔄 Protocolo de actualización


🪪 Identidad#

Node.js es un runtime de JavaScript construido sobre V8 (el motor de Chrome) con un modelo de I/O no bloqueante y orientado a eventos. Su propuesta original —un solo hilo que nunca espera— resultó ser una respuesta excelente al problema dominante de los servicios web: la mayoría del tiempo de una request se va esperando a la red o al disco, no calculando.

Para qué se usa en la industria: APIs y BFF, servicios de tiempo real (websockets, streaming), herramientas de build del ecosistema frontend, funciones serverless y prototipado rápido. Es la opción por defecto cuando el equipo ya escribe JavaScript en el navegador y no quiere pagar el costo de dos lenguajes.

Por qué está en este laboratorio: porque su restricción —un solo hilo— convierte en visible lo que en otros stacks queda escondido. Si una operación bloquea, no degrada: para todo. El event_loop_lag_ms que el caso 01 expone no tiene equivalente en PHP ni en Python, y es la señal más honesta de saturación de todo el repositorio.


⚙️ Modelo de ejecución#

Event loop de un solo hilo, con I/O asincrónico delegado al pool de libuv.

ConsecuenciaDónde se nota
Una operación bloqueante detiene el proceso enteronode:sqlite expone DatabaseSync, que es síncrono: cada query del N+1 bloquea a todos los demás clientes. Es el peor lugar posible para ese bug, y por eso el caso lo elige — caso 01
El lag del event loop es mediblemonitorEventLoopDelay reporta cuánto se retrasó el loop. Ningún otro runtime del lab tiene una señal tan directa de "estoy saturado" — caso 01
La cancelación es un objeto de primera claseAbortSignal se pasa a fetch, a una promesa propia o a un EventTarget. El deadline queda desacoplado de la biblioteca HTTP — caso 04
El CPU-bound necesita otro hiloworker_threads es la única salida real para no bloquear el loop — caso 11

🧰 Primitivas que usa el laboratorio#

CasoPrimitiva centralPor qué esta y no otra
01 · API lentanode:sqlite (DatabaseSync) + monitorEventLoopDelaySQLite en la stdlib, sin npm install. El lag del loop es la señal propia del stack
02 · N+1node:sqlite con db.prepare()Statement preparado; el N+1 se vuelve visible por el conteo de db_hits
03 · ObservabilidadAsyncLocalStorageContexto que sobrevive a los saltos asincrónicos. Limitación: nada impide filtrarlo
04 · TimeoutsAbortController + AbortSignal.timeout(ms)Deadline sin atornillar timers a mano; la cancelación se propaga al runtime
05 · Memoriaprocess.memoryUsage()Distingue heap de V8 de RSS del proceso — más de lo que ofrecen PHP y Python
06 · PipelineObjeto en memoria, single-threadSin lock: el modelo de un solo hilo es la sección crítica
07 · MonolitoMap<consumer, handler>Tabla de routing mutable en runtime, legible y directa
08 · ExtracciónEventEmitter + Proxy como ACLLo más idiomático del set para un bus de eventos. Limitación: los subscribers son síncronos
09 · Integración externaAbortSignal.timeout(250) + breaker de móduloEl mismo signal sirve para fetch y para una promesa propia
10 · Sobre-arquitecturaMap + JSON.stringify por hopEl costo de cada salto queda cobrado en CPU real
11 · Reportesworker_threadsLa única forma de sacar el CPU del loop sin frenar el proceso
12 · Punto únicooptional chaining ?.Cómodo. Limitación: propaga undefined en silencio hasta que explota tres capas más arriba
13 · Cache stampedeMap<key, Promise>La Promise ya es el single-flight. Tres líneas — y el orden del set es toda la garantía
14 · Pool de conexionesAbortSignal.timeout + finallySin deadline, el que espera es una Promise invisible que no responde nunca
15 · BackpressureWritable con highWaterMarkEl backpressure es parte del protocolo del runtime — e ignorarlo compila
16 · IdempotenciaMap.has() + set()Atómico por el modelo de un hilo — y por eso deja de ser correcto con dos procesos
17 · Migración sin downtimeel event loop es el lockNi siquiera el timeout del lector puede dispararse: no falla, no responde
18 · Arranque en fríoV8 en capas · --build-snapshotMide 1,1x, pero su cold start real vive en el grafo de require, que este caso no alcanza
19 · Deriva del índiceel await que faltaEl único stack donde el bug se produce por NO escribir algo
20 · DLQ olvidadainstanceof + error.causeFrágil por diseño: se rompe entre copias de paquete, workers y bibliotecas nativas

💡 El patrón que solo se ve mirando la columna entera: Node es el stack donde más soluciones dependen de la disciplina y menos del lenguaje. AsyncLocalStorage funciona pero nada impide filtrarlo; ?. propaga undefined sin avisar; el bus de eventos notifica en línea. A cambio, tiene la mejor primitiva de cancelación del set.


📈 Rendimiento: qué mide el laboratorio y cómo reproducirlo#

⚠️ Este repositorio no publica benchmarks entre lenguajes. Se mide la pendiente dentro de cada stack: legacy contra optimized, mismo runtime, misma máquina.

Node tiene la señal de saturación más directa del laboratorio:

SeñalDe dónde saleQué caso la expone
event_loop_lag_msperf_hooks.monitorEventLoopDelay01, 11
heapUsed · rssprocess.memoryUsage()05
avg_ms · p95_ms · p99_msmuestras en memoria01, 02, 10
db_hits por requestcontador alrededor de node:sqlite01, 02

Reproducir la medición del caso 01 (bloqueo del event loop):

bash
docker compose -f compose.nodejs.yml up -d --build
curl -s localhost:8300/01/metrics                          # event_loop_lag_ms en reposo
for i in $(seq 1 20); do curl -s "localhost:8300/01/report-legacy?limit=50" & done; wait
curl -s localhost:8300/01/metrics                          # el lag sube: el N+1 sincronico bloquea el loop
for i in $(seq 1 20); do curl -s "localhost:8300/01/report-optimized?limit=50" & done; wait
curl -s localhost:8300/01/metrics                          # db_hits constante, el lag vuelve a la linea base

Especificación de rendimiento que este stack verifica y ningún otro puede: en Java o Go un N+1 lento degrada esa request. En Node bloquea el proceso completo, y event_loop_lag_ms lo cuantifica en milisegundos. Es la demostración más limpia del repositorio de por qué el modelo de ejecución importa.


🚧 Límites, problemas sin solución y desafíos#

LímitePor qué importaDónde se ve
Una operación síncrona bloquea todoDatabaseSync de node:sqlite es síncrono. El N+1 no degrada: para el servidor enterocaso 01
Sin paralelismo real sin worker_threadsTodo el CPU-bound compite por el mismo hilo. Sacarlo afuera implica serializar mensajes entre workerscaso 11
AsyncLocalStorage no impide la fugaFunciona, pero nada en el lenguaje evita almacenar el contexto donde no correspondecaso 03
?. esconde el error hasta tres capas despuésEl undefined viaja en silencio y explota lejos del origen. Es lo contrario de Option<T>caso 12
Sin tipos en runtimeNada valida la firma del handler al registrarlo; el error llega con el requestcaso 07
node:sqlite sigue siendo experimental en 22Requiere el flag --experimental-sqlite. La API puede cambiar entre versiones menorescasos 01 y 02

Desafío abierto del stack en este laboratorio: los casos 01 y 02 dependen de node:sqlite, que en Node 22 sigue detrás de --experimental-sqlite. Es la dependencia más frágil del repositorio: una API experimental puede cambiar sin ceremonia. Está anotada como disparador explícito en scripts/language_drift.py.


🏆 Dónde gana y dónde pierde en el laboratorio#

Agregado de los veredictos de las 19 comparativas que rankean: 0 primeros puestos, media 5.1.

  • 🥈 Segundo en 04AbortController es la mejor primitiva de cancelación del set después de context.Context, y la única que se pasa igual a fetch que a una promesa propia.
  • 🥉 Tercero en 08 y 13EventEmitter + Proxy para el bus de eventos; Map<key, Promise> como el single-flight más corto del lab.
  • 7º en 20 — el único stack donde la herramienta de clasificación es frágil por diseño: instanceof se rompe entre copias de paquete, workers y bibliotecas nativas, y la alternativa práctica es comparar strings.
  • 7º en 20 — el único stack donde la herramienta de clasificación es frágil por diseño: instanceof se rompe entre copias de paquete, workers y bibliotecas nativas, y la alternativa práctica es comparar strings.
  • 7º en 19 — el único stack donde el bug se produce por no escribir algo: indice.escribir(doc) sin await compila, parece correcto y manda el error a un rechazo sin dueño.
  • 5º en 18 — plano en lo medido (1,1x), pero su arranque en frío real vive en el grafo de require, que este caso no alcanza; los snapshots lo resuelven y están fuera del camino por defecto.
  • 7º en 17 — el lock exclusivo es el event loop entero, y ni siquiera el timeout del lector puede dispararse: no falla rápido, no responde.
  • 6º en 01, 03, 09, 14, 15 y 16 — en el 16 con el matiz más incómodo del lab: el código correcto es el más corto de los siete y deja de ser correcto al escalar a dos procesos, sin ningún aviso. — en el 15 con un matiz: es el único stack donde el backpressure es parte del protocolo del runtime, y también el único donde ignorarlo compila y pasa los tests. — el modelo de un solo hilo y la falta de respaldo del lenguaje le cuestan tres casos.

Lectura honesta: Node no gana ningún caso, y el laboratorio no lo maquilla. Lo que sí hace es ganar el argumento del caso 01 por el lado contrario: es el peor stack posible para un N+1 síncrono, y precisamente por eso es donde el problema se ve con más claridad. Un stack puede ser valioso para enseñar sin ser el que mejor resuelve.


🔄 Ciclo de versiones#

Versión fijada hoy22 LTS (node:22-alpine)
Cadencia upstreamUna mayor cada 6 meses; las pares pasan a LTS en octubre
Política de soporteLTS: 30 meses de mantenimiento
Producto en endoflife.datenodejs

Qué revisar en el próximo salto:

  1. 🚨 node:sqlite fuera de experimental (Node 24+) — el flag --experimental-sqlite de los casos 01 y 02 sobraría, y la API podría haber cambiado. Hay que revisar el código, no solo el Dockerfile.
  2. Cambios en AbortSignal — es la primitiva central de los casos 04 y 09.
  3. Evolución de worker_threads — el argumento del caso 11 depende de que sacar el CPU del loop siga siendo la única salida.
  4. Cambios en el GC de V8 — afectan la lectura de process.memoryUsage() en el caso 05.

El detalle del procedimiento está en docs/language-upgrade-protocol.md.


🚀 Levantar el stack#

bash
docker compose -f compose.nodejs.yml up -d --build

Los 20 casos quedan servidos en http://localhost:8300/NN/. Cada caso trae además su propio compose.yml para correrlo aislado — útil en los casos 01 y 11, donde la medición del event loop necesita el runtime sin ruido de los otros once casos.

Ver esta carpeta en GitHub ↗