Blog

Arquitetura de fila para picos de campanha no WhatsApp

Uma campanha de marketing aperta o botão de disparo e, em um instante, 100 mil mensagens entram na fila para sair. O problema é que a WhatsApp Cloud API não aceita esse volume de uma vez: existe um rate limit de chamadas por segundo e o número tem um messaging tier que limita quantos usuários únicos você pode iniciar conversa em 24h. Disparar tudo de uma vez é a receita certa para erros 429, quedas de qualidade e bloqueio do número. Este guia mostra como dimensionar fila, rate limiter e backpressure para entregar picos de campanha sem bloqueio e sem perda.

2026-06-16 / Arquitetura Backend / 12 min

01

O problema: 100k mensagens contra um rate limit

Quando uma campanha dispara, o instinto é iterar a lista de contatos e chamar a API em loop. Funciona com mil contatos. Com cem mil, o sistema colide com três limites simultâneos. Primeiro, o rate limit da Cloud API: há um teto de requisições por segundo por número, e estourar esse teto retorna HTTP 429 com Retry-After. Segundo, o messaging tier do número, que limita quantas conversas iniciadas por negócio (business-initiated) você pode abrir em uma janela de 24h: 1k, 10k, 100k ou ilimitado, dependendo do tier. Terceiro, a qualidade do número, que a Meta avalia em tempo real e que, se cair, rebaixa o tier ou bloqueia o envio.

A consequência de ignorar esses limites não é só lentidão. Disparos em rajada geram um pico de 429, e o código ingênuo costuma reagir com retry imediato, o que aumenta a carga e piora a tempestade. Pior: marcar contatos como bloqueio ou erro permanente quando na verdade era throttling temporário faz você perder entregas que poderiam ter saído minutos depois. O envio em massa precisa ser tratado como um problema de fluxo controlado, não de loop.

02

Arquitetura: produtor, fila e worker pool com rate limiter

A arquitetura que sustenta picos separa quem produz trabalho de quem o executa. Um produtor recebe a campanha e enfileira um job por mensagem (ou por lote pequeno). Uma fila durável guarda esses jobs. Um pool de workers consome a fila, mas não a velocidade máxima: um rate limiter no estilo token bucket regula a saída para respeitar exatamente o limite de mensagens por segundo da Cloud API. O token bucket é a peça central: ele enche tokens a uma taxa constante (igual ao seu throughput alvo) e cada envio consome um token. Sem token, o worker espera. Isso transforma uma rajada de 100k em um fluxo suave de X msg/s.

Campanha (100k contatos)
        |
        |  enfileira 1 job por mensagem
        v
+--------------------------+
|        Produtor          |
|  valida + segmenta lista |
+--------------------------+
        |
        v
+--------------------------+      tokens a X/s
|          Fila            |   +------------------+
|   (Redis / BullMQ)       |   |   Token Bucket   |
|  duravel, com prioridade |   |  enche X tokens/s|
+--------------------------+   +------------------+
        |                              |
        |  pull                        | consome 1 token/envio
        v                              v
+--------------------------------------------------+
|                 Worker Pool                      |
|   w1   w2   w3   ...   wN  (concorrencia limit.) |
|   cada envio: pega token -> chama Cloud API      |
+--------------------------------------------------+
        |                         |
        | 200 OK                  | 429 / 5xx -> retry backoff
        v                         v
   entregue                  reenfileira
                                  |
                             esgotou retries
                                  v
                             +--------+
                             |  DLQ   |
                             +--------+

O ponto-chave é que dois controles atuam juntos e por motivos diferentes. A concorrência do worker pool (quantos envios acontecem em paralelo) protege seus próprios recursos: conexões, memória, sockets. O rate limiter (quantos envios por segundo no total) protege o limite externo da Cloud API. Você pode ter 20 workers concorrentes, mas se o token bucket só libera 80 tokens por segundo, o teto efetivo continua 80 msg/s. Os dois precisam ser dimensionados juntos.

03

Backpressure: por que não disparar tudo de uma vez

Backpressure é o mecanismo que faz o produtor desacelerar quando o consumidor não dá conta. Numa campanha, a fila já exerce esse papel: o produtor enfileira rápido, mas os workers consomem na taxa do rate limiter. O que você não pode fazer é burlar a fila e empurrar tudo para a API. Os motivos são concretos:

  • Messaging tier: o número só pode iniciar um número fixo de conversas business-initiated em 24h (1k, 10k, 100k). Passar do tier retorna erro e não adianta insistir no mesmo dia.
  • Qualidade do número: a Meta mede em tempo real bloqueios, denúncias e marcações de spam. Um pico de disparo para uma lista fria derruba a qualidade rápido, e qualidade baixa rebaixa o tier.
  • Risco de block: qualidade vermelha somada a volume agressivo leva a Meta a restringir ou banir o número, derrubando inclusive as mensagens transacionais legítimas.
  • Erros 429 em cascata: estourar o rate limit gera 429 em massa; sem backpressure, o retry ingênuo realimenta a tempestade e degrada a taxa de entrega geral.
  • Custo e janela: conversas têm custo e janela de 24h. Disparar fora do ritmo desperdiça tier útil e pode estourar orçamento sem entregar mais rápido de fato.

A leitura de sistemas distribuídos é simples: a fila é o seu buffer e o rate limiter é a sua válvula. Backpressure não é uma limitação a contornar, é a garantia de que o pico se transforma em entrega sustentada em vez de bloqueio.

04

Rate limiter + worker consumindo a fila a X msg/s

Na prática, BullMQ já oferece um limiter nativo no Worker, que implementa o controle de taxa sobre o grupo de workers que compartilham a mesma fila e Redis. Você define quantos jobs podem ser processados por janela de tempo, e a concorrência controla o paralelismo dentro desse teto. O exemplo abaixo enfileira a campanha e consome respeitando 80 mensagens por segundo, com retry exponencial e DLQ para falhas definitivas.

const { Queue, Worker, QueueEvents } = require('bullmq');
const connection = { url: process.env.REDIS_URL };

// Throughput alvo: mantenha abaixo do limite real da Cloud API.
const MESSAGES_PER_SECOND = 80;

const campaignQueue = new Queue('wa-campaign', { connection });
const dlq = new Queue('wa-campaign-dlq', { connection });

// Produtor: enfileira 1 job por contato. A fila absorve a rajada.
async function enqueueCampaign(campaignId, contacts, template) {
  const jobs = contacts.map((contact) => ({
    name: 'send',
    data: { campaignId, to: contact.phone, template },
    opts: {
      attempts: 5,
      backoff: { type: 'exponential', delay: 2000 }, // 2s, 4s, 8s, 16s
      removeOnComplete: true,
      removeOnFail: false,
    },
  }));
  await campaignQueue.addBulk(jobs);
}

// Worker pool: o limiter regula a SAIDA a X msg/s no grupo todo.
const worker = new Worker(
  'wa-campaign',
  async (job) => {
    const { to, template } = job.data;
    const res = await sendViaCloudApi(to, template);
    // Respeita o Retry-After da Meta em vez de martelar a API.
    if (res.status === 429) {
      const retryAfter = Number(res.headers['retry-after'] || 1);
      throw new RateLimitError(retryAfter * 1000);
    }
    if (res.status >= 500) throw new Error('Cloud API 5xx');
    return res.body;
  },
  {
    connection,
    concurrency: 20,               // paralelismo: protege recursos locais
    limiter: {
      max: MESSAGES_PER_SECOND,    // teto de envios...
      duration: 1000,              // ...por segundo (token bucket)
    },
  },
);

// Falha definitiva apos esgotar os retries: vai para a DLQ, nao some.
worker.on('failed', async (job, err) => {
  if (job && job.attemptsMade >= (job.opts.attempts || 1)) {
    await dlq.add('dead-letter', {
      payload: job.data,
      error: err.message,
      failedAt: new Date().toISOString(),
    });
  }
});

Note como o 429 não é tratado como erro permanente: o worker lança um erro de rate limit e o job volta para a fila com backoff, respeitando o Retry-After da Meta. Só depois de esgotar todas as tentativas o evento vai para a DLQ. Isso é o que diferencia throttling temporário de falha real e evita perder entregas que sairiam minutos depois.

05

Priorização: transacional na frente de marketing

Durante um pico de campanha, uma mensagem transacional (código OTP, confirmação de pedido, alerta) não pode ficar atrás de 100 mil mensagens de marketing na fila. A solução é separar por prioridade. Há duas abordagens, e em alto volume vale combinar as duas:

  • Filas separadas por classe: uma fila wa-transactional e outra wa-campaign, com workers e até budgets de taxa distintos. A transacional tem prioridade de recursos e não compete por tokens com a campanha.
  • Prioridade dentro da fila: usar o campo de priority do BullMQ para que jobs urgentes furem a frente sem precisar de uma fila física separada quando o volume é menor.
  • Reserva de capacidade: se a Cloud API permite N msg/s no total, reserve uma fatia (ex.: 20%) para transacional e dimensione o limiter da campanha para o restante, garantindo que o marketing nunca consuma 100% do throughput.
  • Throttle assimétrico: marketing tolera atraso de minutos; transacional não. Dimensione retries e backoff mais agressivos na campanha e mais curtos no transacional.

O princípio de sistemas distribuídos aqui é isolamento de recursos: você não deixa uma carga de baixa prioridade e alto volume degradar a latência de uma carga crítica e de baixo volume. Filas separadas com budgets próprios são a forma mais limpa de garantir esse isolamento.

06

Parâmetros a dimensionar

Dimensionar a fila é um exercício de calibrar poucos parâmetros contra o limite real do seu número e a urgência de cada classe de mensagem. A tabela resume os principais e os critérios.

ParâmetroO que controlaComo dimensionar
Throughput alvo (msg/s)Taxa de saída do limiterAbaixo do rate limit real da Cloud API, com margem de segurança (ex.: 80% do teto)
Concorrência do workerParalelismo dos envios em vooSuficiente para saturar o throughput sem esgotar conexões/memória; sobe até o limiter virar o gargalo
TTL / janela de validadeQuanto tempo um job pode esperar antes de virar irrelevanteCurto para transacional (segundos/minutos); maior para marketing dentro da janela de 24h
RetriesTentativas antes de desistir3 a 5 com backoff exponencial; respeitando Retry-After em 429 sem contar como falha permanente
DLQDestino das falhas definitivasSempre ativa; com alerta de crescimento para inspeção humana de erros reais (número inválido, template rejeitado)
Reserva transacionalFatia de throughput protegidaPercentual fixo (ex.: 20%) que a campanha nunca consome, garantindo latência do crítico

07

O que monitorar durante o pico

Durante um pico, três sinais dizem se o sistema está saudável ou afundando. Profundidade da fila mostra o backlog: subir é esperado no início, mas precisa drenar a uma taxa estável. Idade da mensagem (quanto tempo o job mais antigo espera) revela se a entrega está dentro da janela aceitável; idade crescente no transacional é alarme imediato. Taxa de erro, separada por tipo (429 de rate limit, 4xx de template inválido, 5xx da API), distingue throttling esperado de falha real.

  1. Profundidade da fila por classe: backlog de campanha e de transacional medidos separadamente, com a taxa de drenagem.
  2. Idade da mensagem mais antiga: tempo de espera do job no topo da fila, com alerta diferente por prioridade.
  3. Taxa de erro segmentada: 429 (rate limit, esperado), 4xx (template/número, acionável), 5xx (API instável).
  4. Tamanho e crescimento da DLQ: deve ficar próxima de zero; crescimento aponta erro real a investigar.
  5. Qualidade e tier do número: acompanhar o phone quality rating e o messaging tier para frear a campanha antes do rebaixamento.
  6. Throughput efetivo vs alvo: comparar msg/s reais com o alvo do limiter para detectar gargalo de worker ou throttle da API.

FAQ

Perguntas frequentes

Como descubro o rate limit certo para configurar o limiter?

Comece pelo limite documentado da Cloud API para o seu número e tier, mas trate-o como teto, não como alvo. Configure o limiter com margem (por exemplo 80% do teto) e observe a taxa de 429 durante um pico real. Se aparecerem 429 mesmo abaixo do teto, reduza o throughput alvo. O objetivo é operar num ponto onde o 429 é raro e tratado por retry, não a norma.

Qual a diferença entre concorrência do worker e rate limiter?

São dois controles com propósito distinto. A concorrência limita quantos envios acontecem em paralelo e protege seus recursos locais (conexões, memória, sockets). O rate limiter limita quantos envios por segundo no total e protege o limite externo da Cloud API. Você pode ter alta concorrência, mas o rate limiter ainda segura a saída no teto de msg/s. Os dois precisam ser dimensionados em conjunto.

O que fazer quando o messaging tier do número se esgota no meio da campanha?

Quando o tier se esgota, novos disparos business-initiated retornam erro e não adianta reenviar no mesmo dia. O correto é detectar esse erro específico, pausar a fila de campanha (sem perder os jobs, que ficam enfileirados) e retomar quando a janela de 24h renova ou quando o tier sobe. As mensagens transacionais em fila separada continuam fluindo. Tratar isso como pausa controlada, e não como falha, evita marcar contatos válidos como erro.

Pico de campanha é problema de fluxo, não de loop

Fila durável, token bucket respeitando o rate limit da Cloud API, backpressure consciente do messaging tier e priorização do transacional formam a arquitetura que entrega campanhas em massa sem bloqueio nem perda. Se você precisa disparar volume alto sem arriscar a qualidade do número, posso ajudar a desenhar essa fila.