Node.js en Producción: Aislamiento de Errores y Rendimiento Sostenido
Patrones para mantener la latencia estable bajo carga: límites de proceso, backpressure en streams y observabilidad útil, sin depender de frameworks pesados.
Node.js es cómodo de arrancar y sorprendentemente fácil de dejar en un estado frágil. Un único hilo compartido para toda la aplicación convive bien con cargas moderadas y muy mal con una excepción no capturada, un bucle de espera activa o una cola que crece sin freno. Este artículo resume los patrones que usamos para que un servicio Node.js mantenga latencia predecible cuando la carga deja de ser la del entorno de desarrollo.
1. El modelo de un solo hilo y sus límites
El event loop procesa un evento a la vez. Eso significa que cualquier trabajo síncrono pesado bloquea todo lo demás: ninguna petición avanza mientras se serializa un objeto enorme o se calcula un hash costoso. La consecuencia práctica es una regla: el hilo principal no debe hacer trabajo intensivo de CPU.
Para tareas de este tipo existen dos vías razonables:
worker_threads: ejecutar el cálculo en un hilo separado y devolver el resultado por mensaje.- Delegar a un proceso externo: mover el trabajo a un servicio dedicado, por ejemplo en un lenguaje distinto.
El error frecuente no es elegir mal la herramienta, sino mezclar CPU y E/S en el mismo request y descubrir el problema solo cuando la latencia se degrada en producción.
2. Aislar errores: del try/catch global a los límites de proceso
Envolver todo el arranque en un try/catch no protege el proceso. Una excepción lanzada dentro de un manejador asíncrono no vuelve al punto de arranque: termina en un uncaughtException o en un rejection no manejado de una promesa.
process.on('unhandledRejection', (reason) => {
logger.fatal({ err: reason }, 'Unhandled promise rejection');
shutdown('unhandledRejection');
});
process.on('uncaughtException', (error) => {
logger.fatal({ err: error }, 'Uncaught exception');
shutdown('uncaughtException');
});
La estrategia por defecto ante estos eventos debe ser terminar el proceso de forma ordenada, no intentar continuar. Un proceso que sobrevive a un estado inconsistente suele ser peor que uno que reinicia limpio. El orquestador se encargará de levantar una instancia nueva.
Además, cada request debe tener su propia frontera de error. Un middleware que captura errores por petición evita que el fallo de un usuario derribe el servicio entero:
async function withErrorBoundary(req, res, next) {
try {
await next();
} catch (error) {
logger.error({ err: error, path: req.path }, 'Request failed');
res.status(500).json({ error: 'internal_error' });
}
}
3. Backpressure: no acumules trabajo en memoria
Cuando un productor es más rápido que un consumidor, la diferencia se acumula. Sin un mecanismo explícito, esa acumulación vive en memoria hasta que el proceso consume todo lo disponible y muere. Los streams de Node.js llevan backpressure incorporada, pero hay que usarla: concatenar data en una variable es precisamente lo que la desactiva.
import { pipeline } from 'node:stream/promises';
import { createReadStream, createWriteStream } from 'node:fs';
import { Transform } from 'node:stream';
const transform = new Transform({
transform(chunk, _encoding, callback) {
callback(null, chunk);
},
});
await pipeline(
createReadStream('input.log'),
transform,
createWriteStream('output.log')
);
pipeline propaga los errores hacia todos los extremos y libera los recursos al terminar. Para colas de trabajo, el principio es el mismo: fijar una capacidad máxima y decidir qué hacer cuando se alcanza, ya sea rechazar con un código claro o aplicar contrapresión al productor. Un contador de profundidad de cola expuesto como métrica convierte ese límite en algo observable en lugar de en una sorpresa.
4. Límites de memoria y reinicio controlado
Por defecto, Node.js dimensiona el heap a partir de la memoria del sistema, no del límite real del contenedor. Si el límite del contenedor es de 512 MB y el heap intenta crecer por encima, el proceso es terminado por el kernel sin un mensaje claro.
La solución es alinear ambas cosas:
node --max-old-space-size=384 dist/server.js
El heap se mantiene deliberadamente por debajo del límite del contenedor, dejando margen para la memoria nativa y los búferes. El reinicio ante un consumo excesivo debe ser una decisión explícita y registrada, no un efecto secundario de que el sistema operativo mate el proceso.
5. Observabilidad mínima y útil
La observabilidad que de verdad se usa durante un incidente cabe en cuatro señales:
- Latencia por percentil, no solo la media. El percentil 99 revela lo que la media esconde.
- Tasa de errores diferenciada por código de estado, para distinguir un fallo del cliente de uno del servidor.
- Profundidad de colas y uso de memoria, como indicadores tempranos de saturación.
- Trazas con un identificador por petición, para poder seguir un caso concreto a través del sistema.
Registrar todo sin criterio produce ruido; registrar estas cuatro señales con consistencia produce diagnósticos. El registro estructurado en formato JSON, con campos estables como requestId y durationMs, permite además filtrar sin depender de expresiones frágiles sobre texto libre.
6. Conclusión
Mantener un servicio Node.js estable no depende de una librería concreta, sino de aceptar sus límites: un hilo principal que no debe bloquearse, errores que deben terminar procesos de forma ordenada, colas con capacidad acotada y una memoria dimensionada para el contenedor real. Estos patrones no añaden complejidad apreciable y eliminan la mayor parte de los modos de fallo que aparecen solo bajo carga.