A frase "manda um email pro usuário" parece inofensiva. Você chama o SendGrid, passa o template, e pronto. Funciona lindo com 10 usuários.
Agora coloca 500 mil pedidos por dia, 3 canais de notificação, época de Black Friday, e o serviço de push fora do ar. Aquele sendEmail() dentro da sua API de checkout acabou de se transformar num problema distribuído.
Sistema de notificações envolve fila, prioridade, retry, fallback, preferência de usuário, idempotência, rate limiting. E se você quer entregar notificações em tempo real, adiciona WebSocket distribuído nessa lista.
Nesse post eu vou montar essa arquitetura peça por peça.
Por que você não pode mandar notificação de forma síncrona
Vamos visualizar o problema. Sem fila, o fluxo fica assim:
Se o SendGrid demora 10 segundos, o checkout demora 10 segundos a mais. O usuário acha que não funcionou, clica de novo, e agora você tem pedido duplicado e notificação duplicada.
Multiplica isso por milhares de requisições simultâneas. Cada request segura uma thread/conexão esperando o provider responder. Sua aplicação trava. E a resposta pra isso não é aumentar servidor. Você vai estar multiplicando o problema.
"Ah, mas eu não coloco o await na chamada do SendGrid." Ainda existe memória sendo consumida no seu servidor. Você pode não perceber em escalas menores, mas conforme o volume cresce, vai estourar.
Agora com fila:
O checkout termina em milissegundos. A notificação é processada de forma assíncrona, sem travar nada.
A arquitetura: 5 peças
O sistema inteiro se divide em 5 componentes:
| Componente | O que faz | Exemplos |
|---|---|---|
| Producer | Qualquer serviço que gera um evento que precisa notificar alguém | API de pedidos, chat, pagamento |
| Message Queue | Desacopla o producer do envio. Garante que nada se perde | RabbitMQ, SQS, Kafka |
| NotificationService | Worker que consome a fila, decide o que mandar, por qual canal, pra quem | Seu código |
| Providers | Quem realmente entrega a notificação | SendGrid, Firebase, Twilio |
| Database | Histórico, preferências, templates, auditoria | Postgres, Cassandra, Redis |
A fila: o coração do sistema
Sem a fila, você está ferrado. E eu não estou exagerando.
A fila é o que garante que:
- O producer não precisa esperar o envio
- Mensagens não se perdem se o worker cair
- Você pode processar no ritmo que quiser
- Picos de tráfego não derrubam o sistema
Qual ferramenta usar?
Para notificações, RabbitMQ e SQS já são suficientes. Kafka entra quando você tem volume muito maior e quer usar os eventos pra outras coisas além de notificação.
Filas separadas por prioridade
Você pode (e deve) ter filas com prioridades diferentes:
notification.critical → "Sua conta foi invadida" (processa imediatamente)
notification.high → "Pedido confirmado" (processa em segundos)
notification.low → "Fulano curtiu seu post" (processa quando der)
Uma notificação de segurança não pode ficar atrás de 500 mil curtidas.
O cérebro: NotificationService
O NotificationService é um worker que fica rodando em background consumindo a fila. Antes de enviar qualquer coisa, ele responde 4 perguntas.
1. O que aconteceu?
Ele recebe um evento da fila:
1{ 2 "event": "order.confirmed", 3 "data": { 4 "orderId": "123", 5 "userId": "456" 6 } 7}
A partir do tipo de evento, ele sabe qual template usar:
Olá {{nome}}, seu pedido {{orderId}} foi confirmado!
2. Por qual canal?
Aqui ele consulta a tabela de preferências do usuário:
1SELECT email_enabled, sms_enabled, push_enabled 2FROM user_notification_preferences 3WHERE user_id = '456';
| Canal | Habilitado? |
|---|---|
| ✅ Sim | |
| SMS | ❌ Não |
| Push | ✅ Sim |
O sistema só manda pelos canais que o usuário habilitou. E muitas vezes respeitar isso não é só boa prática. É lei.
3. Já mandei essa mensagem?
Idempotência. Se a fila entregou a mesma mensagem duas vezes (acontece), você não pode notificar o usuário em dobro.
A solução é gerar uma chave única por evento:
1function generateIdempotencyKey(event: NotificationEvent): string { 2 // Combina tipo do evento + IDs relevantes 3 return `${event.event}:${event.data.orderId}:${event.data.userId}`; 4 // Resultado: "order.confirmed:123:456" 5} 6 7async function processNotification(event: NotificationEvent) { 8 const key = generateIdempotencyKey(event); 9 10 // Verifica se já foi processado 11 const existing = await db.query( 12 'SELECT status FROM notification_log WHERE idempotency_key = $1', 13 [key] 14 ); 15 16 if (existing && existing.status === 'sent') { 17 console.log('Notificação já enviada, ignorando duplicata'); 18 return; 19 } 20 21 // Processa e registra 22 await sendNotification(event); 23 await db.query( 24 'INSERT INTO notification_log (idempotency_key, status, sent_at) VALUES ($1, $2, NOW())', 25 [key, 'sent'] 26 ); 27}
4. Tá dentro do limite?
Rate limiting. Você não quer mandar 50 mensagens pro mesmo usuário em 10 minutos.
1const RATE_LIMITS = { 2 push: { max: 5, window: '1h' }, 3 email: { max: 3, window: '1h' }, 4 sms: { max: 2, window: '1h' }, 5}; 6 7async function checkRateLimit(userId: string, channel: string): Promise<boolean> { 8 const limit = RATE_LIMITS[channel]; 9 const count = await redis.get(`ratelimit:${channel}:${userId}`); 10 11 if (count && parseInt(count) >= limit.max) { 12 // Agrupa pra mandar no próximo intervalo 13 await queueForNextBatch(userId, channel); 14 return false; 15 } 16 17 await redis.incr(`ratelimit:${channel}:${userId}`); 18 await redis.expire(`ratelimit:${channel}:${userId}`, parseWindow(limit.window)); 19 return true; 20}
É isso que separa um sistema profissional de um sistema de spam.
Providers e estratégia de fallback
Providers são quem realmente entrega. SendGrid ou Resend pra email, Firebase pra push, Twilio pra SMS.
Mas e se o provider cair?
O retry usa backoff exponencial: falhou, espera 30s. Falhou de novo, 60s. Depois 120s. Após N tentativas, manda pra Dead Letter Queue.
1async function sendWithRetry( 2 provider: Provider, 3 message: NotificationMessage, 4 maxRetries = 4 5) { 6 for (let attempt = 0; attempt < maxRetries; attempt++) { 7 try { 8 await provider.send(message); 9 return { success: true }; 10 } catch (error) { 11 const delay = Math.pow(2, attempt) * 15_000; // 15s, 30s, 60s, 120s 12 console.log(`Tentativa ${attempt + 1} falhou. Retry em ${delay / 1000}s`); 13 await sleep(delay); 14 } 15 } 16 17 // Todas tentativas falharam 18 await deadLetterQueue.publish(message); 19 return { success: false, reason: 'max_retries_exceeded' }; 20}
A Dead Letter Queue é a fila das mensagens mortas. Se ela começa a crescer, você tem um bug. Monitore ela.
Notificação em tempo real com WebSocket
Até agora falamos de email, push e SMS. Mas e aquela notificação que aparece na hora, sem você atualizar nada? Tipo o Instagram mostrando "fulano curtiu sua foto" enquanto você tá scrollando.
Isso funciona com WebSocket. O app do usuário mantém uma conexão aberta com o servidor. Quando chega algo novo, o servidor empurra direto pro aparelho.
O problema da escala
Com um servidor e 1 milhão de usuários, são 1 milhão de conexões abertas. Um servidor não aguenta isso sozinho.
A solução: distribuir entre vários servidores. Mas aí surge um novo problema. O usuário 6 faz uma ação que precisa notificar o usuário 2. O usuário 6 tá no servidor 1, o usuário 2 tá no servidor 3. Como conectar eles?
Redis Pub/Sub resolve. O servidor 1 publica a mensagem no Redis. Todos os outros servidores estão lendo. O servidor 3 encontra o usuário 2 na sua base, marca como lido e entrega a notificação.
WebSocket distribuído sem atrito.
Storage: o que guardar e onde
Você precisa registrar tudo. Sem exceção.
| Dado | O que guardar |
|---|---|
| Evento | Tipo do evento que disparou a notificação |
| Usuário | ID de quem recebeu |
| Canal | Email, push, SMS (cada canal = um registro separado) |
| Conteúdo | O que foi enviado |
| Status | Enviado, falhou, lido, não lido |
| Timestamps | Criado, enviado, lido |
Isso serve pra auditoria ("quando mandamos a confirmação do pedido X?"), debug ("por que o usuário Y não recebeu?") e analytics ("qual canal tem mais taxa de abertura?").
Separando bancos conforme escala
Quando o volume cresce, você divide:
O Postgres fica com dados relacionais (preferências, templates). O Cassandra/DynamoDB fica com o histórico, que cresce rápido e precisa de leitura veloz. Faz as contas: 10 notificações por dia pra 1 milhão de usuários, são 300 milhões de registros por mês.
E o Redis faz cache. Quando o usuário abre a aba "Minhas Notificações", bate primeiro no Redis (últimas 50 notificações). Só vai no banco pesado se precisar de mais.
Conclusão
Sistema de notificações é uma das partes mais importantes de um sistema robusto. Ele é a ponte de comunicação com o seu usuário, com o seu cliente. Se essa comunicação falha, se o usuário não recebe o que precisa, ele perde a confiança no seu produto.
A arquitetura que montamos aqui tem:
- Fila pra desacoplar e garantir resiliência
- NotificationService com lógica de template, preferências, idempotência e rate limiting
- Providers com fallback e retry exponencial
- Dead Letter Queue pra monitorar falhas
- WebSocket distribuído com Redis Pub/Sub pra tempo real
- Storage separado por tipo de dado e velocidade de acesso
Se você nunca trabalhou num sistema grande o suficiente pra ter tudo isso separado, saber montar essa arquitetura é extremamente valioso pra entrevistas de system design. Porque quando você entra numa empresa grande, esse tipo de pergunta aparece.