Filas e Jobs Assíncronos com BullMQ + Redis no NestJS: o guia completo

Existe um momento na vida de quase todo projeto Node em que uma rota que respondia em 80ms passa a responder em 4 segundos. Você abre o código e descobre o motivo: aquele endpoint de cadastro agora envia um e-mail de boas-vindas, gera um PDF de contrato, notifica um webhook externo e ainda atualiza um índice de busca. Tudo dentro do mesmo bloco try/catch, tudo antes do res.send().

O usuário não precisa esperar nada disso. Ele só quer saber que a conta foi criada. O resto é trabalho que pode, e deve, acontecer depois, em segundo plano. É esse o problema que uma fila resolve, e neste guia vamos construir uma solução completa com BullMQ e Redis dentro do NestJS. Vamos do conceito até os detalhes que só aparecem quando a aplicação já está em produção: retry, idempotência, concorrência, rate limit e desligamento seguro.

O que é uma fila, de verdade

Esqueça por um instante a palavra "fila" e pense em um restaurante. A cozinha não para de funcionar enquanto o garçom anota o pedido. O garçom anota, prende a comanda no varal e segue atendendo outras mesas. A cozinha pega as comandas na ordem em que chegam e processa cada uma no seu ritmo. Se um prato dá errado, a comanda volta pro varal. Se a cozinha está sobrecarregada, as comandas simplesmente esperam.

Uma fila de jobs é esse varal. De um lado existe o producer, que é quem cria a comanda, no nosso caso a rota HTTP que recebe o cadastro. Do outro existe o worker, que é a cozinha, consumindo comandas e executando o trabalho de verdade. Entre os dois fica a fila, que é apenas uma estrutura de dados onde os jobs aguardam.

O ganho central é o desacoplamento no tempo. A rota HTTP termina assim que escreve a comanda no varal. Ela não espera o e-mail ser enviado nem o PDF ser gerado. O usuário recebe a resposta em milissegundos, e o trabalho pesado acontece de forma independente. Como efeito colateral você ainda ganha resiliência: se o envio do e-mail falhar, o job pode ser tentado de novo sem que o usuário sequer perceba.

Por que Redis é o motor disso

BullMQ não inventa um banco de dados próprio. Ele usa o Redis como armazenamento, e entender o porquê ajuda a confiar na ferramenta.

Um job, ao ser enfileirado, vira um conjunto de estruturas no Redis: hashes guardam os dados do job, listas guardam os jobs aguardando, e sorted sets organizam jobs atrasados ou agendados por timestamp. Tudo isso fica em memória, o que torna a leitura e a escrita extremamente rápidas.

O ponto mais importante, porém, é a atomicidade. BullMQ move jobs entre estados usando scripts Lua, que o Redis executa de forma atômica. Isso garante uma propriedade que parece simples mas é difícil de conseguir: quando dois workers estão rodando ao mesmo tempo, um job nunca é entregue para os dois. Ou ele vai para um, ou vai para o outro. Sem essa garantia, você teria e-mails duplicados e cobranças repetidas.

Por fim, o Redis pode ser configurado com persistência em disco. Se o servidor reiniciar, os jobs que ainda não foram processados continuam lá. E como Redis é um serviço de rede, a sua API e os seus workers podem rodar em processos ou máquinas diferentes, todos olhando para a mesma fila.

BullMQ ou Bull? O que você precisa saber

Se você pesquisar sobre filas em Node, vai esbarrar em dois nomes parecidos. O Bull é a biblioteca original, ainda funcional, mas em modo de manutenção. O BullMQ é a reescrita feita pela mesma equipe, com TypeScript de primeira classe, uma arquitetura mais limpa e recursos novos como fluxos de jobs com dependência entre pai e filho.

Para um projeto novo, use BullMQ. E dentro do NestJS, use o pacote oficial @nestjs/bullmq, não o antigo @nestjs/bull. O resto deste guia assume essa escolha.

As quatro peças do BullMQ

Antes de escrever código, vale fixar o vocabulário. BullMQ se organiza em torno de quatro objetos:

  • Queue: a representação da fila do lado de quem produz. É por ela que você adiciona jobs.

  • Worker: o processo que consome jobs e executa o trabalho. No NestJS, ele aparece disfarçado de classe com o decorator @Processor.

  • Job: a unidade de trabalho. Tem um nome, um payload de dados e um conjunto de opções.

  • QueueEvents: um fluxo de eventos da fila inteira, útil quando você precisa reagir a jobs a partir de outro processo que não é o worker.

Existe ainda o FlowProducer, para quando um job só pode rodar depois que seus jobs filhos terminarem. É um recurso poderoso, mas fica fora do escopo deste guia introdutório.

O ciclo de vida de um job

Essa é a parte que mais gente ignora e que mais causa confusão depois. Um job não simplesmente "roda". Ele transita por estados, e conhecer esses estados é o que separa quem usa fila de quem sofre com fila.

  • waiting: o job foi adicionado e está na fila, aguardando um worker livre.

  • delayed: o job tem um atraso configurado ou está aguardando o intervalo de uma nova tentativa. Ele só vira waiting quando o tempo chega.

  • active: um worker pegou o job e está executando.

  • completed: o processamento terminou sem lançar erro.

  • failed: o processamento lançou um erro e não há mais tentativas restantes.

O detalhe que muda tudo: quando um job em active lança um erro e ainda existem tentativas configuradas, ele não vai direto para failed. Ele volta para delayed, espera o tempo de backoff, e depois retorna a waiting para ser pego de novo. Só quando as tentativas se esgotam é que o estado final failed é gravado. Guarde isso, porque é a base das próximas seções.

Montando o projeto

Vamos partir de um projeto NestJS limpo. Primeiro as dependências:

npm install @nestjs/bullmq bullmq

E o Redis. Para desenvolvimento, um container resolve sem instalar nada na máquina:

docker run -d --name redis -p 6379:6379 redis:7-alpine

O bullmq é a biblioteca em si, e o @nestjs/bullmq é a camada que integra tudo ao sistema de módulos e injeção de dependência do Nest.

Configurando o módulo de filas

A conexão com o Redis é registrada uma única vez, normalmente no AppModule. Evite cravar host e porta no código. Use o ConfigService e a versão assíncrona do módulo:

// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { BullModule } from '@nestjs/bullmq';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    BullModule.forRootAsync({
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        connection: {
          host: config.get('REDIS_HOST', 'localhost'),
          port: config.get('REDIS_PORT', 6379),
        },
      }),
    }),
    BullModule.registerQueue({ name: 'emails' }),
  ],
})
export class AppModule {}

O forRootAsync define a conexão global. O registerQueue declara uma fila específica chamada emails. Cada fila que a aplicação usar precisa ser registrada assim, e o nome dela é o identificador que vai amarrar producer e worker.

Criando o producer

O producer é qualquer serviço que precise jogar trabalho na fila. Você injeta a Queue pelo nome e chama add:

// email.service.ts
import { Injectable } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';

@Injectable()
export class EmailService {
  constructor(
    @InjectQueue('emails') private readonly emailQueue: Queue,
  ) {}

  async enfileirarBoasVindas(usuarioId: string) {
    await this.emailQueue.add('boas-vindas', { usuarioId });
  }
}

O primeiro argumento do add é o nome do job. O segundo é o payload, ou seja, os dados que o worker vai receber. Repare que passamos apenas o usuarioId, e não o objeto inteiro do usuário. Esse é um cuidado deliberado, e a seção de erros comuns explica o motivo.

Esse enfileirarBoasVindas é o que sua rota HTTP de cadastro vai chamar. A rota responde imediatamente; o e-mail vira problema do worker.

Criando o worker

No NestJS, o worker é uma classe decorada com @Processor que estende WorkerHost. O método process é chamado para cada job:

// email.processor.ts
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import { Logger } from '@nestjs/common';

@Processor('emails')
export class EmailProcessor extends WorkerHost {
  private readonly logger = new Logger(EmailProcessor.name);

  async process(job: Job): Promise<void> {
    switch (job.name) {
      case 'boas-vindas':
        await this.enviarBoasVindas(job.data.usuarioId);
        break;
      default:
        this.logger.warn(`Job desconhecido: ${job.name}`);
    }
  }

  private async enviarBoasVindas(usuarioId: string) {
    // busca o usuário, monta o template e dispara o e-mail
  }
}

Como uma fila pode carregar vários tipos de job, o switch em job.name é o padrão recomendado para rotear cada tipo para o seu método. Não esqueça de declarar o EmailProcessor nos providers do módulo. O @nestjs/bullmq cuida de instanciar o Worker do BullMQ por baixo dos panos.

Uma regra de ouro: se o process termina sem lançar erro, o job é marcado como completed. Se ele lança um erro, o job falha. Essa é a única forma de o BullMQ saber se deu certo. Por isso, nunca engula exceções dentro do process com um catch vazio, ou você vai perder o retry sem perceber.

Opções de job: onde mora o controle

O terceiro argumento do add é um objeto de opções, e é aqui que a fila deixa de ser um brinquedo. As principais:

Opção

O que faz

attempts

Quantas vezes o job pode ser tentado antes de falhar de vez.

backoff

Quanto esperar entre tentativas. Pode ser fixo ou exponencial.

delay

Adia o início do job por um número de milissegundos.

priority

Define ordem de prioridade. Valores menores são processados antes.

removeOnComplete

Remove ou limita o histórico de jobs concluídos.

removeOnFail

Remove ou limita o histórico de jobs que falharam.

jobId

Define um identificador próprio, base para evitar duplicação.

Na prática, um add de produção quase nunca vai sozinho:

await this.emailQueue.add(
  'boas-vindas',
  { usuarioId },
  {
    attempts: 3,
    backoff: { type: 'exponential', delay: 2000 },
    removeOnComplete: 1000,
    removeOnFail: 5000,
  },
);

Essa configuração diz: tente até 3 vezes; se falhar, espere de forma exponencial começando em 2 segundos, ou seja, 2s, depois 4s, depois 8s; mantenha no máximo os 1000 jobs concluídos e os 5000 que falharam mais recentes. Esse removeOnComplete não é detalhe estético, e a seção de erros explica por quê.

Retry e tratamento de falhas

Com attempts e backoff configurados, o retry é automático. Mas o retry cego é uma armadilha. Existem dois tipos de erro, e tratá-los igual desperdiça recursos.

Um erro transitório é temporário: o provedor de e-mail respondeu com timeout, o banco estava momentaneamente indisponível, a rede oscilou. Tentar de novo daqui a alguns segundos provavelmente resolve. Esse é o caso para o qual o attempts existe.

Um erro permanente é definitivo: o usuário do job não existe mais, o endereço de e-mail é inválido, o payload está malformado. Tentar de novo três vezes não vai mudar nada, só vai gastar tempo e poluir os logs. Para esses casos, o BullMQ oferece o UnrecoverableError. Ao lançar esse erro específico, o job vai direto para failed, ignorando as tentativas restantes:

import { UnrecoverableError } from 'bullmq';

private async enviarBoasVindas(usuarioId: string) {
  const usuario = await this.usuarios.buscar(usuarioId);

  if (!usuario) {
    // não adianta tentar de novo, o usuário não existe
    throw new UnrecoverableError(`Usuário ${usuarioId} não encontrado`);
  }

  // um erro comum aqui (ex: timeout) sobe normal e dispara o retry
  await this.mailer.enviar(usuario.email, 'boas-vindas');
}

A regra mental é simples: erro que vale a pena repetir, deixe subir como erro normal; erro que não vale, lance como UnrecoverableError.

Idempotência: o conceito que evita o pesadelo

Aqui está a parte que tutoriais raros explicam e que separa um sistema confiável de um gerador de bugs intermitentes.

BullMQ garante entrega ao menos uma vez, e não exatamente uma vez. A diferença é enorme. Imagine o seguinte: o worker pega o job, envia o e-mail com sucesso, e então, no instante antes de marcar o job como completed no Redis, o processo cai. Um deploy, um kill, uma falta de memória. Quando o worker sobe de novo, o BullMQ encontra um job que ficou preso em active, conclui que ele não terminou e o coloca de volta para ser processado. Resultado: o e-mail é enviado duas vezes.

Isso não é um defeito do BullMQ. É uma consequência inevitável de qualquer sistema distribuído. A solução não é tentar impedir que o job rode duas vezes, e sim fazer com que rodar duas vezes não cause dano. Esse é o significado de idempotência: a segunda execução produz o mesmo resultado da primeira, sem efeito colateral extra.

Existem duas frentes de defesa. A primeira é evitar duplicação já na entrada, usando o jobId. Se você adicionar dois jobs com o mesmo jobId, o BullMQ ignora o segundo:

await this.emailQueue.add(
  'boas-vindas',
  { usuarioId },
  { jobId: `boas-vindas:${usuarioId}` },
);

Assim, se a sua rota de cadastro for chamada duas vezes por um clique duplo, só um job de boas-vindas existe para aquele usuário.

A segunda frente, mais importante, é tornar o próprio handler idempotente. Antes de executar o efeito, verifique se ele já foi executado:

private async enviarBoasVindas(usuarioId: string) {
  const usuario = await this.usuarios.buscar(usuarioId);
  if (!usuario) {
    throw new UnrecoverableError(`Usuário ${usuarioId} não encontrado`);
  }

  // checa antes de agir: este e-mail já saiu?
  if (usuario.boasVindasEnviadoEm) {
    return; // job duplicado, encerra sem reenviar
  }

  await this.mailer.enviar(usuario.email, 'boas-vindas');
  await this.usuarios.marcarBoasVindas(usuarioId, new Date());
}

Sempre que projetar um job, faça a si mesmo a pergunta: "se isso rodar duas vezes seguidas, algo quebra?". Se a resposta for sim, você ainda não terminou.

Jobs agendados e repetíveis

Filas não servem só para reagir a eventos. Elas também substituem com vantagem aquele cron espalhado pelo código. BullMQ permite jobs repetíveis com um padrão de agendamento:

// roda todo dia às 9h
await this.relatorioQueue.add(
  'relatorio-diario',
  {},
  { repeat: { pattern: '0 9 * * *' } },
);

// ou a cada 30 segundos
await this.healthQueue.add(
  'verificar-saude',
  {},
  { repeat: { every: 30_000 } },
);

O pattern usa a sintaxe padrão de cron. O every usa um intervalo fixo em milissegundos. A vantagem sobre um cron tradicional é que o agendamento agora vive no Redis, é compartilhado entre processos e ganha de graça retry, observabilidade e o painel de monitoramento.

Um detalhe operacional: jobs repetíveis não somem se você apenas parar de chamar o add. Eles ficam registrados no Redis. Para remover um agendamento, você lista os repetíveis com getRepeatableJobs() e remove pelo identificador retornado. Vale registrar isso em algum script de manutenção.

Concorrência e rate limiting

Por padrão, um worker processa um job por vez. Se você tem 500 e-mails na fila e cada um leva 1 segundo, são mais de 8 minutos para esvaziar. A concorrência resolve isso permitindo que um mesmo worker processe vários jobs em paralelo:

@Processor('emails', { concurrency: 10 })
export class EmailProcessor extends WorkerHost {
  // ...
}

Agora são 10 jobs simultâneos. Mas cuidado: concorrência alta demais pode estourar o limite de conexões do seu banco, ou bombardear uma API externa que tem cota. É aqui que entra o limiter:

@Processor('emails', {
  concurrency: 10,
  limiter: { max: 50, duration: 1000 },
})

Essa configuração diz: rode até 10 jobs ao mesmo tempo, mas nunca ultrapasse 50 jobs por segundo no total. Se o seu provedor de e-mail permite 100 requisições por segundo, você ajusta o limiter para ficar com folga abaixo disso. A fila passa a respeitar a cota sozinha, sem você espalhar sleep pelo código.

Um aviso importante: com concorrência maior que 1, a ordem de conclusão dos jobs deixa de ser garantida. Se a sua lógica depende de processar jobs em sequência estrita, ou você mantém concorrência 1, ou repensa o desenho do problema.

Eventos e observabilidade

Você vai querer saber quando jobs concluem, falham ou progridem. O @nestjs/bullmq expõe isso com o decorator @OnWorkerEvent dentro da própria classe do processor:

import { OnWorkerEvent } from '@nestjs/bullmq';

@OnWorkerEvent('active')
onActive(job: Job) {
  this.logger.log(`Iniciando job ${job.id} (${job.name})`);
}

@OnWorkerEvent('completed')
onCompleted(job: Job) {
  this.logger.log(`Job ${job.id} concluído`);
}

@OnWorkerEvent('failed')
onFailed(job: Job, erro: Error) {
  this.logger.error(`Job ${job.id} falhou: ${erro.message}`);
}

Para jobs longos, como gerar um relatório grande, você ainda pode reportar progresso de dentro do process com await job.updateProgress(40), e ouvir o evento progress. Isso alimenta barras de progresso na interface ou métricas de acompanhamento.

Desligamento seguro

Esse tópico costuma ser descoberto da pior forma: um deploy mata o processo no meio de um job, e algo fica pela metade.

Quando um servidor recebe o sinal de término, o que normalmente acontece em todo deploy, você não quer que ele desligue na hora. Você quer que ele pare de pegar jobs novos e dê tempo para os jobs active terminarem. Isso se chama desligamento gracioso.

No NestJS, o primeiro passo é habilitar os hooks de ciclo de vida:

// main.ts
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks();
await app.listen(3000);

Com isso ligado, o @nestjs/bullmq fecha os workers de forma ordenada quando a aplicação encerra, aguardando os jobs em andamento concluírem antes de soltar o processo. Sem essa linha, um deploy corriqueiro pode interromper trabalho no meio. É uma linha só, e ela evita uma classe inteira de bugs difíceis de reproduzir.

Enxergando a fila com o Bull Board

Operar fila no escuro é doloroso. O Bull Board é um painel web que mostra jobs aguardando, ativos, concluídos e falhos, com opção de reprocessar manualmente. A instalação no NestJS:

npm install @bull-board/nestjs @bull-board/api @bull-board/express
import { BullBoardModule } from '@bull-board/nestjs';
import { ExpressAdapter } from '@bull-board/express';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';

@Module({
  imports: [
    BullBoardModule.forRoot({
      route: '/filas',
      adapter: ExpressAdapter,
    }),
    BullBoardModule.forFeature({
      name: 'emails',
      adapter: BullMQAdapter,
    }),
  ],
})
export class AppModule {}

Com isso, o painel fica disponível em /filas. Um aviso de segurança que não pode faltar: esse painel expõe dados internos da aplicação. Em produção, ele precisa ficar atrás de autenticação, nunca aberto na internet.

Separe os workers da API

No começo, é tentador rodar a API HTTP e os workers no mesmo processo. Funciona, e para um projeto pequeno até serve. Mas existe um motivo forte para separar quando o tráfego cresce.

Node roda em uma única thread principal. Um job que faz trabalho pesado de CPU, como gerar um PDF complexo, segura o event loop. Se esse job está no mesmo processo da sua API, todas as requisições HTTP ficam mais lentas enquanto o PDF é gerado. A cozinha está atrapalhando o salão.

A solução é ter dois tipos de processo a partir do mesmo código: um que sobe a aplicação HTTP, e outro que sobe apenas os módulos de fila, sem escutar porta nenhuma. Você escala cada um de forma independente: mais réplicas de API nos horários de pico de acesso, mais workers quando a fila cresce. Os dois conversam pelo mesmo Redis, então a separação é puramente de processo, não de código de negócio.

Erros comuns em produção

Reunindo as armadilhas que mais aparecem, e que valem como checklist antes de subir:

  • Payload gigante no job. Não coloque o objeto inteiro do usuário, ou pior, um arquivo, dentro do data. Tudo isso vai para o Redis, que é memória cara. Guarde apenas identificadores e busque o resto dentro do worker.

  • Esquecer o removeOnComplete. Sem ele, cada job concluído fica para sempre no Redis. Em uma fila movimentada, a memória cresce sem parar até o Redis cair. Sempre limite o histórico.

  • Engolir exceções no process. Um catch vazio faz o job ser marcado como concluído mesmo tendo falhado. Você perde o retry e não fica sabendo do erro. Deixe o erro subir.

  • Tratar o job como se rodasse uma vez só. Já cobrimos isso na seção de idempotência, mas vale repetir: a entrega é ao menos uma vez, então o handler precisa aguentar rodar de novo.

  • Concorrência sem rate limit. Subir concurrency sem pensar no banco e nas APIs externas troca um gargalo por uma sobrecarga.

  • Conexão Redis mal configurada. Se você passar uma instância própria do ioredis para o BullMQ em vez do objeto de conexão simples, ela precisa ter maxRetriesPerRequest definido como null. O BullMQ exige isso para os workers funcionarem de forma confiável.

Quando NÃO usar fila

Um guia honesto também diz quando a ferramenta é exagero. Fila adiciona uma peça de infraestrutura, o Redis, e uma camada de complexidade operacional. Nem todo problema justifica isso.

Se o trabalho é rápido e o usuário precisa do resultado na mesma resposta, como uma validação ou um cálculo simples, fila só atrapalha. Se você precisa apenas de uma tarefa agendada e nada mais, um cron tradicional pode bastar. E se o projeto é pequeno, sem volume e sem previsão de crescer, vale começar simples e introduzir a fila quando a dor aparecer de verdade.

A regra: use fila quando o trabalho é demorado, pode falhar e ser repetido, ou precisa rodar de forma independente da requisição. Fora disso, pense duas vezes.

Conclusão

Filas deixam de ser um detalhe de arquitetura no momento em que a aplicação começa a fazer trabalho que o usuário não deveria esperar. Com BullMQ e Redis dentro do NestJS, você ganha desacoplamento no tempo, retry automático, jobs agendados, controle fino de concorrência e um painel para enxergar tudo isso.

Mas a parte que realmente importa não está na configuração, e sim na mentalidade: pensar em estados, projetar handlers idempotentes, distinguir erro transitório de permanente e garantir desligamento seguro. É isso que faz uma fila aguentar produção em vez de virar fonte de bug.

A partir daqui, dois caminhos valem a exploração: os fluxos de jobs com dependência entre pai e filho, para orquestrar processos com várias etapas, e a coleta de métricas da fila, integrando os eventos a uma ferramenta de observabilidade. Mas com o que está neste guia, você já tem uma base sólida o suficiente para colocar trabalho assíncrono em produção com tranquilidade.