Blog

Arquitetura de fila para picos de campanha no WhatsApp

Uma campanha de marketing aperta o botao de disparo e, em um instante, 100 mil mensagens entram na fila para sair. O problema e que a WhatsApp Cloud API nao aceita esse volume de uma vez: existe um rate limit de chamadas por segundo e o numero tem um messaging tier que limita quantos usuarios unicos voce pode iniciar conversa em 24h. Disparar tudo de uma vez e a receita certa para erros 429, quedas de qualidade e bloqueio do numero. 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 e iterar a lista de contatos e chamar a API em loop. Funciona com mil contatos. Com cem mil, o sistema colide com tres limites simultaneos. Primeiro, o rate limit da Cloud API: ha um teto de requisicoes por segundo por numero, e estourar esse teto retorna HTTP 429 com Retry-After. Segundo, o messaging tier do numero, que limita quantas conversas iniciadas por negocio (business-initiated) voce pode abrir em uma janela de 24h: 1k, 10k, 100k ou ilimitado, dependendo do tier. Terceiro, a qualidade do numero, que a Meta avalia em tempo real e que, se cair, rebaixa o tier ou bloqueia o envio.

A consequencia de ignorar esses limites nao e so lentidao. Disparos em rajada geram um pico de 429, e o codigo ingenuo 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 temporario faz voce perder entregas que poderiam ter saido minutos depois. O envio em massa precisa ser tratado como um problema de fluxo controlado, nao 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 duravel guarda esses jobs. Um pool de workers consome a fila, mas nao a velocidade maxima: um rate limiter no estilo token bucket regula a saida para respeitar exatamente o limite de mensagens por segundo da Cloud API. O token bucket e a peca 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 e que dois controles atuam juntos e por motivos diferentes. A concorrencia do worker pool (quantos envios acontecem em paralelo) protege seus proprios recursos: conexoes, memoria, sockets. O rate limiter (quantos envios por segundo no total) protege o limite externo da Cloud API. Voce pode ter 20 workers concorrentes, mas se o token bucket so libera 80 tokens por segundo, o teto efetivo continua 80 msg/s. Os dois precisam ser dimensionados juntos.

03

Backpressure: por que nao disparar tudo de uma vez

Backpressure e o mecanismo que faz o produtor desacelerar quando o consumidor nao da conta. Numa campanha, a fila ja exerce esse papel: o produtor enfileira rapido, mas os workers consomem na taxa do rate limiter. O que voce nao pode fazer e burlar a fila e empurrar tudo para a API. Os motivos sao concretos:

  • Messaging tier: o numero so pode iniciar um numero fixo de conversas business-initiated em 24h (1k, 10k, 100k). Passar do tier retorna erro e nao adianta insistir no mesmo dia.
  • Qualidade do numero: a Meta mede em tempo real bloqueios, denuncias e marcacoes de spam. Um pico de disparo para uma lista fria derruba a qualidade rapido, e qualidade baixa rebaixa o tier.
  • Risco de block: qualidade vermelha somada a volume agressivo leva a Meta a restringir ou banir o numero, derrubando inclusive as mensagens transacionais legitimas.
  • Erros 429 em cascata: estourar o rate limit gera 429 em massa; sem backpressure, o retry ingenuo realimenta a tempestade e degrada a taxa de entrega geral.
  • Custo e janela: conversas tem custo e janela de 24h. Disparar fora do ritmo desperdicia tier util e pode estourar orcamento sem entregar mais rapido de fato.

A leitura de sistemas distribuidos e simples: a fila e o seu buffer e o rate limiter e a sua valvula. Backpressure nao e uma limitacao a contornar, e 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 pratica, BullMQ ja oferece um limiter nativo no Worker, que implementa o controle de taxa sobre o grupo de workers que compartilham a mesma fila e Redis. Voce define quantos jobs podem ser processados por janela de tempo, e a concorrencia 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 nao e tratado como erro permanente: o worker lanca um erro de rate limit e o job volta para a fila com backoff, respeitando o Retry-After da Meta. So depois de esgotar todas as tentativas o evento vai para a DLQ. Isso e o que diferencia throttling temporario de falha real e evita perder entregas que sairiam minutos depois.

05

Priorizacao: transacional na frente de marketing

Durante um pico de campanha, uma mensagem transacional (codigo OTP, confirmacao de pedido, alerta) nao pode ficar atras de 100 mil mensagens de marketing na fila. A solucao e separar por prioridade. Ha 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 ate budgets de taxa distintos. A transacional tem prioridade de recursos e nao 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 fisica separada quando o volume e 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 assimetrico: marketing tolera atraso de minutos; transacional nao. Dimensione retries e backoff mais agressivos na campanha e mais curtos no transacional.

O principio de sistemas distribuidos aqui e isolamento de recursos: voce nao deixa uma carga de baixa prioridade e alto volume degradar a latencia de uma carga critica e de baixo volume. Filas separadas com budgets proprios sao a forma mais limpa de garantir esse isolamento.

06

Parametros a dimensionar

Dimensionar a fila e um exercicio de calibrar poucos parametros contra o limite real do seu numero e a urgencia de cada classe de mensagem. A tabela resume os principais e os criterios.

ParametroO que controlaComo dimensionar
Throughput alvo (msg/s)Taxa de saida do limiterAbaixo do rate limit real da Cloud API, com margem de seguranca (ex.: 80% do teto)
Concorrencia do workerParalelismo dos envios em vooSuficiente para saturar o throughput sem esgotar conexoes/memoria; sobe ate 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 inspecao humana de erros reais (numero invalido, template rejeitado)
Reserva transacionalFatia de throughput protegidaPercentual fixo (ex.: 20%) que a campanha nunca consome, garantindo latencia do critico

07

O que monitorar durante o pico

Durante um pico, tres sinais dizem se o sistema esta saudavel ou afundando. Profundidade da fila mostra o backlog: subir e esperado no inicio, mas precisa drenar a uma taxa estavel. Idade da mensagem (quanto tempo o job mais antigo espera) revela se a entrega esta dentro da janela aceitavel; idade crescente no transacional e alarme imediato. Taxa de erro, separada por tipo (429 de rate limit, 4xx de template invalido, 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/numero, acionavel), 5xx (API instavel).
  4. Tamanho e crescimento da DLQ: deve ficar proxima de zero; crescimento aponta erro real a investigar.
  5. Qualidade e tier do numero: 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 numero e tier, mas trate-o como teto, nao 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 e operar num ponto onde o 429 e raro e tratado por retry, nao a norma.

Qual a diferenca entre concorrencia do worker e rate limiter?

Sao dois controles com proposito distinto. A concorrencia limita quantos envios acontecem em paralelo e protege seus recursos locais (conexoes, memoria, sockets). O rate limiter limita quantos envios por segundo no total e protege o limite externo da Cloud API. Voce pode ter alta concorrencia, mas o rate limiter ainda segura a saida no teto de msg/s. Os dois precisam ser dimensionados em conjunto.

O que fazer quando o messaging tier do numero se esgota no meio da campanha?

Quando o tier se esgota, novos disparos business-initiated retornam erro e nao adianta reenviar no mesmo dia. O correto e detectar esse erro especifico, 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 nao como falha, evita marcar contatos validos como erro.

Pico de campanha e problema de fluxo, nao de loop

Fila duravel, token bucket respeitando o rate limit da Cloud API, backpressure consciente do messaging tier e priorizacao do transacional formam a arquitetura que entrega campanhas em massa sem bloqueio nem perda. Se voce precisa disparar volume alto sem arriscar a qualidade do numero, posso ajudar a desenhar essa fila.