Diagnosticar API lentas de Node.js: Mida la latencia antes de optimizarlas
Aprenda a dividir una solicitud lenta en Node.js en tiempos de espera del bucle de eventos, tiempo de espera en el pool, tiempo de consulta y llamadas posteriores, para así solucionar la etapa que realmente le hace perder tiempo.
Un endpoint tarda casi un segundo, pero la CPU utiliza solo alrededor de un tercio de su capacidad; la memoria no presenta problemas y el panel de control de la base de datos muestra estado verde. Ajustar lo que se sospecha rara vez ayuda. Esta guía explica paso a paso cada etapa de una solicitud para que pueda identificar dónde se pierde el tiempo, además de las prácticas de medición que permiten realizar investigaciones de forma económica y fiable.
Mide el tiempo de la solicitud antes de culpar al entorno de ejecución
Utilice una ruta sencilla que cargue un pedido por su ID y lo devuelva en formato JSON.
app.get("/orders/:id", async (req, res) => {
const order = await getOrder(req.params.id);
res.json(order);
});
Supongamos que las respuestas tardan aproximadamente 900 ms. En lugar de culpar a Node.js, envuelva la llamada pendiente con performance.now() para ver qué proporción del tiempo total representa.
const start = performance.now();
const order = await getOrder(req.params.id);
console.log(
`getOrder: ${performance.now() - start}ms`
);
res.json(order);
Por lo general, las cifras resuelven la cuestión rápidamente:
Request: 910ms
getOrder(): 870ms
Node.js: 40ms
Casi toda la latencia se genera dentro de getOrder(); el JavaScript circundante consume unos 40 ms. El tiempo se invierte esperando algo en el flujo posterior.
Un bajo uso de la CPU no significa que el servicio esté en buen estado
Un panel de utilización como el siguiente parece tranquilizador:
CPU: 32%
Memory: 48%
La CPU mide el trabajo que se está realizando, y una solicitud en espera casi no consume recursos. Cosas típicas por las que espera un proceso de Node.js:
- obtener una conexión a la base de datos
- la consulta en sí
- otro servicio HTTP
- E/S de red en general
- una cola de mensajes
- un bloqueo
Un servicio lento con CPU ociosa generalmente no está ocupado; está bloqueado.
Preste atención al retraso del bucle de eventos, no solo al porcentaje de CPU
Cada función de callback en un proceso de Node.js comparte un mismo bucle de eventos, por lo que el intervalo entre el momento en que debería activarse un temporizador y cuando realmente lo hace es una señal directa del estado de salud del sistema. monitorEventLoopDelay de node:perf_hooks mide ese intervalo y lo representa en un histograma; en este caso utiliza una resolución de 20 ms e imprime los valores p95 y p99 cada cinco segundos.
import { monitorEventLoopDelay } from "node:perf_hooks";
const histogram = monitorEventLoopDelay({
resolution: 20
});
histogram.enable();
setInterval(() => {
console.log({
p95: histogram.percentile(95),
p99: histogram.percentile(99)
});
}, 5000);
Si el resultado es este, el bucle funciona correctamente y no puede explicar una solicitud de 900 ms:
p95 event-loop delay: 8ms
p99 event-loop delay: 14ms
Números en el rango de cientos de milisegundos indican una situación muy diferente:
p95: 180ms
p99: 650ms
Algo está monopolizando el bucle. Un culpable típico es una función síncrona que consume muchos recursos del procesador y se llama directamente desde un manejador:
app.get("/report", (req, res) => {
const result = expensiveCalculation();
res.json(result);
});
Mientras se ejecuta expensiveCalculation(), ninguna otra solicitud en ese proceso avanza. En un host multi-núcleo, el gráfico general de uso del CPU puede seguir pareciendo moderado, ya que un núcleo saturado se promedia con los inactivos; por eso el retraso en bucles suele ser una medida más fiable que el porcentaje de uso del CPU. La solución habitual es trasladar dicho trabajo a un hilo de trabajo, una cola de tareas o un servicio separado. Un modo de fallo relacionado, en el que la programación de microtareas en lugar del cálculo provoca que el bucle quede sin recursos, se aborda en cómo process.nextTick puede dejar sin recursos el bucle de eventos de Node.js.
Cuando la base de datos parece ser la culpable
A continuación, instrumente la consulta de la misma manera.
const start = performance.now();
const result = await db.query(
"SELECT * FROM orders WHERE customer_id = $1",
[customerId]
);
console.log(
`DB query: ${performance.now() - start}ms`
);
Ahora, el problema apunta directamente a la base de datos:
Total request: 850ms
Node.js: 15ms
DB query: 810ms
Serialization: 25ms
Antes de reescribir el SQL, verifique si la consulta fue lenta o si la solicitud estaba esperando una conexión.
Agotamiento del pool disfrazado de consulta lenta
Considere una flota con este tamaño:
API instances: 10
Connections/instance: 20
Total connections: 200
Luego llega un aumento repentino de tráfico:
Requests: 500
Active DB queries: 20
Waiting requests: 480
Solo se pueden ejecutar 20 consultas por instancia, por lo que el resto queda en cola dentro del pool. Una consulta podría tardar 30 ms, pero una solicitud al final de la cola espera cientos de milisegundos antes de llegar a la base de datos. El temporizador de la aplicación indica:
DB call = 500ms
La propia base de datos informa:
Query = 30ms
Una consulta lenta requiere índices o un plan mejor; una espera prolongada exige un tamaño adecuado del pool, transacciones más cortas o menos viajes de ida y vuelta. Mida las distintas fases por separado en lugar de usar una sola cifra de “latencia de la base de datos”:
Connection wait
+
Query execution
+
Result processing
La mayoría de las bibliotecas de pools exponen los conteos de elementos inactivos, totales y en espera; exportarlos es la forma más rápida de diferenciar estos casos.
Servicios posteriores y await secuenciales
Muchos endpoints generan una respuesta a partir de varias dependencias, una tras otra:
const customer = await customerService.get(id);
const payment = await paymentApi.get(
customer.paymentId
);
const orders = await orderService.get(id);
Medir cada llamada por separado permite identificar los valores atípicos:
Customer API: 40ms
Payment API: 700ms ← 🚨
Orders DB: 35ms
Node.js: 15ms
El servicio de pago domina el proceso, y como cada await espera al anterior, las latencias se acumulan:
40ms + 700ms + 35ms + 15ms
≈ 790ms
Las llamadas independientes pueden iniciarse al mismo tiempo. Las búsquedas de cliente y pedido solo necesitan el id, por lo que Promise.all las ejecuta simultáneamente:
const [customer, orders] = await Promise.all([
customerService.get(id),
orderService.get(id)
]);
La llamada de pago sigue en espera, ya que necesita customer.paymentId. No se debe paralelizar de forma automática: la concurrencia adicional ejerce presión sobre los pools, las APIs y la memoria, y bajo carga puede empeorar las situaciones críticas.
Los percentiles revelan lo que ocultan las medias
Un panel de control que muestra esto parece adecuado:
Average latency: 120ms
La vista de percentiles del mismo tráfico resulta menos tranquilizadora:
p50: 80ms
p95: 180ms
p99: 2.8s
La media describe una solicitud normal; la distribución en los extremos describe a usuarios que se enfrentan a un pool saturado o a dependencias lentas. Haga un seguimiento de la distribución:
p50
p95
p99
Instrumentación que ayuda en lugar de perjudicar
La medición consume CPU, almacenamiento y atención. Algunos hábitos ayudan a mantenerla útil.
Mida en los puntos críticos, no en cada función auxiliar
Medir cada función de esta manera genera más ruido que información útil:
const start = performance.now();
// ...
console.log(performance.now() - start);
Mida donde una solicitud pasa a otro sistema, porque allí es donde ocurre la espera:
HTTP request
↓
Database
↓
External API
↓
Queue
↓
Response
Controle el registro por solicitud
Tener una línea de registro en cada solicitud se vuelve costoso con un alto volumen de tráfico:
console.log({
path: req.path,
duration,
userId: req.user.id
});
Utilice un registro estructurado con niveles y muestreo, y evite incluir completamente estas informaciones en los registros:
passwords
tokens
authorization headers
payment information
La duración no es un diagnóstico
Ver un valor como este no demuestra que la sentencia SQL tardó tanto tiempo:
DB: 700ms
Ese intervalo puede incluir varios costos distintos:
Connection acquisition
+
Query execution
+
Network
+
Result transfer
+
Client-side processing
Cuando un número le sorprende, divídalo en sus componentes antes de actuar sobre él.
Evite etiquetas de métricas con alta cardinalidad
Poner una etiqueta de identificador de usuario en una métrica de latencia parece inofensivo:
http_request_duration{user_id="123456"}
Con millones de usuarios, eso significa millones de series temporales. Utilice dimensiones limitadas en su lugar:
route
method
status_code
service
Una serie bien etiquetada se verá así:
http_request_duration{
route="/orders/:id",
method="GET",
status="200"
}
Los detalles por solicitud deben quedar en los registros y trazas.
Muestree de forma intencionada
Una política común sigue una fracción del tráfico normal y todo lo interesante:
Normal traffic → sample a percentage
Errors → capture aggressively
Slow requests → capture aggressively
La proporción depende del tráfico, de las herramientas y del presupuesto; el objetivo es contar con suficientes pruebas para explicar los fallos, no la exhaustividad.
Compare antes y después, en varios indicadores
“Se siente más rápido” no es una prueba. Registre el percentil antes de realizar un cambio:
Before:
p95 = 820ms
Y nuevamente después de él:
After:
p95 = 410ms
Luego confirme que nada más haya cambiado en la dirección incorrecta:
error rate
CPU
memory
DB load
connection pool usage
Una consulta más rápida que duplica el uso del pool o la carga de la base de datos simplemente ha desplazado el problema.
Siga una única solicitud con seguimiento
Una métrica a nivel de ruta solo le indica el total:
GET /orders = 980ms
Un seguimiento distribuido divide la misma solicitud en segmentos, de modo que el costo de cada etapa es visible de inmediato:
GET /orders
│
├── Node.js processing 15ms
├── DB connection wait 120ms
├── PostgreSQL query 40ms
├── Payment API 700ms
├── JSON serialization 20ms
└── Network 5ms
───
900ms
Vale la pena recordar esta división del trabajo: las métricas le indican que algo no está bien, y los rastros muestran dónde ocurre el problema.
Una lista de verificación para seguir la ruta de la solicitud
Cuando aumenta la latencia, evite añadir servidores o CPU primero. Trace el camino que sigue una solicitud y mida cada etapa:
Request
↓
Queue / Load Balancer
↓
Node.js
↓
Event Loop
↓
Connection Pool
↓
Database
↓
External APIs
↓
Network
↓
Response
En cada fase, haga una pregunta concreta:
Is the event loop blocked?
Are requests waiting for connections?
Are database queries slow?
Are downstream APIs slow?
Are we waiting on network I/O?
Is the queue growing?
Is garbage collection causing pauses?
Is the response itself expensive to serialize?
Cada uno de estos puntos puede responderse con datos, por lo que no hay necesidad de adivinar.
Lo que suelen mostrar las pruebas
Volvamos al escenario inicial:
API latency: 900ms
CPU: 35%
Memory: 48%
Después de medir cada fase, la situación es completamente diferente:
Event loop: 12ms
DB query: 35ms
Connection wait: 120ms
Payment API: 700ms
Serialization: 20ms
Network: 5ms
No hay reescritura en tiempo de ejecución; una instancia más grande o memoria adicional ayudaría. Los problemas principales son la dependencia de pago que dura 700 ms (caché, tiempo de espera con solución alternativa o desplazamiento fuera del camino de la solicitud) y la espera por conexión de 120 ms (tamaño del pool o volumen de consultas).
Puntos clave
El bucle intuitivo se ve así:
Slow API
↓
Add more servers
El bucle eficiente se ve así:
Slow API
↓
Measure
↓
Break down latency
↓
Find the bottleneck
↓
Fix the bottleneck
↓
Measure again
- Mide primero todo el tiempo de la solicitud y luego divídelo en cada punto donde abandona el proceso.
- Considera la demora del bucle de eventos como la señal principal de estado de salud en Node.js; el porcentaje de CPU puede ocultar la existencia de un núcleo bloqueado.
- Aísla la espera por conexión de la ejecución de consultas antes de interactuar con SQL.
- Evalúa la latencia según los valores p95 y p99, no según promedios.
- Mantén las métricas con baja cardinalidad, los registros de log muestreados y sin información confidencial, y guarda los detalles por solicitud en los rastreos.
Un sistema lento es manejable una vez que se puede observar; hasta entonces, cada cambio es una suposición. Para conocer qué soluciones priorizar una vez que se identifique el cuello de botella, consulte un marco de trabajo ordenado por prioridades para el rendimiento de las API de Node.js.