Inicio / Artículos

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.

Editor: REACTAPP.TOP5 min de lectura1240 palabras

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.all no 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
  • El uso de la CPU y la memoria se mantiene estable
  • 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
  • Intentar nuevamente con retroceso permite que el sistema se recupere de manera adecuada.
  • 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-map para limitar la concurrencia
    • Utilizar Bottleneck para 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.