De un script de prueba a una API Node.js en producción: bucle de eventos, hilos de trabajo, registro de logs
Cómo el bucle de eventos, el trabajo bloqueante, los hilos de trabajo, los tiempos de espera, la concurrencia, los registros estructurados y el acceso a bases de datos dan forma a una API de Node.js que funciona correctamente en producción.
Fuentes: The Node.js Runtime Is the Easy Part
Lograr que una API de Node.js responda rara vez es la parte difícil. El endpoint devuelve datos, el frontend los muestra y el conjunto de pruebas da resultado positivo. La verdadera dificultad comienza en producción, cuando llegan miles de solicitudes al mismo tiempo, la base de datos se ralentiza, una API de terceros deja de responder, el uso de memoria aumenta gradualmente o una solicitud que normalmente tarda 200 milisegundos de repente tarda 12 segundos. También existe una pregunta menos evidente: ¿algún otro desarrollador entenderá este código en seis meses?
Estas preguntas se responden mediante el comportamiento del entorno de ejecución y mediante algunos hábitos de diseño. A continuación verá dónde brilla el modelo de ejecución de Node.js, dónde falla y qué patrones mantienen un servicio receptivo una vez que aparece tráfico real: trasladar el trabajo de la CPU a hilos de trabajo, usar tiempos de espera, realizar llamadas de red concurrentes, registrar información de forma estructurada y realizar consultas con cuidado.
En qué es realmente bueno el entorno de ejecución
La explicación habitual es que Node.js permite ejecutar JavaScript fuera del navegador. Es cierto, pero se pasa por alto la parte interesante. Lo que realmente ofrece Node.js es un entorno de ejecución basado en eventos con E/S asíncrona y un ecosistema muy amplio alrededor de él. Ese diseño resulta útil cada vez que un servidor pasa la mayor parte de su tiempo esperando a algo más:
- consultas a una base de datos
- llamadas HTTP salientes
- lectura y escritura de archivos
- tráfico de red en general
- colas de mensajes
- conexiones WebSocket de larga duración
Sigue el rastro de una sola solicitud a través de un backend típico:
Client
↓
API Server
↓
Database
↓
External API
↓
Response
Casi en ningún momento de esa cadena el servidor está ocupado procesando datos. En su mayor parte, espera a la base de datos y luego a la API externa. El modelo de Node.js está diseñado para utilizar ese tiempo ocioso para atender otras solicitudes, y comprender esto es más valioso que cualquier API en particular.
Cómo el bucle de eventos cambia tu modelo mental
JavaScript en una aplicación Node.js normalmente se ejecuta en un hilo principal, mientras que el bucle de eventos coordina las operaciones asíncronas alrededor de él. Una representación simplificada del ciclo de vida de una solicitud:
Incoming Request
↓
JavaScript
↓
Start I/O operation
↓
Don't block execution
↓
Event Loop
↓
I/O completes
↓
Callback / Promise
↓
Continue execution
Así es como se ve en código ordinario:
const user = await database.getUser(userId);
const orders = await database.getOrders(userId);
return {
user,
orders
};
El await pausa esta función, no el proceso. Mientras la base de datos procesa la primera consulta, el tiempo de ejecución está disponible para manejar otras llamadas de retorno y otras solicitudes. Por eso un único proceso de Node.js puede manejar una gran cantidad de solicitudes que, en su mayoría, esperan en la red.
Una nuanza que merece atención: las dos consultas anteriores se ejecutan una tras otra, ya que el segundo await solo comienza después de que la primera se resuelva. Volveremos sobre esto en la sección sobre concurrencia.
Asíncrono no es lo mismo que rápido
El modelo tiene un límite estricto. Si tu propio JavaScript realiza un cálculo largo, nada más en ese hilo puede ejecutarse hasta que termine. Un bucle como este mantiene el hilo ocupado durante toda su duración:
function expensiveOperation() {
let result = 0;
for (let i = 0; i < 10_000_000_000; i++) {
result += i;
}
return result;
}
Mientras está en ejecución, no se aceptan solicitudes entrantes, las promesas no se resuelven y los temporizadores no se activan según lo programado. No existe un programador que interrumpa el bucle para darle turno a otras solicitudes. Esta es la razón fundamental por la cual conocer el comportamiento del entorno de ejecución es más importante que memorizar sus APIs.
El problema del manejador bloqueante
Ahora imagina que ese tipo de tareas se realizan dentro de un manejador HTTP. El redimensionado de imágenes, las transformaciones de grandes volúmenes de datos o los cálculos intensivos realizados directamente en la ruta de la solicitud parecen inofensivos en el código:
app.post("/process", async (req, res) => {
const result = expensiveComputation(req.body);
res.json(result);
});
El problema solo aparece bajo carga. Una solicitud activa la función costosa y monopoliza el bucle de eventos. Todas las demás solicitudes se quedan en cola detrás de ella. La latencia aumenta en general, no solo para el endpoint con mayor carga, y el propio proceso de la aplicación se convierte en el cuello de botella. Marcar al manejador como async no ayuda, porque el cálculo que se realiza dentro sigue siendo síncrono.
Un diseño más sólido saca por completo el trabajo dependiente de la CPU del camino de las solicitudes:
Client
↓
Node.js API
↓
Queue
↓
Worker
↓
Heavy computation
↓
Database / Storage
La API acepta el trabajo, lo coloca en una cola y responde rápidamente, mientras que los trabajadores dedicados procesan las tareas a su propio ritmo y escriben los resultados en una base de datos o almacenamiento de objetos. La API mantiene su capacidad de respuesta, y puedes escalar los trabajadores de forma independiente de la capa web. Este cambio, de escribir controladores a diseñar el flujo de trabajo, es en gran medida lo que diferencia el conocimiento básico de Node.js de la creación de sistemas con él.
Cuándo no usar una cola
Una cola añade infraestructura, intentos de reintentos y consistencia eventual que deben tenerse en cuenta. Para tareas cortas de CPU que deben devolver un resultado de forma síncrona, los hilos de trabajo de Node mantienen el trabajo dentro del proceso pero fuera del hilo principal. Para tareas que tardan milisegundos, a menudo es suficiente ejecutarlas directamente. La decisión depende de cuánto tiempo dura el trabajo y de si el cliente debe esperar la respuesta.
Express es un framework, no el entorno de ejecución
Muchos desarrolladores difuminan la línea entre Node.js y Express. Node.js es el entorno de ejecución. Express es un framework web construido sobre él. Puedes servir HTTP con nada más que el módulo integrado http:
const http = require("http");
const server = http.createServer((req, res) => {
res.writeHead(200, {
"Content-Type": "application/json"
});
res.end(JSON.stringify({
message: "Hello"
}));
});
server.listen(3000);
Eso funciona, pero a medida que la aplicación crece, la enrutación, el middleware, el análisis del cuerpo de las solicitudes, la validación y el manejo de errores pasan a ser tu responsabilidad. Existen frameworks para aliviar esa carga. La misma idea en Express se presenta de esta manera:
import express from "express";
const app = express();
app.use(express.json());
app.get("/users/:id", async (req, res) => {
const user = await getUser(req.params.id);
res.json(user);
});
app.listen(3000);
Express no reemplaza nada en Node.js; ofrece una abstracción más conveniente sobre las mismas primitivas. Mantener clara esa distinción ayuda al depurar: un endpoint lento podría ser un problema del framework, un problema del entorno de ejecución o ninguno de los dos.
El código de producción se trata principalmente de los límites
Un servicio pequeño puede residir en un único archivo. Un sistema de producción no puede hacerlo, porque las diferentes responsabilidades requieren separaciones claras entre sí. Un diseño razonable sería:
src/
├── api/
│ ├── routes/
│ ├── controllers/
│ └── middleware/
│
├── services/
│ └── userService.js
│
├── repositories/
│ └── userRepository.js
│
├── config/
│ └── environment.js
│
├── jobs/
│ └── emailWorker.js
│
└── app.js
Los nombres específicos de las carpetas no son lo importante. Lo que cuenta es que las responsabilidades permanezcan separadas:
- Un controlador no debería acumular cientos de líneas de reglas de negocio.
- Un repositorio no debería saber nada sobre cómo se forman las respuestas HTTP.
- Un servicio no debería recibir como entrada los objetos
reqoresde Express.
Estas separaciones son las que permiten que el código sea probable de forma aislada, reemplazable pieza por pieza y comprensible para quien lo lea después. Para un análisis más profundo de esta estructura, consulte nuestra guía sobre el diseño de APIs en Node.js con capas.
Diseño para manejar fallos en código asíncrono
Cada desarrollador de Node.js aprende esto temprano:
const data = await fetchData();
La producción plantea preguntas más difíciles al respecto. ¿Qué pasa si fetchData() tarda 30 segundos? ¿Qué pasa si lanza un error? ¿Qué pasa si lo hace tres veces seguidas? ¿Qué pasa si el servicio remoto está completamente fuera de servicio? ¿Qué pasa si una escritura en la base de datos tiene éxito pero el siguiente paso falla, dejando al sistema parcialmente actualizado?
Un código de backend fiable define el comportamiento para esos casos en lugar de limitarse a describir solo el escenario óptimo. Un pequeño ejemplo que muestra de forma explícita los fallos:
async function getUserProfile(userId) {
try {
const user = await userService.getUser(userId);
if (!user) {
throw new Error("User not found");
}
return user;
} catch (error) {
logger.error({
userId,
error: error.message
});
throw error;
}
}
La función convierte la ausencia de un usuario en un error, registra las fallas con el contexto necesario para investigarlas y las vuelve a lanzar para que quien las llama pueda decidir cómo responder. El principio subyacente es que el manejo de fallos es una decisión de diseño, no algo que se añade después de un incidente.
Una advertencia práctica: si cada capa registra y vuelve a lanzar los errores, una sola falla genera varias líneas de registro. Decida dónde se registrarán los errores (generalmente una vez, en el límite que los maneja) y deje que las capas internas añadan el contexto necesario.
Nunca espere eternamente: tiempos de espera
Aquí hay una llamada a otro servicio sin ninguna protección:
const response = await fetch("https://example.com/api");
Si el servidor remoto acepta la conexión pero nunca responde, este await podría quedar bloqueado durante mucho más tiempo del que cualquier usuario esté dispuesto a tolerar, manteniendo los recursos ocupados todo el tiempo. Un sistema de producción necesita un límite superior explícito. Con el AbortController estándar, disponible en las versiones modernas de Node.js, se puede establecer uno:
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, 5000);
try {
const response = await fetch(
"https://example.com/api",
{
signal: controller.signal
}
);
return await response.json();
} finally {
clearTimeout(timeout);
}
El temporizador cancela la solicitud después de cinco segundos, y el bloque finally lo reinicia para que no se active tras una respuesta exitosa. Los entornos de ejecución más recientes también ofrecen AbortSignal.timeout(ms), que expresa la misma idea en una sola expresión.
Cinco segundos no es un número mágico y puede ser demasiado generoso para un endpoint orientado al usuario. Elija un valor según el presupuesto de latencia del endpoint. Lo indispensable es que una espera ilimitada no constituya una forma aceptable de fallar.
Concurrencia sin paralelismo
Cuando dos operaciones no dependen una de la otra, esperarlas una tras otra es una pérdida de tiempo:
const user = await getUser();
const orders = await getOrders();
La segunda llamada ni siquiera comienza hasta que termina la primera, por lo que la latencia total es la suma de ambas. Por lo general, es mejor iniciarlas al mismo tiempo y esperar a que ambas finalicen:
const [user, orders] = await Promise.all([
getUser(),
getOrders()
]);
Las dos solicitudes se están procesando simultáneamente, y la latencia total es aproximadamente la del proceso más lento. Esto es concurrencia, no cálculo paralelo: tu JavaScript sigue ejecutándose en un solo hilo, pero los tiempos de espera se superponen. La diferencia es más importante en los puntos de extremo que se conectan con varios servicios downstream.
Hay dos casos límite que hay que tener en cuenta. Promise.all rechaza la solicitud en cuanto cualquier entrada lo haga, por lo que se debe utilizar Promise.allSettled cuando sean aceptables resultados parciales. Además, realizar cientos de llamadas al mismo tiempo puede sobrecargar un pool de bases de datos o una API con límites de velocidad, por lo que es necesario limitar la concurrencia cuando la lista es grande.
El rendimiento depende de la arquitectura
Es común ver a los equipos debatir sobre decisiones a nivel micro, como una opción clásica
for
loop en comparación con
forEach
mientras que el mismo endpoint realiza cinco consultas a la base de datos innecesarias. O para ver un pequeño complemento ajustado cuidadosamente mientras la API devuelve cargas JSON enormes, o mientras cada solicitud espera 800 milisegundos por una consulta lenta.
En los servicios reales, los tiempos de respuesta suelen estar determinados por:
- la forma en que se accede a la base de datos
- la latencia de red
- consultas ineficientes o sin índices
- serialización excesiva
- dependencia de APIs externas
- tareas que en realidad no son necesarias
- presión de memoria
- cache ausente o mal diseñado
Los mayores avances suelen provenir de cambios arquitectónicos, no de la sintaxis. Si desea una forma estructurada de priorizar, consulte nuestro marco de optimización ordenado por prioridad para el rendimiento de las APIs de Node.js.
La observabilidad forma parte del producto
Un servidor que no puede indicarle qué hizo es muy difícil de operar. El registro de actividades es la primera capa, pero una línea como esta es casi inútil en entornos de producción:
console.log("Something went wrong");
El registro estructurado anota el nombre del evento y los identificadores que se necesitan para investigar:
logger.error({
event: "payment_failed",
orderId,
userId,
error: error.message
});
Dado que cada entrada es un objeto y no texto libre, los registros pueden filtrarse, agregarse y consultarse: por ejemplo, todos los eventos payment_failed de un usuario.
Los registros por sí solos siguen presentando lagunas. Los sistemas de producción suelen basarse en tres señales complementarias:
Logs
↓
What happened?
Metrics
↓
How often / how much?
Traces
↓
Where did the request spend its time?
Los registros te indican qué sucedió, las métricas te muestran con qué frecuencia y en qué cantidad, y las trazas revelan dónde pasó el tiempo una solicitud en los diferentes servicios. Cuando alguien informa que “la aplicación es lenta”, estas señales te permiten responder con pruebas en lugar de con conjeturas.
El entorno de ejecución no diseña el acceso a tus datos
Elegir Node.js no implica necesariamente el uso de una base de datos específica. Funciona bien con PostgreSQL, MySQL, MongoDB, Redis, SQLite, Elasticsearch y muchas otras. Lo que realmente importa es cómo el código se comunica con la base de datos que elijas. Este endpoint parece perfectamente razonable:
app.get("/products", async (req, res) => {
const products = await db.query(
"SELECT * FROM products"
);
res.json(products);
});
En producción plantea de inmediato una serie de preguntas:
- ¿Cuántas filas contiene
productshoy y cuántas el próximo año? - ¿Existe alguna paginación?
- ¿La consulta cuenta con el soporte de un índice?
- ¿Podría esta respuesta llegar a los 50 MB?
- ¿Cómo se comporta ante tráfico concurrente?
- ¿El pool de conexiones tiene límites razonables?
- ¿Podría un error bruto de la base de datos filtrarse al cliente?
Node.js no responderá por usted a ninguna de estas preguntas. Esas decisiones corresponden a quien diseña el servicio.
Node.js como un componente entre muchos
Node.js está ampliamente utilizado en entornos en la nube, en parte porque su modelo de procesos ligero se adapta bien a los contenedores y a los diseños orientados a servicios. En tales sistemas, suele ser solo una parte de varios componentes: una capa de API junto con colas, procesadores, cachés, bases de datos y otros servicios, cada uno con su propia responsabilidad. No tiene por qué ser todo el sistema, y un verdadero backend rara vez es simplemente “una API de Express”.
Donde Node.js necesita ayuda
Ningún entorno de ejecución es adecuado para todas las cargas de trabajo. Node.js es más eficaz cuando el trabajo está dominado por operaciones de E/S, mientras que las tareas que requieren mucho procesamiento de CPU necesitan un diseño más cuidadoso. Ejemplos:
- Procesamiento de imágenes a gran escala
- Codificación de video
- Computación científica intensiva
- Algunas cargas de trabajo de aprendizaje automático
- Operaciones criptográficas complejas
- Transformaciones de datos de muy gran tamaño
Nada de esto excluye a Node.js de este tipo de sistemas. Significa que el proceso que maneja las solicitudes HTTP no debería ser el encargado de las tareas más pesadas. Las opciones incluyen:
- hilos de trabajo dentro del mismo proceso
- procesos de trabajo en segundo plano separados
- colas entre la API y los trabajadores
- servicios dedicados para la parte más costosa
- otros lenguajes o entornos de ejecución más adecuados para el cálculo
El objetivo no es forzar todos los problemas a Node.js, sino colocarlo donde su modelo de ejecución sea adecuado.
Preguntar “por qué”, no solo “cómo”
Al principio, la pregunta es cómo construir una API con Node.js. Con la experiencia, pasa a ser por qué la API debe construirse de una manera específica, lo que conduce a otro conjunto de preguntas:
- ¿Debería esta operación ser síncrona o pasar por una cola?
- ¿Debería cachearse la respuesta?
- ¿Este servicio es dueño de estos datos?
- ¿Debería este endpoint depender de otros tres servicios, y qué sucede cuando uno falla?
- ¿Dónde deben realizarse los intentos de nuevo?
- ¿Qué se debe registrar y qué se debe medir?
- ¿Cómo se comportará esto bajo carga?
Ninguno de estos puntos es específico de Node.js. Son preguntas de ingeniería de software, y Node.js proporciona las herramientas para implementar las respuestas.
El checklist de producción en resumen
- El bucle de eventos hace que Node.js sea excelente para manejar esperas simultáneas en red y en disco, pero cualquier tarea síncrona en la CPU detiene todas las solicitudes en ese hilo.
- Desplaza los cálculos intensivos fuera del flujo de solicitudes mediante hilos de trabajo, colas y trabajadores, o servicios separados.
- Trata las rutas de fallo y los tiempos de espera explícitos como parte del diseño; una espera ilimitada es un error.
- Ejecuta llamadas independientes en paralelo con
Promise.all, y limita la concurrencia cuando se realicen muchas llamadas simultáneas. - Los problemas de rendimiento suelen estar relacionados con las consultas, el tamaño de los datos transmitidos y los saltos de red, no con la sintaxis de los bucles.
- Los registros estructurados, las métricas y los seguimientos convierten las quejas en producción en pruebas concretas.
- Los frameworks y entornos de ejecución son herramientas; la arquitectura, los límites y el acceso a los datos siguen siendo responsabilidad del desarrollador.
Leer más
- Registro estructurado en Node.js: Conviertiendo el caos en el desarrollo en producción en claridad: Aprende por qué console.log falla en las aplicaciones Node.js en producción y cómo el registro estructurado, los niveles de registro y los IDs de correlación convierten los errores difíciles en soluciones rápidas.
- De Modulo a Anillo Hash: Escalar un Flotel de Cache de Node.js Sin Tempestades de Fallas: Aprenda por qué el sharding hash-mod-N destruye las bases de datos cuando cambian los nodos, cómo se comparan los algoritmos de rendezvous, jump y ring hashing, y cómo crear un anillo ponderado equilibrado en Node.js.