---
title: "Disparo em massa no WhatsApp: fila, rate limit e retry (a engenharia que o for-loop não resolve)"
description: "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."
url: "https://wame.api.br/blog/fila-rate-limit-retry-disparo-whatsapp"
language: "pt-BR"
og:type: "article"
published_time: "2026-09-10"
modified_time: "2026-09-10"
author: "Raphael Serafim"
keywords: "rate limit whatsapp api, fila de mensagens whatsapp, disparo em massa whatsapp api, retry whatsapp api, enviar muitas mensagens whatsapp, backoff exponencial api, campanha whatsapp api"
reading_time: "10 min de leitura"
---

# 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:**

```js
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](/blog/lista-contatos-optin-optout-whatsapp) e as [boas práticas de conteúdo](/blog/enviar-mensagens-em-massa-whatsapp) 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

```js
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

```js
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:

```js
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](/blog/erros-api-whatsapp-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.

```js
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](/blog/api-nao-oficial-whatsapp) não há limite documentado — há o comportamento do número, e cadência de robô é um dos sinais que derrubam conta:

```js
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:

```js
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:

```js
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](/blog/whatsapp-api-sem-bloqueio-nao-perder-numero) é o comportamento, não a tecnologia.

## Acompanhar enquanto roda

```js
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:

```sql
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](/blog/metricas-campanha-whatsapp-api) é o que diz se valeu a pena.

## Perguntas 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.
