Domar la concurrencia en Node.js: Evitar colapsos de API con p-map y cuellos de botella
Promise.all sin límites puede sobrecargar las APIs. Combine p-map y Bottleneck en Node.js para limitar la concurrencia, respetar las restricciones de tasa y mantener funcionales los sistemas posteriores.
Fuentes: Stop Melting Your APIs: Smart Concurrency Control in Node.js with p-map & Bottleneck
La mayoría de las aplicaciones Node.js no fallan porque la lógica esté incorrecta. Fallan porque intentan ejecutarse de forma demasiado agresiva, al mismo tiempo.
Imagínese un escenario: un equipo lanza una función que realiza aproximadamente 10,000 llamadas a la API en pocos segundos. En una máquina local, todo funciona sin problemas y rápidamente, y los resultados parecen impresionantes.
Cuando ese mismo código llega a producción, la situación cambia:
- Aparecen errores 429 por todas partes
- La API de terceros comienza a rechazar solicitudes
- El consumo de memoria aumenta drásticamente
- La lógica de reintentos empeora la situación en lugar de mejorarla
El verdadero problema nunca fue la API externa en sí. Fue la concurrencia sin ningún tipo de límite.
La trampa oculta dentro de Promise.all
Muchos desarrolladores recurren primero a este patrón:
await Promise.all(urls.map(fetchData));
Se lee de manera clara y parece ser eficiente.
Pero observe más de cerca qué es lo que realmente se activa:
- Cada solicitud se ejecuta simultáneamente
- No existe un límite en la cantidad que pueden ejecutarse al mismo tiempo
- No hay mecanismo de contrapresión
- Los límites de velocidad establecidos por la API se ignoran por completo
En la práctica, este patrón funciona como un generador de picos repentinos de tráfico.
Promise.allno le brinda control de concurrencia. Le provoca una explosión de concurrencia.
Paso uno: Limitar la concurrencia con p-map
Si su objetivo es restringir cuántas operaciones se ejecutan en paralelo, p-map es la herramienta adecuada para ello.
import pMap from 'p-map';
const result = await pMap(
urls,
async (url) => {
const res = await fetch(url);
return res.json();
},
{ concurrency: 5 }
);
Qué soluciona esto
- Solo cinco solicitudes se ejecutan en paralelo en cualquier momento dado
- El resto permanece en una cola hasta que se libere un espacio
La idea fundamental
La concurrencia no se trata realmente de obtener más velocidad. Se trata de mantener bajo control la carga en el sistema.
Por qué eso por sí solo no es suficiente
Establecer concurrency: 5 no garantiza que evitarás los límites de tasa.
Las APIs no solo registran cuántas solicitudes llegan al mismo tiempo, sino también cuántas lo hacen dentro de un determinado intervalo de tiempo.
Eso significa que necesitas gestionar el tiempo, no solo la cantidad de llamadas en paralelo.
Paso dos: Aplicar límites de tasa con Bottleneck
import Bottleneck from "bottleneck";
const limiter = new Bottleneck({
maxConcurrent: 5,
minTime: 200 // ~5 requests per second
});
const fetchWithLimit = limiter.wrap(async (url) => {
const res = await fetch(url);
return res.json();
});
const results = await Promise.all(urls.map(fetchWithLimit));
Qué logra esto
- Mantiene la concurrencia dentro de límites
- Distribuye las solicitudes a lo largo del tiempo
- Impide que se produzcan picos de carga
En lugar de saturar la API de golpe, ahora le envías solicitudes a un ritmo constante y predecible.
p-map frente a Bottleneck
Cada herramienta resuelve un problema diferente:
- p-map se encarga del caso en el que demasiadas promesas se ejecutan al mismo tiempo
- Bottleneck gestiona la situación en la que el límite de frecuencia de la API genera errores 429
- Cuando necesitas ambas protecciones, combina las dos bibliotecas
Un patrón útil para producción
import pMap from 'p-map';
import Bottleneck from 'bottleneck';
const limiter = new Bottleneck({
minTime: 200
});
const task = limiter.wrap(async (url) => {
const res = await fetch(url);
return res.json();
});
await pMap(urls, task, { concurrency: 5 });
Esta combinación ofrece:
- Concurrencia estable y limitada
- Protección contra el exceso de solicitudes
- Un perfil de carga que se puede predecir con antelación
Manejo de fallos: intentos repetidos con retroceso
Incluso un sistema cuidadosamente regulado a veces enfrentará fallos. Lo que diferencia a un sistema resiliente de uno frágil es cómo responde a esos fallos.
async function fetchWithRetry(url, retries = 3) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error('Failed');
return res.json();
} catch (err) {
if (retries === 0) throw err;
// exponential backoff
const delay = 2 ** (3 - retries) * 100;
await new Promise(r => setTimeout(r, delay));
return fetchWithRetry(url, retries - 1);
}
}
Por qué es importante el retroceso aquí
- Intentar de nuevo sin ningún retraso tiende a generar tormentas de intentos
El equilibrio entre ancho de banda y latencia
Aquí es donde entran en juego las verdaderas decisiones de diseño del sistema.
- Una menor concurrencia ofrece un proceso más seguro pero más lento
- Una mayor concurrencia acelera las operaciones pero introduce más riesgos
Por ejemplo:
- Establecer la concurrencia en 2 mantiene las cosas estables, aunque lentas
- Establecer la concurrencia en 20 acelera la ejecución pero podría hacer que se supere los límites de tasa
No existe un valor ideal universal. Es necesario ajustar dicho valor según lo que realmente puedan manejar su sistema y la API objetivo.
Observabilidad: el paso que la mayoría de los equipos omite
No basta con desplegar esta configuración y dejarla sin supervisión en producción.
Debe vigilar:
- cuán grande se vuelve la cola
- el tiempo promedio que pasan las solicitudes en espera
- la relación entre éxitos y fallos
Sin visibilidad sobre lo que realmente está haciendo su sistema, ajustarlo correctamente se convierte en una especulación.
¿Dónde encajan colas como BullMQ o Kafka?
Es una pregunta legítima plantearla.
- Las colas de mensajes ofrecen un procesamiento asíncrono y duradero
- p-map y Bottleneck brindan control dentro del mismo proceso dentro de un servicio único
Utilice una cola cuando:
- los trabajos necesitan sobrevivir a una caída o reinicio
- la carga de trabajo es a gran escala o está distribuida en varios servicios
Opte por el enfoque de p-map y Bottleneck cuando:
- necesita una ejecución rápida y controlada que permanezca dentro de un solo servicio
Efecto medido después de aplicar esto
Después de implementar tanto los límites de concurrencia como la limitación de tasa al mismo tiempo, un sistema en producción observó:
- Las tasas de error 429 disminuyeron de aproximadamente el 18% a menos del 1%
- Mejora notable en la estabilidad al comunicarse con la API externa
- Fin total de las tormentas de reintentos
La lógica de la aplicación permaneció igual. Lo que cambió fue la disciplina aplicada en su ejecución.
Pensamiento final
Escribir código asíncrono es una habilidad básica para los desarrolladores de Node.js. Saber cómo controlar la velocidad de ejecución de ese código es lo que distingue a los ingenieros de backend competentes.
Si su servicio se comunica con:
- APIs externas
- bases de datos
- otros microservicios
entonces el control de concurrencia deja de ser algo opcional y se convierte en una parte esencial de su arquitectura.
Conclusión
Si su código de producción sigue dependiendo de Promise.all sin ningún límite, solo le falta un aumento repentino de tráfico para que ocurra una interrupción del servicio.
Un punto de partida práctico sería el siguiente:
- Introducir
p-mappara limitar la concurrencia - Utilizar
Bottleneckpara aplicar límites de velocidad - Agregar lógica de intentos repetidos para mejorar la resiliencia
Ese es el camino desde un código que simplemente funciona hasta uno que realmente puede escalar.