Envío masivo por WhatsApp: cola, rate limit y reintentos (lo que un bucle for no resuelve)
Un bucle for con 5.000 contactos falla a la mitad y no sabes en cuáles. Cómo armar la cola, respetar el límite de la API, reintentar solo los errores que valen y retomar una campaña interrumpida sin enviar nada dos veces.
El código que todos escriben primero:
for (const contacto of contactos) {
await enviar(contacto.numero, mensaje);
}
Funciona con 50 contactos. Con 5.000 falla a la mitad — y no sabes en cuál.
Este artículo es sobre la ingeniería del envío. La lista es requisito previo: ninguna cola salva a una base mala.
Los cuatro problemas del bucle
1. No tiene memoria. ¿El proceso se cayó en el mensaje 2.300? Volver a ejecutarlo manda todo de nuevo.
2. No tiene ritmo. Los primeros salen en milisegundos, alcanzas el límite y el resto falla en cadena.
3. No distingue errores. "El número no tiene WhatsApp" y "servidor ocupado, intenta luego" caen en el mismo catch.
4. No es observable. Mientras corre, nadie sabe cuántos salieron, cuántos fallaron ni cuánto falta.
La estructura: productor, cola, worker
import { Queue, Worker } from 'bullmq';
const cola = new Queue('campana', { connection: redis });
// PRODUCTOR — solo encola. No envía nada. Termina en segundos.
async function agendarCampana(campanaId, contactos, plantilla) {
for (const c of contactos) {
await cola.add('envio', {
campanaId,
to: c.numero,
params: [c.nombre, c.pedido],
plantilla,
}, {
// Determinista: reencolar la campaña entera no duplica nada.
jobId: `${campanaId}:${c.numero}`,
attempts: 5,
backoff: { type: 'exponential', delay: 2000 },
});
}
}
El jobId es la pieza central. Con él, "correr la campaña de nuevo" es una operación segura: la cola descarta lo ya procesado y ejecuta solo lo que faltaba.
El worker: uno por vez, al ritmo correcto
new Worker('campana', async (job) => {
const { to, params, plantilla, campanaId } = job.data;
// Opt-out ACÁ, no al armar la campaña. Una campaña grande tarda
// horas en drenar, y quien pide salir a la mitad tiene que dejar
// de recibir a la mitad.
if (await estaEnOptOut(to)) {
await registrar(campanaId, to, 'optout');
return;
}
try {
const r = await enviarPlantilla(to, plantilla, params);
await registrar(campanaId, to, 'enviado', r.messageId);
} catch (e) {
if (definitivo(e)) {
await registrar(campanaId, to, 'fallo_definitivo', null, e.code);
return; // no reintenta: no sirve
}
throw e; // temporal: deja que la cola reintente
}
}, {
connection: redis,
concurrency: 1, // uno por vez: el ritmo es el objetivo
limiter: { max: 20, duration: 60_000 }, // máximo 20 por minuto
});
Dos configuraciones hacen el trabajo:
concurrency: 1 — paralelizar el envío es contraproducente. El cuello de botella es el límite de la plataforma, no tu CPU.
limiter — el techo duro. Aunque la cola tenga 50 mil elementos, salen 20 por minuto.
Distinguir temporal de definitivo
Es lo que separa una cola que drena de una que se arrastra:
const DEFINITIVOS = new Set([
'numero_invalido',
'sin_whatsapp',
'plantilla_no_aprobada',
'contacto_bloqueo',
]);
function definitivo(e) {
if (DEFINITIVOS.has(e.code)) return true;
const s = e.status;
if (!s) return false; // red: temporal
if (s === 429) return false; // límite: temporal
if (s >= 500) return false; // servidor: temporal
return s >= 400 && s < 500; // demás 4xx: definitivo
}
Reintentar cinco veces un "el número no tiene WhatsApp" gasta cinco veces la cuota y atrasa a quien viene detrás, sin ninguna posibilidad de éxito.
Backoff con jitter
El reintento sincronizado es peor que ninguno: si 200 mensajes fallan juntos por un 429 y todos reintentan a los 2 segundos, vuelven juntos y se llevan otro 429.
backoff: {
type: 'custom',
// 2s, 4s, 8s, 16s… con hasta 30% de variación aleatoria,
// para que los reintentos no vuelvan todos en el mismo instante.
strategy: (intento) => {
const base = Math.min(2000 * 2 ** (intento - 1), 5 * 60_000);
return base * (1 + Math.random() * 0.3);
},
}
El techo de 5 minutos evita que el quinto intento caiga dentro de horas.
Ritmo humano en la API no oficial
En la API no oficial no hay un límite documentado: hay el comportamiento del número, y una cadencia de robot es una de las señales que tumban cuentas.
async function pausaHumana() {
// 3 a 10 segundos, variable. Un intervalo fijo es un patrón detectable.
const ms = 3000 + Math.random() * 7000;
await new Promise((r) => setTimeout(r, ms));
}
Súmale pausas más largas cada cierto bloque y respeto al horario comercial — enviar a las 3 de la mañana genera bloqueos incluso con un opt-in impecable:
function dentroDelHorario() {
const ahora = new Date();
const h = ahora.getHours();
const dia = ahora.getDay();
if (dia === 0) return false; // domingo no
if (dia === 6) return h >= 9 && h < 13; // sábado por la mañana
return h >= 8 && h < 20;
}
En el worker, si está fuera de horario, posponlo en lugar de enviar:
if (!dentroDelHorario()) {
await job.moveToDelayed(Date.now() + 30 * 60_000);
return;
}
Seguir la campaña mientras corre
async function progreso(campanaId) {
const [esperando, activos, fallidos] = await Promise.all([
cola.getWaitingCount(),
cola.getActiveCount(),
cola.getFailedCount(),
]);
const enviados = await contarPorEstado(campanaId, 'enviado');
return {
enviados,
esperando,
activos,
fallidos,
// Con un limiter de 20/min se puede estimar el final de verdad
terminaEn: `${Math.ceil(esperando / 20)} min`,
};
}
Una campaña sin barra de progreso es una campaña en la que nadie confía — y alguien termina corriéndola de nuevo "por las dudas", que es justo el escenario que el jobId previene.
Sin Redis
El mismo diseño cabe en una tabla:
CREATE TABLE envios (
campana_id BIGINT,
numero VARCHAR(20),
estado VARCHAR(20) DEFAULT 'pendiente',
intentos INT DEFAULT 0,
proximo_en TIMESTAMPTZ DEFAULT NOW(),
message_id VARCHAR(80),
error VARCHAR(50),
PRIMARY KEY (campana_id, numero)
);
La clave primaria compuesta da la idempotencia que daría el jobId. Un worker busca los pendientes con proximo_en <= NOW(), envía y actualiza. Es más código, y funciona.
Lo que no funciona es guardar el estado solo en la memoria del proceso: se pierde exactamente cuando más lo necesitas.
Conclusión
El envío masivo es un problema de cola, no de bucle. Las cuatro piezas —idempotencia por jobId, limitador de ritmo, reintento solo de lo temporal y progreso visible— son media tarde de trabajo y convierten "lo corrí y no sé qué pasó" en una operación que retomas sin miedo.
¿Listo para automatizar tu WhatsApp?
Crea tu cuenta gratis y empieza a enviar mensajes por la API en minutos.
Empezar gratisPreguntas frecuentes
¿Por qué no debo usar un bucle for para el envío masivo?+
Porque el bucle no tiene memoria. Si el proceso se cae en el mensaje 2.300 de 5.000, no sabes cuáles ya salieron, y volver a ejecutarlo se lo manda de nuevo a quien ya lo recibió. Además, un bucle sin control de ritmo alcanza el rate limit y empieza a recibir error en la mayoría de las llamadas restantes.
¿Cuál es el intervalo correcto entre mensajes?+
Depende del canal y del historial del número. En la API no oficial, algo entre 3 y 10 segundos con variación aleatoria es prudente. En la oficial el límite es más alto y está documentado, pero sigue existiendo. Lo correcto es tratar el intervalo como configuración ajustable, no como un número fijo en el código.
¿Qué errores conviene reintentar y cuáles no?+
Conviene reintentar el error temporal: 429 por límite, 5xx del servidor, timeout y fallo de red. No conviene reintentar el definitivo: número inválido, sin WhatsApp, plantilla no aprobada o contacto en opt-out. Reintentar un error definitivo gasta cuota y atrasa la cola sin ninguna posibilidad de éxito.
¿Cómo retomo una campaña que se cortó a la mitad?+
Con un jobId determinista por destinatario y campaña. Al reencolar todo, la cola descarta los trabajos ya procesados por el id repetido y solo corre lo que faltaba. Eso convierte el reenvío de la campaña completa en una operación segura.
¿Necesito Redis para esto?+
No necesariamente. Redis con BullMQ es el camino más corto, pero una tabla en la base con estado por destinatario y un worker leyendo los pendientes resuelve el mismo problema. Lo que no funciona es mantener el estado solo en la memoria del proceso, porque se pierde justo cuando más lo necesitas.
Sigue leyendo
API de Instagram y Messenger en la misma API de WhatsApp: una instancia, un estándar
Tres canales oficiales de Meta casi siempre significan tres integraciones. Con una sola instancia, el mismo envío y el mismo sobre de webhook, solo cambia un campo: provider. Cómo funciona y qué te ahorra.
API no oficial de WhatsApp: qué es, si es segura y cómo usarla (2026)
Entiende qué es una API no oficial de WhatsApp, cómo funciona la conexión por código QR, si es segura y legal, cuál es el riesgo real de bloqueo y cuándo conviene usarla en lugar de la API oficial.
API oficial vs no oficial de WhatsApp: ¿cuál elegir? (comparativa 2026)
Comparativa completa entre la API oficial de WhatsApp (Cloud API de Meta) y la API no oficial: costo, aprobación, límites de envío, plantillas, soporte y riesgo de bloqueo. Descubre cuál tiene sentido para tu caso.