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
waitingquando 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 bullmqE o Redis. Para desenvolvimento, um container resolve sem instalar nada na máquina:
docker run -d --name redis -p 6379:6379 redis:7-alpineO 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 |
|---|---|
| Quantas vezes o job pode ser tentado antes de falhar de vez. |
| Quanto esperar entre tentativas. Pode ser fixo ou exponencial. |
| Adia o início do job por um número de milissegundos. |
| Define ordem de prioridade. Valores menores são processados antes. |
| Remove ou limita o histórico de jobs concluídos. |
| Remove ou limita o histórico de jobs que falharam. |
| 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/expressimport { 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. Umcatchvazio 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
concurrencysem 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
maxRetriesPerRequestdefinido comonull. 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.