Disparo em massa no WhatsApp: fila, rate limit e retry (a engenharia que o for-loop não resolve)
Um laço for com 5.000 contatos falha na metade e você não sabe em quais. Como montar a fila, respeitar o rate limit e retomar sem duplicar envios.
O código que todo mundo escreve primeiro:
for (const contato of contatos) {
await enviar(contato.numero, mensagem);
}Funciona com 50 contatos. Com 5.000, falha na metade — e você não sabe em qual.
Este artigo é sobre a engenharia do disparo. A lista e as boas práticas de conteúdo são pré-requisito: nenhuma fila salva uma base ruim.
Os quatro problemas do laço
1. Não tem memória. Processo caiu na mensagem 2.300? Reexecutar manda tudo de novo. Quem já recebeu, recebe duas vezes.
2. Não tem ritmo. As primeiras saem em milissegundos, você bate o limite, e o resto falha em sequência.
3. Não distingue erro. "Número não tem WhatsApp" e "servidor ocupado, tente de novo" caem no mesmo catch.
4. Não é observável. Enquanto roda, ninguém sabe quantas saíram, quantas falharam nem quanto falta.
A estrutura: produtor, fila, worker
import { Queue, Worker } from 'bullmq';
const fila = new Queue('campanha', { connection: redis });
// PRODUTOR — só enfileira. Não envia nada. Termina em segundos.
async function agendarCampanha(campanhaId, contatos, template) {
for (const c of contatos) {
await fila.add('envio', {
campanhaId,
to: c.numero,
params: [c.nome, c.pedido],
template,
}, {
// Determinístico: reenfileirar a campanha inteira não duplica nada.
jobId: `${campanhaId}:${c.numero}`,
attempts: 5,
backoff: { type: 'exponential', delay: 2000 },
});
}
}O jobId é a peça central. Com ele, "rodar a campanha de novo" vira uma operação segura: a fila descarta o que já processou e executa só o que faltava. Sem ele, cada reexecução é um novo disparo completo.
O worker: um de cada vez, no ritmo
new Worker('campanha', async (job) => {
const { to, params, template, campanhaId } = job.data;
// Opt-out AQUI, não na montagem da campanha. Uma campanha grande
// leva horas para escoar, e quem pede para sair no meio precisa
// parar de receber no meio.
if (await estaEmOptOut(to)) {
await registrar(campanhaId, to, 'optout');
return;
}
try {
const r = await enviarTemplate(to, template, params);
await registrar(campanhaId, to, 'enviado', r.messageId);
} catch (e) {
if (definitivo(e)) {
await registrar(campanhaId, to, 'falha_definitiva', null, e.code);
return; // não repete: não adianta
}
throw e; // temporário: deixa a fila repetir
}
}, {
connection: redis,
concurrency: 1, // um por vez: ritmo é o objetivo
limiter: { max: 20, duration: 60_000 }, // no máximo 20 por minuto
});Duas configurações fazem o trabalho:
concurrency: 1 — paralelizar disparo é contraproducente. O gargalo é o limite da plataforma, não a sua CPU.
limiter — o teto duro. Mesmo que a fila tenha 50 mil itens, saem 20 por minuto.
Distinguir erro temporário de definitivo
É o que separa uma fila que escoa de uma fila que se arrasta:
const DEFINITIVOS = new Set([
'numero_invalido',
'sem_whatsapp',
'template_nao_aprovado',
'contato_bloqueou',
]);
function definitivo(e) {
if (DEFINITIVOS.has(e.code)) return true;
const s = e.status;
if (!s) return false; // rede: temporário
if (s === 429) return false; // limite: temporário
if (s >= 500) return false; // servidor: temporário
return s >= 400 && s < 500; // demais 4xx: definitivo
}Repetir cinco vezes um "número não tem WhatsApp" gasta 5× a cota e atrasa quem está atrás na fila, sem nenhuma chance de sucesso.
O que cada código significa e o tratamento certo para cada um estão em erros da API e como tratar.
Backoff com jitter
Retry sincronizado é pior que retry nenhum: se 200 mensagens falham juntas por um 429 e todas repetem em 2 segundos, elas voltam juntas e tomam 429 de novo.
backoff: {
type: 'custom',
// 2s, 4s, 8s, 16s… com até 30% de variação aleatória,
// para as tentativas não voltarem todas no mesmo instante.
strategy: (tentativa) => {
const base = Math.min(2000 * 2 ** (tentativa - 1), 5 * 60_000);
return base * (1 + Math.random() * 0.3);
},
}O teto de 5 minutos evita que a quinta tentativa caia daqui a horas.
Ritmo humano na API não oficial
Na API não oficial não há limite documentado — há o comportamento do número, e cadência de robô é um dos sinais que derrubam conta:
async function pausaHumana() {
// 3 a 10 segundos, variável. Intervalo fixo é padrão detectável.
const ms = 3000 + Math.random() * 7000;
await new Promise((r) => setTimeout(r, ms));
}Some a isso pausas maiores a cada bloco e respeito ao horário comercial — mandar às 3 da manhã gera bloqueio mesmo com opt-in impecável:
function dentroDoHorario() {
const agora = new Date();
const h = agora.getHours();
const dia = agora.getDay();
if (dia === 0) return false; // domingo não
if (dia === 6) return h >= 9 && h < 13; // sábado de manhã
return h >= 8 && h < 20;
}No worker, se estiver fora do horário, adie em vez de enviar:
if (!dentroDoHorario()) {
await job.moveToDelayed(Date.now() + 30 * 60_000);
return;
}Na API oficial o limite é maior e documentado, mas o princípio continua: o que protege o número é o comportamento, não a tecnologia.
Acompanhar enquanto roda
async function progresso(campanhaId) {
const [aguardando, ativos, falhos] = await Promise.all([
fila.getWaitingCount(),
fila.getActiveCount(),
fila.getFailedCount(),
]);
const enviados = await contarPorStatus(campanhaId, 'enviado');
return {
enviados,
aguardando,
ativos,
falhos,
// Com limiter de 20/min, dá para estimar o fim de verdade
terminaEm: `${Math.ceil(aguardando / 20)} min`,
};
}Campanha sem barra de progresso é campanha que ninguém confia — e alguém acaba rodando de novo "por garantia", que é exatamente o cenário que o jobId previne.
Sem Redis
O mesmo desenho cabe numa tabela:
CREATE TABLE envios (
campanha_id BIGINT,
numero VARCHAR(20),
status VARCHAR(20) DEFAULT 'pendente',
tentativas INT DEFAULT 0,
proxima_em TIMESTAMPTZ DEFAULT NOW(),
message_id VARCHAR(80),
erro VARCHAR(50),
PRIMARY KEY (campanha_id, numero)
);A chave primária composta dá a idempotência que o jobId daria. Um worker busca os pendentes com proxima_em <= NOW(), envia, atualiza. É mais código, e funciona.
O que não funciona é guardar o estado só na memória do processo: ele se perde exatamente no momento em que você mais precisa dele.
Conclusão
Disparo em massa é problema de fila, não de laço. As quatro peças — idempotência por jobId, limitador de ritmo, retry só no que é temporário e progresso visível — são meia tarde de trabalho e transformam "rodei e não sei o que aconteceu" em uma operação que você retoma sem medo.
Depois que a campanha escoa, o trabalho continua: medir entrega, leitura e resposta é o que diz se valeu a pena.
Pronto para automatizar seu WhatsApp?
Crie sua conta gratuita e comece a enviar mensagens pela API em minutos.
Começar grátisPerguntas frequentes
Por que não devo usar um for-loop para disparar em massa?+
Porque o laço não tem memória. Se o processo cair na mensagem 2.300 de 5.000, você não sabe quais já foram, e reexecutar manda tudo de novo para quem já recebeu. Além disso, um laço sem controle de ritmo atinge o rate limit e passa a receber erro para a maioria das chamadas restantes.
Qual o intervalo certo entre mensagens?+
Depende do canal e do histórico do número. Na API não oficial, algo entre 3 e 10 segundos com variação aleatória é o intervalo prudente. Na oficial o limite é mais alto e documentado, mas continua existindo. O certo é tratar o intervalo como configuração ajustável, não como número fixo no código.
Quais erros valem retry e quais não?+
Vale repetir erro temporário: 429 de limite, 5xx do servidor, timeout e falha de rede. Não vale repetir erro definitivo: número inválido, sem WhatsApp, template não aprovado ou contato em opt-out. Repetir erro definitivo gasta cota e atrasa a fila sem chance de sucesso.
Como retomar uma campanha que parou no meio?+
Com um jobId determinístico por destinatário e campanha. Ao reenfileirar tudo, a fila descarta os jobs já processados pelo id repetido, e só o que faltava roda. Isso torna o reenvio da campanha inteira uma operação segura.
Preciso de Redis para fazer isso?+
Não obrigatoriamente. Redis com BullMQ é o caminho mais curto, mas uma tabela no banco com status por destinatário e um worker lendo os pendentes resolve o mesmo problema. O que não funciona é manter o estado só na memória do processo, porque ele se perde exatamente quando você mais precisa dele.
Continue lendo
Mídia no webhook do WhatsApp: como receber, baixar e os limites de tamanho por canal
O webhook traz a referência da mídia, não o arquivo. Como baixar imagem, áudio, vídeo e documento pela API, em base64 ou binário, e os limites de tamanho de WhatsApp, Instagram e Messenger.
Bloqueios em massa do WhatsApp: o que aconteceu e quem foi atingido
A onda de bloqueios de contas do WhatsApp em agosto de 2026 atingiu negócios inteiros. O que os casos tinham em comum, por que a Meta agiu em lote e quem passou ileso.
Como criar um CRM do zero com IA
A IA entrega modelo de dados, funil e painel em dias. Ela não entrega canal, entrega de mensagem nem multi-inquilino. O que fazer com cada uma das três partes.