Em 25 de janeiro de 2009, o The New York Times publicou a notícia da nomeação de Hillary Clinton como Secretária de Estado. Em segundos, milhões de assinantes do aplicativo do NYT receberam uma push notification — e o backend do app começou a despencar. O motivo não foi a notícia em si: foi que o sistema tinha sido desenhado para enviar uma notificação por vez, dentro de uma transação síncrona. Quando 5 milhões de usuários precisavam receber a mesma push simultaneamente, o sistema gastou 4 horas processando o backlog, com mais 30 minutos de cada vez que algo dava errado em algum dispositivo. A equipe reescreveu inteiramente o sistema nos meses seguintes, e essa reescrita virou o padrão arquitetural que praticamente toda empresa adota hoje: filas por canal, workers idempotentes, e separação total entre o evento que gera a notificação e a entrega ao destinatário.
O que torna notificações um problema interessante de system design não é a entrega individual — APNs e FCM resolvem isso. O problema é a combinação de escala (eventos em rajada quando uma notícia quebra), heterogeneidade (cada canal tem provedor, limites, custo e SLA diferentes), e psicologia (usuários abandonam apps que mandam notificações demais ou irrelevantes). Um sistema bem desenhado precisa entregar em até alguns segundos quando o evento é urgente, batched quando o usuário tem preferência por digest, parar imediatamente se o usuário desinstalou, e nunca duplicar — tudo isso conversando com 4-5 APIs externas que cada uma tem suas idiossincrasias.
Requisitos e estimation
Requisitos funcionais:
- Suportar 4 canais: push (APNs/FCM), email, SMS, in-app
- Templates por canal com personalização (nome, dados do evento)
- Preferências por usuário (canais habilitados, horário de silêncio, frequência máxima)
- Notificações imediatas (transacionais: confirmação de compra) e agendadas (marketing: lembretes)
- Deduplicação: o mesmo evento não gera duas notificações idênticas
- Rastreamento de entrega: sent, delivered, opened, clicked, bounced
Requisitos não-funcionais:
- Throughput: 10M notificações/hora sustentado (2778/s); pico 5× = ~15k/s
- Latência: notificações transacionais entregues em <5s (do evento ao provedor)
- Disponibilidade: 99.9% — uma falha não pode parar todos os canais
- Reliability: zero perda silenciosa; entregas falhadas vão para DLQ visível
# Estimation — sistema de notificações multi-canal
# Usuários ativos: 100M
# Notificações/dia por usuário ativo: 2.4 (média) = 240M notif/dia = ~2800/s
# Pico (notícia quebrando, promoção): 10× = 28k/s por alguns minutos
# Distribuição por canal (depende muito do produto):
# - Push: 70% (gratuito, alto engajamento)
# - In-app: 20% (processado quando o usuário abre o app)
# - Email: 8% (mais formal, transacional)
# - SMS: 2% (apenas crítico — caro, intrusivo)
# Custos típicos por mensagem (referência 2026):
# - APNs / FCM: $0 (Apple/Google não cobram)
# - Email (SendGrid): $0.0002/email = $200/M
# - SMS (Twilio US): $0.0079/SMS = $7900/M (40x mais caro que email)
# - SMS internacional: $0.05-0.30/SMS — pode custar $300k/M
# → Implicação: SMS só para casos onde justifica (2FA, alertas críticos)
# Storage:
# - Tabela de notificações: 240M/dia × 90 dias retenção × 500 bytes = ~10TB
# - Status updates (sent/delivered/opened): 4× linhas = 40TB
# - Particionamento por user_id (queries comuns: "notif do usuário X")
# - TTL: status detalhado por 30 dias; agregados por 1 ano
# Capacity por componente:
# - Notification Service (stateless): 28k req/s pico
# Cada req gera 1-N tasks na fila (em média 1.2 — broadcast amplifica)
# - Workers por canal:
# Push: 30k tasks/s pico ÷ 500 task/s por worker = 60 workers
# Email: 3k tasks/s pico ÷ 200 task/s por worker = 15 workers
# SMS: 600 tasks/s pico ÷ 100 task/s por worker = 6 workers
# - Provedor externo é gargalo: APNs ~9k req/s por connection × N conexões
# Rate limits dos provedores (importantes para design):
# - APNs HTTP/2: ~9000 notif/s por conexão; mantenha pool de 10-50 conexões
# - FCM: 1000/s por projeto sem ajuste; pode-se solicitar aumento
# - SendGrid: 600 req/s tier padrão; 10k+/s tier enterprise
# - Twilio: 1 SMS/s por número de origem (pode usar pools de números)
# → para 100 SMS/s em pico, precisa de 100+ source numbers
Arquitetura: filas por canal
A decisão arquitetural mais importante: filas separadas por canal. O motivo não é teórico — é isolamento de falhas. Se o SendGrid está com problemas e os emails empilham, a fila de email cresce mas push e SMS continuam fluindo normalmente. Com uma fila única, um provedor lento afeta todos os canais.
# Fluxo de uma notificação:
# 1. Evento chega ao Notification Service (HTTP, Kafka, ou trigger interno)
# Eventos comuns:
# - user.purchase_completed → notif de confirmação
# - user.password_reset_requested → notif com link
# - content.new_episode_available → notif para subscribers
# - alert.fraud_detected → notif crítica
# 2. Notification Service:
def handle_event(event):
# 2a. Determinar destinatários (1 ou N usuários)
recipients = resolve_recipients(event)
# ex: para new_episode → todos os subscribers da série; podem ser milhões
# 2b. Para cada recipient, decidir canais com base em preferências
for user_id in recipients:
prefs = preferences_cache.get(user_id)
if not prefs.allows(event.type):
continue # usuário desativou esse tipo de notificação
# 2c. Aplicar dedup (mesmo evento não vira duas notif)
dedup_key = f"notif:dedup:{user_id}:{event.id}"
if not redis.set(dedup_key, "1", nx=True, ex=86400):
continue # já enfileirado nas últimas 24h
# 2d. Aplicar rate limiting (não mais de N notif/hora por usuário)
if not rate_limiter.allow(user_id, event.type):
stats.incr("rate_limited", {"user": user_id, "type": event.type})
continue
# 2e. Enfileirar uma task POR CANAL ativo do usuário
channels = prefs.channels_for(event.type) # ex: ["push", "email"]
for channel in channels:
task = NotificationTask(
user_id=user_id,
event_id=event.id,
channel=channel,
template_id=event.template_id,
context=event.context,
priority=event.priority,
)
queue[channel].enqueue(task)
# 3. Workers por canal:
# - PushWorker: consume da queue "push", chama APNs ou FCM
# - EmailWorker: consume da queue "email", chama SendGrid/SES
# - SmsWorker: consume da queue "sms", chama Twilio
# - InAppWorker: consume da queue "in_app", grava no DB e notifica via WebSocket
# Priorização: filas separadas por prioridade dentro de cada canal
# - push.high (transacional, <1s)
# - push.normal (eventos do produto, <30s)
# - push.bulk (marketing, podem aguardar minutos)
# Workers de high priority sempre têm parallelism garantido
Push notifications: APNs e FCM
iOS (Apple Push Notification service) e Android (Firebase Cloud Messaging) têm modelos parecidos mas diferenças importantes em payload, autenticação e features. Um sistema multiplataforma abstrai essas diferenças, mas o design precisa entender ambos.
# APNs (Apple Push Notification service):
# Autenticação: JWT signed com Apple Push Key (.p8 file)
# Token JWT é regenerado a cada ~1 hora (Apple recomenda)
# Conexão: HTTP/2 persistente para api.push.apple.com
# Uma conexão suporta ~9000 notif/s (Apple recomenda 1 conexão por core)
# Cada notif é um HTTP POST individual sobre a conexão HTTP/2
# Payload máximo: 4KB (incluindo headers)
# Estrutura:
{
"aps": {
"alert": {
"title": "Nova mensagem",
"body": "Você tem uma mensagem de João"
},
"badge": 3, # contador no ícone do app
"sound": "default",
"category": "MESSAGE", # define ações disponíveis na notificação
"thread-id": "chat-123" # agrupa notif relacionadas
},
# Custom data (lido pelo app quando aberto):
"conversation_id": "abc123",
"message_id": 4567
}
# Headers importantes:
# apns-priority: 10 (immediate) ou 5 (low power, dispositivo decide quando)
# apns-push-type: alert, background, voip, location, complication, fileprovider
# apns-collapse-id: string para coalescing (notif mais nova substitui anterior)
# apns-expiration: timestamp; 0 = entregar apenas se possível imediatamente
# apns-topic: bundle ID do app (obrigatório)
# Device token:
# - 64 hex chars (32 bytes); muda quando o usuário reinstala o app
# - Sistema precisa atualizar registro quando o app envia novo token
# - Apple retorna 410 Gone para tokens inválidos → remover do DB
# FCM (Firebase Cloud Messaging):
# Autenticação: Service Account JSON → OAuth2 token (renovado a cada hora)
# Endpoint: fcm.googleapis.com/v1/projects/{PROJECT_ID}/messages:send
# Payload máximo: 4KB para notification message; 4KB para data message
# Dois tipos de mensagem:
# - Notification message: sistema operacional renderiza UI sem acordar o app
# - Data message: app processa em código quando recebida (mais flexível)
{
"message": {
"token": "device_token_aqui",
"notification": {
"title": "Nova mensagem",
"body": "Você tem uma mensagem de João"
},
"data": {
"conversation_id": "abc123",
"click_action": "OPEN_CHAT"
},
"android": {
"priority": "high", # high = entrega imediata; normal = batched (economiza bateria)
"collapse_key": "chat-123"
},
"apns": { # FCM também envia para iOS — config APNs aqui
"headers": {"apns-priority": "10"}
}
}
}
# Token de FCM:
# - String longa (~150 chars); registration token gerado pelo SDK
# - Pode mudar: app reinstalado, app data limpo, FCM rotaciona periodicamente
# - FCM retorna UNREGISTERED para tokens inválidos → remover do DB
# Topic-based messaging (FCM):
# - Em vez de enviar para N tokens, enviar para um tópico
# - Dispositivos se subscrevem (ex: subscribeToTopic("sports-news"))
# - FCM faz fan-out internamente; o backend envia 1 request → milhões de devices
# - Útil para broadcast (notícias, promoções globais)
# - Não use para conteúdo personalizado (não há dedup, não há controle granular)
# APNs / FCM unificados (em código):
class PushSender:
def send(self, device_token, platform, payload):
if platform == "ios":
return self._send_apns(device_token, payload)
elif platform == "android":
return self._send_fcm(device_token, payload)
def _send_apns(self, token, payload):
try:
response = self.apns_client.send(token, payload)
if response.status_code == 200:
return Result.ok()
elif response.status_code == 410: # Gone
# Token inválido — remover do DB para não tentar de novo
self.token_store.invalidate(token)
return Result.permanent_failure()
elif response.status_code in (429, 500, 503):
# Rate limit ou erro transitório — retry
return Result.transient_failure(retry_after=response.headers.get("Retry-After"))
else:
return Result.permanent_failure()
except ConnectionError:
return Result.transient_failure()
Email: bounces e complaints como cidadãos primeira classe
Email é o canal mais "esquecido" no design e o que mais machuca a reputação de IP quando mal feito. Provedores como SendGrid e Amazon SES enviam, mas o trabalho duro é processar os webhooks de bounce e complaint corretamente — bounces não tratados levam a blacklist em pouco tempo.
# Tipos de bounce:
# Hard bounce (permanente):
# - Endereço não existe (550 5.1.1 User unknown)
# - Domínio inexistente
# - Conta desativada
# → Ação: marcar email como inválido, NUNCA mais enviar
# → ESPs (SendGrid, SES) suspendem sua conta se você continuar enviando para hard bounces
# Soft bounce (transitório):
# - Caixa cheia (552)
# - Servidor temporariamente indisponível (4xx)
# - Mensagem grande demais
# → Ação: retry com backoff; após N falhas (ex: 5 em 7 dias), considerar hard
# Complaint (FBL — Feedback Loop):
# - Usuário marcou como spam no provedor (Gmail, Outlook)
# - Provedor envia FBL via webhook
# → Ação: imediatamente parar de enviar para esse usuário, qualquer tipo
# → Complaint rate >0.3% nos principais ISPs → reputação cai → emails vão para spam
# Webhook handler (SendGrid format):
@app.post("/webhooks/sendgrid")
async def handle_sendgrid_webhook(events: list):
for event in events:
email = event["email"]
event_type = event["event"]
if event_type == "bounce":
if event["type"] == "hard":
await email_store.mark_invalid(email, reason="hard_bounce")
logger.warn(f"Hard bounce: {email}")
else:
await email_store.record_soft_bounce(email)
# Após 5 soft bounces em 7 dias, escalar para hard
if await email_store.soft_bounce_count(email, days=7) >= 5:
await email_store.mark_invalid(email, reason="repeated_soft_bounce")
elif event_type == "spamreport": # complaint
await user_prefs.disable_all_email(email, reason="user_complaint")
# Críticos: nunca mais enviar marketing; transacional só com flag explícita
stats.incr("email_complaint")
elif event_type == "delivered":
await notification_store.update_status(event["sg_message_id"], "delivered")
elif event_type == "open":
await notification_store.update_status(event["sg_message_id"], "opened")
elif event_type == "click":
await notification_store.record_click(event["sg_message_id"], event["url"])
# Reputação de IP/domínio:
# - ISPs (Gmail, Outlook) avaliam suas IPs e domínios para decidir spam vs inbox
# - Métricas que afetam: bounce rate, complaint rate, engagement (open/click)
# - Boas práticas críticas:
# * SPF, DKIM, DMARC configurados (autenticação do remetente)
# * IP warm-up: começar com pouco volume e crescer gradualmente (não 0 → 1M no dia 1)
# * Segregação por tipo: IP separado para transacional (alta deliverability)
# vs marketing (mais variável); reputações não se contaminam
# * Lista limpa: remover hard bounces, manter list hygiene
# * Conteúdo bem feito: HTML balanceado, evitar palavras-gatilho de spam
# Templates:
# - Versionar (template_v1, template_v2 — quando mudar, manter ambos por X dias)
# - Renderizar server-side com biblioteca robusta (Jinja2, MJML para HTML responsivo)
# - Suportar plain text + HTML (multipart MIME) — alguns clientes não renderizam HTML
# - Pre-rendering de variáveis comuns (nome) vs link tracking dinâmico (rastrear cliques)
SMS: cuidado com custo e regulação
# SMS é único entre os canais por três razões:
# 1. CUSTO: 10-1000x mais caro que email/push
# US: $0.0079/SMS (Twilio); Brasil: ~$0.05/SMS; alguns países: $0.30+
# Implicação: 10k SMS internacionais/dia = $300+ vs $2 em email
# 2. REGULAÇÃO:
# - TCPA (US): exige opt-in explícito; multa de $500-1500 POR MENSAGEM por violação
# - GDPR (EU): mesma exigência de opt-in com prova de consentimento
# - 10DLC (US): números short code precisam de registro com carriers (semanas para aprovação)
# - País-específico: Brasil tem regras de horário; Índia precisa de DLT registration
# 3. THROUGHPUT:
# - Twilio: 1 SMS/s POR SOURCE NUMBER (default)
# - Para 100/s sustentado: precisa de 100 números (cada um custa ~$1/mês)
# - Toll-free / short code: throughput mais alto mas mais caro
# Design para SMS:
class SmsSender:
def __init__(self):
# Pool de source numbers para paralelizar throughput
self.source_pool = SourceNumberPool(numbers=load_source_numbers())
async def send(self, recipient, message):
# 1. Validar formato E.164 (+55119...)
if not is_valid_e164(recipient):
return Result.permanent_failure("invalid_format")
# 2. Verificar opt-in (CRÍTICO para compliance)
if not await consent_store.has_opted_in(recipient):
logger.error(f"SMS attempted without opt-in: {recipient}")
return Result.permanent_failure("no_consent")
# 3. Verificar STOP keyword (usuário enviou "STOP" → não enviar mais)
if await consent_store.has_opted_out(recipient):
return Result.permanent_failure("opted_out")
# 4. Escolher source number com menor carga
source = await self.source_pool.acquire(recipient_country=recipient[:3])
# 5. Enviar via Twilio
try:
response = twilio.messages.create(
from_=source.number,
to=recipient,
body=message,
status_callback="https://api.example.com/webhooks/twilio" # delivery status
)
await self.source_pool.record_send(source)
return Result.ok(provider_id=response.sid)
except TwilioRateLimitError:
return Result.transient_failure(retry_after=1)
except TwilioException as e:
return Result.permanent_failure(str(e))
# Webhook de status do Twilio:
@app.post("/webhooks/twilio")
async def handle_twilio_status(payload):
sid = payload["MessageSid"]
status = payload["MessageStatus"]
# Status: queued → sending → sent → delivered (ou failed, undelivered)
if status == "delivered":
await notification_store.update_status(sid, "delivered")
elif status in ("failed", "undelivered"):
error_code = payload.get("ErrorCode")
# 30003 = number unreachable; 30005 = unknown destination; 21610 = STOP
if error_code == "21610": # User sent STOP
await consent_store.opt_out(payload["To"])
await notification_store.update_status(sid, "failed", error=error_code)
# STOP/HELP keywords (compliance — carriers exigem):
# - Quando usuário responde STOP: imediatamente opt-out
# - Quando usuário responde HELP: responder com info de contato e como sair
# - Implementação via webhook de mensagens recebidas (inbound)
Rate limiting por usuário
Notification fatigue é o caminho mais rápido para perder um usuário. O usuário recebe 30 notificações de um app num dia, desativa todas, depois desinstala. Rate limiting por usuário não é só etiqueta — é proteção do produto.
# Estratégias de rate limiting por usuário:
# Estratégia 1: Sliding window com Redis ZSET
# Para cada usuário, manter um ZSET dos timestamps das últimas notificações
# Antes de enviar: contar quantas notif nos últimos N segundos
async def check_rate_limit(user_id, notif_type, max_per_hour=10):
key = f"notif:rate:{user_id}:{notif_type}"
now = time.time()
one_hour_ago = now - 3600
pipe = redis.pipeline()
# Remover entries velhas
pipe.zremrangebyscore(key, 0, one_hour_ago)
# Contar atuais
pipe.zcard(key)
# Adicionar timestamp atual (será removido se não passou no limite)
pipe.zadd(key, {f"{now}:{uuid4()}": now})
pipe.expire(key, 3600)
_, count, _, _ = await pipe.execute()
if count >= max_per_hour:
# Reverter o add
await redis.zrem(key, ...)
return False
return True
# Estratégia 2: Quotas por categoria + override para crítico
# Não todas as notif são iguais — algumas devem sempre passar
LIMITS = {
"marketing": {"per_hour": 1, "per_day": 3},
"social": {"per_hour": 5, "per_day": 15}, # likes, comments
"transactional": {"per_hour": 100}, # purchase, password reset
"critical": {}, # nunca rate-limita: fraud, security, emergency
}
# Estratégia 3: Quiet hours (horário de silêncio)
# Usuário definiu: não receber push entre 22h e 8h no fuso dele
# Notificações enfileiradas durante quiet hours são entregues ao final
# Exceção: notif "critical" passam mesmo em quiet hours
async def respect_quiet_hours(user_id, notif):
prefs = await user_prefs.get(user_id)
user_tz = prefs.timezone # ex: "America/Sao_Paulo"
local_now = datetime.now(pytz.timezone(user_tz))
if prefs.quiet_hours_active(local_now) and notif.priority != "critical":
# Reschedule para o fim do quiet hours
next_send = prefs.quiet_hours_end(local_now)
await scheduler.enqueue_at(notif, next_send)
return "deferred"
return "send_now"
# Estratégia 4: Digest mode
# Em vez de N notif separadas, agrupar em uma "você tem 5 notificações novas"
# Útil para social: 10 likes viram 1 notif
# Lógica:
# - Quando uma notif chega, verificar se há outras da mesma categoria pendentes
# - Se sim, mesclar (atualizar a notif existente) em vez de criar nova
# - Para push: usar apns-collapse-id ou FCM collapse_key
# Estratégia 5: Engagement-based throttling
# Se usuário não abre as notif há 30 dias, reduzir frequência automaticamente
# Métricas: open_rate, click_rate, dismiss_rate
# Usuário com 0 opens em 90 dias → pausar notif promocionais
Deduplicação
# O mesmo evento pode entrar no Notification Service múltiplas vezes:
# - Retry de upstream (event source faz retry porque não recebeu ACK)
# - Múltiplos producers do mesmo evento (bug, ou ambiguidade de fonte)
# - Replay de logs (debug, reindex)
# Sem dedup, o usuário recebe a mesma notif 3 vezes. Inaceitável.
# Dedup por (user_id, event_id):
async def deduplicate(user_id, event_id, ttl_seconds=86400):
dedup_key = f"notif:dedup:{user_id}:{event_id}"
# SET NX: atomic; sucesso = primeiro a chegar
was_set = await redis.set(dedup_key, "1", nx=True, ex=ttl_seconds)
return was_set # True = primeiro, processar; False = duplicate, ignorar
# Por que TTL de 24h? Janela razoável de retry; eventos > 24h provavelmente são intencionais
# Dedup de conteúdo (mais raro, mas útil):
# Mesmo evento_id mas dois producers diferentes; OR
# Eventos distintos com mesmo conteúdo (raro mas acontece)
# Hash do conteúdo como chave:
content_hash = hashlib.sha256(f"{user_id}:{title}:{body}".encode()).hexdigest()[:16]
dedup_key = f"notif:contentdedup:{content_hash}"
# Cuidado: pode mascarar duplicações legítimas (mensagem repetida intencionalmente)
# Dedup em escala: muitas chaves Redis
# 10M notif/hora × 24h = 240M chaves dedup ativas
# Redis com 240M chaves: ~30GB de memória
# Mitigação: shard Redis ou usar BLOOM filter (false positive aceitável, false negative não)
# Bloom filter para dedup approximado:
# - Falso positivo: pode pular uma notif legítima (raro, ex: 0.1%)
# - Falso negativo: NUNCA — se diz que é novo, é novo
# - Memory efficient: 240M items × 10 bits = 300MB (100x menos que keys Redis)
# RedisBloom (módulo):
# BF.RESERVE notif_dedup 0.001 240000000 # 0.1% false positive, 240M capacity
# BF.ADD notif_dedup "user:123:event:456"
# BF.EXISTS notif_dedup "user:123:event:456"
Retry e dead letter queue
# Falhas externas são inevitáveis: APNs intermitente, SendGrid 503, network blip
# Retry mal feito amplifica problemas (thundering herd contra provider já estressado)
# Backoff exponencial com jitter:
RETRY_DELAYS = [1, 5, 30, 120, 600] # segundos: 1s, 5s, 30s, 2min, 10min
MAX_RETRIES = 5
async def deliver_with_retry(task):
for attempt in range(MAX_RETRIES + 1):
result = await sender.send(task)
if result.is_success:
return result
if result.is_permanent_failure:
# Token inválido, email hard bounce → não retry
await dlq.enqueue(task, reason=result.error)
return result
if result.is_transient_failure and attempt < MAX_RETRIES:
# Esperar com backoff
base_delay = RETRY_DELAYS[attempt]
# Jitter: ±50% aleatório, evita thundering herd na recuperação
delay = base_delay * (0.5 + random.random())
await asyncio.sleep(delay)
continue
# Esgotou retries — DLQ
await dlq.enqueue(task, reason="max_retries_exceeded")
return result
# Dead Letter Queue:
# Fila separada que recebe tasks que esgotaram retries
# CRUCIAL: ter monitoramento e dashboard para inspecionar DLQ
# Razões comuns para tasks irem para DLQ:
# - Provider down há muito tempo
# - Bug no template (validação falha sistemáticamente)
# - Configuração errada (credencial expirada)
# - Edge case não tratado
# Política de DLQ:
# - Retenção: 7 dias (depois deleta — auditoria já capturou)
# - Alerting: se DLQ rate > X% do throughput → page on-call
# - Manual reprocessing: ferramenta para reenviar após fix
# - Categorização: por provider, por error type → identificar padrões
# Circuit breaker para provedores:
# Se SendGrid retorna erro em 50% das requests por 1 minuto → abrir circuit
# Por 30s, NÃO chamar SendGrid; tasks vão direto para retry queue
# Depois de 30s, half-open: tentar uma request; se OK, fechar; se falhar, abrir mais 30s
# Evita martelar provider que claramente está down
class CircuitBreaker:
def __init__(self, failure_threshold=0.5, window_seconds=60, open_duration=30):
self.state = "closed" # closed, open, half-open
self.failures = deque()
self.opened_at = None
async def call(self, func, *args):
if self.state == "open":
if time.time() - self.opened_at > self.open_duration:
self.state = "half-open"
else:
raise CircuitOpenError()
try:
result = await func(*args)
if self.state == "half-open":
self.state = "closed"
self.failures.clear()
return result
except Exception as e:
self._record_failure()
if self._failure_rate() > self.failure_threshold:
self.state = "open"
self.opened_at = time.time()
raise
Arquitetura completa
Event Sources
├── Application services (purchase, signup, ...)
├── Kafka topic (events.notifications)
├── Scheduled jobs (digests, reminders)
└── Admin tools (broadcasts manuais)
│
▼
┌─────────────────────────────┐
│ Notification Service │
│ - Resolve recipients │
│ - Check user prefs │
│ - Dedup (Redis SET NX) │
│ - Rate limit │
│ - Quiet hours │
│ - Render template per channel│
└──────────────┬──────────────┘
│ fan-out por canal
┌──────────┼──────────┬──────────┐
▼ ▼ ▼ ▼
queue.push queue.email queue.sms queue.inapp
(SQS/Kafka)
│ │ │ │
┌─┴─┐ ┌──┴──┐ ┌──┴──┐ ┌──┴──┐
│N×W│ │N×W │ │N×W │ │N×W │ workers
└─┬─┘ └──┬──┘ └──┬──┘ └──┬──┘
│ │ │ │
▼ ▼ ▼ ▼
APNs/ SendGrid/ Twilio WebSocket
FCM SES (pool of + DB
numbers)
│ │ │ │
└──────────┴────┬─────┴──────────┘
│ status webhooks
▼
┌──────────────────────┐
│ Status Tracking │
│ - sent / delivered │
│ - opened / clicked │
│ - bounced / failed │
└──────────┬───────────┘
▼
┌──────────────┐
│ Notif DB │ (per-user notification ledger)
│ Analytics │ (aggregations: open rate, etc)
└──────────────┘
Falhas:
queue.push (retry esgotou) ──► DLQ.push
queue.email (retry esgotou) ─► DLQ.email
etc
Observabilidade:
- Throughput por canal (notif/s)
- Success rate por canal (% delivered / sent)
- Latência: event ingestion → delivered (P50, P99)
- DLQ size e taxa de crescimento
- Provider response time (P99 por endpoint)
- Bounce rate, complaint rate (email)
- Engagement: open rate, click rate por categoria/template
Caches:
- User preferences (Redis): TTL 5min — atualizado em mudança
- Device tokens por user_id (DB com cache curto)
- Templates (in-memory por worker, refresh periódico)
Há uma armadilha clássica em métricas de notification systems: otimizar para "100% das notificações foram entregues" pode ser exatamente o oposto do que o produto precisa. O Snapchat publicou em 2019 que reduziu o volume médio de push notifications em 40% e viu retention subir 8% no mesmo trimestre — usuários abandonavam menos quando o app era menos barulhento. A métrica certa não é throughput de entregas; é taxa de engagement (open rate, click-through). Se o open rate cai abaixo de 5%, o problema não é o sistema de delivery — é o produto enviando demais ou sem relevância. Engenheiros maduros tratam o sistema de notificações como uma plataforma com freios, não como um amplificador. Rate limits agressivos, quiet hours por default, opt-out simples — todos são features de qualidade, não restrições.
Decisões de engenharia
A tentação inicial é usar uma fila única e despachar tasks com um campo "channel". Funciona em pequena escala, mas quebra em produção: quando o SendGrid está com problemas e emails empilham, o backlog cresce e workers ficam ocupados com tasks que não conseguem entregar — atrasando push e SMS na mesma fila. Filas por canal isolam falhas: cada canal tem seu próprio backlog, throughput, retry policy e DLQ. Os workers de push continuam fluindo mesmo quando o email está parado.
Regra prática: sempre filas por canal. Adicionalmente, filas por prioridade dentro de cada canal (push.high, push.normal, push.bulk) para garantir que notif transacionais não fiquem atrás de promoções. Custo adicional é mínimo (mais nomes de fila); ganho operacional é enorme. Para isolamento ainda maior: filas por tipo de notificação (push.fraud_alert separado de push.social), úteis quando o blast radius de um bug precisa ser contido.
Server-side rendering (Jinja2, MJML): backend recebe template_id + context, renderiza o conteúdo final (title, body, HTML) e envia para o provider. Vantagens: consistência (todos os destinatários veem o mesmo), localização e personalização centralizadas, fácil de A/B testar (trocar template_id), versionamento simples. Desvantagens: templates não podem mudar sem deploy do backend; payload de push fica maior se o conteúdo é longo. Client-side rendering: backend envia template_id e dados estruturados; o app renderiza usando templates embedded. Vantagens: payload menor; templates podem mudar com release do app (não exige backend deploy para variações de copy). Desvantagens: divergência entre versões do app; impossível mudar copy de versões já lançadas.
Regra prática: server-rendered para o conteúdo da notificação (title, body) — controle total sobre o que chega ao usuário. Data payload (action, deep link) é estruturado e processado pelo app. Para A/B testing pesado de copy, server-rendered é praticamente obrigatório. MJML para email HTML responsivo é o padrão de mercado; libsass + Jinja2 para CSS condicional. Cuidado com templates dinâmicos demais — bugs em templates causam incidents pontuais para subsets de usuários, difíceis de detectar.
Abstrair providers (camada que normaliza APNs, FCM, SendGrid, Twilio em uma interface) parece a escolha "limpa", mas tem custos reais: cada provider tem features únicas (apns-collapse-id, FCM topics, SendGrid dynamic templates) que ficam difíceis de expor através de uma abstração. O lowest common denominator pinga atrás das features que diferenciam um provider. Por outro lado, lock-in a um provider torna difícil migrar quando muda preço, qualidade ou compliance.
Regra prática: abstrair o que é commodity (enviar uma push, enviar um email), expor features avançadas como opcionais por provider. Não tentar abstrair tudo. Mantenha a possibilidade de migrar trocando o adapter — mas não pague o custo de implementar TODA feature em TODOS os adapters. Para empresas grandes: usar um SaaS de notification orchestration (OneSignal, Customer.io) que abstrai os providers é frequentemente melhor que construir tudo internamente — operam em escala e têm time dedicado a se manter atualizados com APIs dos providers.
Quando o usuário completa uma compra, o backend de checkout precisa "notificar". A tentação: chamar o Notification Service síncrono dentro da transação de checkout. Problema: o checkout fica acoplado à disponibilidade do Notification Service, e a latência da notificação degrada a UX da compra. Solução padrão: o checkout emite um evento (Kafka, SNS, ou tabela outbox) e retorna ao usuário; o Notification Service consome o evento de forma assíncrona.
Regra prática: notificações são SEMPRE assíncronas em relação ao evento de negócio. Use o padrão outbox para garantir que o evento é emitido se e somente se a transação foi commitada (não pode ter notif de compra sem a compra ter sido registrada). O Notification Service consome do outbox/Kafka, com at-least-once delivery — daí a importância da dedup. A única exceção: notif que SÃO o produto (ex: "enviar confirmação de senha por email" num fluxo de signup), onde o usuário literalmente espera a notificação chegar — mas mesmo nesse caso, o ideal é polling do status no client em vez de bloquear o fluxo de signup.
Perguntas de entrevista
Uma notícia importante quebra e gera 10 milhões de notificações que precisam ser entregues em 1 minuto. Como o sistema lida sem quebrar o APNs/FCM nem o próprio backend?
Esse é o cenário clássico de fan-out massivo que diferencia um sistema de notificações maduro de um amador. A resposta tem várias camadas:
1. Fan-out staged: não tentar enfileirar 10M tasks em uma operação síncrona. O service que dispara o broadcast escreve uma "broadcast intent" no banco — apenas um registro com (broadcast_id, audience_query). Um job em background materializa a audiência (query no DB pelos N milhões de user_ids) e enfileira em lotes (ex: 10000 tasks/batch). Isso evita lock-up no producer.
2. Workers autoscaling: a fila de push pode crescer rapidamente para 10M items. Os workers precisam escalar (Kubernetes HPA com métrica de queue depth, ou Lambda concurrent executions). Importante: o autoscaling deve ter ramp-up agressivo mas com teto — não escalar para 10000 workers e overwhelmar APNs.
3. Conexões HTTP/2 ao APNs: manter um pool de conexões (50-200) abertas. Cada conexão suporta ~9000 notif/s. 100 conexões × 9000 = 900k notif/s — capacidade mais que suficiente para 10M em 1 minuto.
4. Rate limit ao FCM/SendGrid/etc: respeitar os limites do provider, com circuit breaker. Se FCM começar a 429-tar, recuar para 80% do limite e reabrir lentamente.
5. Priorização: manter filas separadas para garantir que notif transacionais (compras, password resets) não sofram com o broadcast. O broadcast vai para push.bulk, transacional para push.high — workers diferentes.
6. Dedup ainda relevante: mesmo num broadcast, o evento pode ser emitido duas vezes (bug, retry). Dedup por (user_id, broadcast_id) impede dupla entrega.
7. Backpressure: se a fila de push está em 10M e o sistema não consegue drenar em tempo razoável, parar de aceitar broadcasts adicionais (return 503 ao caller). Não acumular indefinidamente — melhor falhar rápido que entregar 12h depois.
O ponto que distingue: o sistema bem desenhado nunca "explode" — degrada de forma controlada. Notif individuais podem ficar 2-3 minutos atrasadas durante o pico, mas tudo é eventualmente entregue, tudo é visível em observability, e os outros canais não são afetados.
O usuário reclama que recebeu a mesma notificação 3 vezes. Como você diagnostica e impede isso?
Esse é um dos bugs mais comuns e mais embaraçosos em notification systems. A causa pode estar em várias camadas — e o diagnóstico precisa percorrer todas.
Hipótese 1 — evento duplicado na origem: o producer (ex: checkout service) emitiu o mesmo evento 3 vezes. Pode ser retry de Kafka producer com idempotency desabilitada, ou bug no código que dispara o evento. Diagnóstico: olhar o log do producer e contar emissões com mesmo event_id. Fix: idempotency no producer; uso correto de transactional outbox.
Hipótese 2 — consumidor processou múltiplas vezes: o Notification Service consumiu o mesmo evento de Kafka 3 vezes sem commitar offset. Diagnóstico: logs do consumer mostrando o mesmo event_id processado em múltiplos workers ou no mesmo worker múltiplas vezes. Fix: dedup por event_id no Notification Service (Redis SET NX), antes de qualquer fan-out.
Hipótese 3 — falta de dedup por canal: o evento foi processado uma vez, mas o sistema gerou 3 tasks de push porque o usuário tem 3 devices registrados E a lógica enviou para todos. Não é "duplicação" técnica, mas a UX é a mesma. Fix: deduplicar no nível de notificação visível, não de delivery — se o usuário tem 3 devices, ainda é uma única notificação lógica (cada device é uma delivery dela).
Hipótese 4 — broadcast + notif individual sobrepostos: o usuário caiu numa lista de broadcast (ex: "promoção para todos") e também tinha uma notif individual gerada pelo mesmo evento. Fix: ao gerar broadcasts, dedup contra notif individuais recentes para o mesmo usuário/categoria.
Hipótese 5 — provider duplicou: raro, mas APNs/FCM ocasionalmente entregam duplicatas em caso de network glitch. O cliente recebe a mesma push duas vezes do provider. Fix: dedup no cliente — ao receber push, verificar se o notif_id já foi exibido nas últimas X horas.
A estratégia geral: dedup em camadas — no producer (idempotency), no consumer (event_id), e idealmente no cliente (notif_id). Defense in depth, porque cada camada pode falhar.
Como você implementa notificações agendadas (ex: "lembrete amanhã às 9h") em escala?
Notificações agendadas têm dois requisitos não-triviais: armazenamento eficiente de potencialmente bilhões de "delayed tasks", e disparo preciso no momento certo sem polling caro.
Approach 1 — fila com delay nativo: SQS (Amazon) e RabbitMQ suportam mensagens com delay até alguns minutos/horas (SQS: 15min, RabbitMQ com plugin: até 1 dia). Adequado para curto prazo, mas não para "daqui a 30 dias". Não escala para bilhões de tasks pendentes.
Approach 2 — Redis sorted set como time wheel: armazenar tasks num ZSET com score = unix_timestamp_de_envio. Um scheduler worker consulta a cada N segundos: ZRANGEBYSCORE schedule 0 NOW — retorna tasks já vencidas. Move para a fila normal e remove do ZSET. Limitação: Redis ZSET com 100M entries é OK, mas 1B+ começa a doer; e Redis é in-memory, então cresce caro.
Approach 3 — banco relacional com índice e poll: tabela scheduled_notifications com (id, scheduled_at INDEX, payload). Scheduler worker: SELECT * FROM scheduled_notifications WHERE scheduled_at <= NOW() AND status = 'pending' LIMIT 1000. Após enfileirar, marcar como 'processed'. Escala para bilhões de rows; particionamento por scheduled_at_date para evitar índice gigante. Pode ter latência de até N segundos (intervalo do poll).
Approach 4 — time-bucket sharding: uma tabela por dia (schedule_2026_05_18). Cada dia tem ~10M tasks (gerenciável). Workers iteram os buckets de "hoje" e "amanhã" (para o caso de quiet hours/timezone). Tabelas antigas são dropadas após processamento — não precisa de DELETE em escala.
Approach 5 — Cassandra com TTL: escrever na tabela com primary key (bucket, scheduled_at, id) onde bucket = hora ou minuto de envio. Workers escaneiam buckets atuais. TTL nativo do Cassandra remove rows antigas automaticamente. Escala bem para escrita; query é eficiente (partition key conhecido).
Na prática, sistemas grandes combinam: SQS para delay <15min (transacional como "reminder em 10min"), DB ou Cassandra para schedule longo. Critical: idempotency — o mesmo schedule_id não pode disparar duas vezes mesmo se o scheduler worker reiniciar no meio.
Como você faz A/B testing de copy/template de notificação sem complicar demais a arquitetura?
A/B testing em notificações é mais complexo que em UI porque a "variante" precisa ser determinística por usuário (não pode receber A num dispositivo e B em outro) e o resultado é medido em prazo curto (open/click em horas, não dias).
1. Atribuição determinística: a variante é decidida por hash(user_id + experiment_id) % 100. O mesmo usuário sempre cai na mesma variante para o mesmo experimento. Sem necessidade de armazenar a atribuição — recalcula on-the-fly.
2. Variantes como rows na tabela de templates: template_id não aponta para um template fixo, mas para um "experiment_id"; o renderer consulta qual variante essa requisição deve usar.
def render(user_id, template_ref):
if template_ref.is_experiment:
variant = hash(f"{user_id}:{template_ref.exp_id}") % len(template_ref.variants)
template = template_ref.variants[variant]
log_assignment(user_id, template_ref.exp_id, variant)
else:
template = template_ref.fixed
return template.render(user_id_context)
3. Logging de atribuição: registrar em cada envio: (notification_id, user_id, experiment_id, variant). É essa tabela que permite calcular métricas por variante depois.
4. Métricas: contar opens/clicks/conversions por variant_id. Significância estatística com sample size suficiente (geralmente 1000+ usuários por variante para detectar 10% de lift).
5. Ramp-up: começar com 5% do tráfego, ver se métricas são saudáveis (sem aumento de unsubscribe), depois ramp para 50/50. Após N dias com vencedor claro, promover a variante vencedora a "fixa" e desligar o experimento.
6. Multivariate testing: testar título + body + CTA simultaneamente cria 8+ variantes. Atribuição: hash(user_id + exp_id) → (title_variant, body_variant, cta_variant). Precisa de mais sample size; cuidado com efeitos de interação.
Não complique demais: começar com sistema simples (variantes hardcoded no template, hash-based assignment) é suficiente para 90% dos casos. Plataformas como Optimizely/Statsig integram, mas custam — só vale a pena quando o volume justifica.
O time de produto quer saber por que a taxa de "delivered" é só 70% para iOS. Como você investiga?
70% de delivery rate em iOS é baixo mas não impossível — várias causas plausíveis, cada uma com diagnóstico diferente.
1. Tokens inválidos: a fonte mais comum. Usuário desinstalou o app, ou o iOS rotacionou o token. APNs retorna 410 Gone. Diagnóstico: somar response codes do APNs por dia — qual % é 410? Se >20%, é sinal de que o cleanup de tokens inválidos não está acontecendo. Fix: implementar handler para 410 que remove o token do DB; rodar cleanup periódico de tokens não usados em >6 meses.
2. Push desativado pelo usuário: o usuário desativou notificações no nível do sistema operacional (Settings → App → Notifications → Off). APNs ainda aceita o envio (200 OK) mas não exibe nada. Diagnóstico: olhar o token registration — se o app está enviando para o servidor que o usuário desabilitou push, há um caminho diferente. iOS 15+ tem API que expõe esse estado; o app pode reportar.
3. Background app refresh desativado: certas categorias de push (background, silent) só são entregues se Background App Refresh está ligado. Se o app depende disso, usuários com ele desligado nunca recebem. Diagnóstico: cruzar com analytics do app — usuários que recebem push abrem o app vs os que não recebem.
4. APNs throttling: Apple silenciosamente limita apps que enviam muito ou enviam para tokens inválidos. Diagnóstico: olhar latência das requests ao APNs — se está aumentando, pode ser throttle. Verificar Apple Developer dashboard para warnings.
5. Priority baixa: push enviado com apns-priority=5 (low power) só é entregue quando o dispositivo está em condições favoráveis (não em low power mode, conectado a rede). Pode ser entregue horas depois — ou nunca. Diagnóstico: separar métricas por priority.
6. Quiet hours do dispositivo: iOS tem Focus modes/Do Not Disturb que silenciam notificações; algumas configs descartam completamente em vez de empilhar.
7. Definição de "delivered" inconsistente: APNs retorna 200 OK quando ACEITA a notif, não quando o dispositivo a recebe. Não há feedback do Apple sobre se chegou ao device. O que muitos sistemas chamam de "delivered" é na verdade "accepted by APNs". Verificar a métrica — pode ser que o número 70% seja "opened" disfarçado de "delivered".
A metodologia: instrumentar agressivamente, segmentar por (response_code, ios_version, app_version, token_age) e olhar onde a métrica diverge. Geralmente uma causa explica >60% do gap.
Exercícios práticos
Implemente o Notification Service básico: receber um evento (HTTP POST) com (user_id, event_type, payload), consultar as preferências do usuário (mock simples em dict), e despachar tasks para 3 filas diferentes (push, email, in_app) — em vez de Kafka real, use 3 listas Python ou Redis lists. Implementar 3 workers (threads ou processos) que consomem de cada fila e simulam o envio (print no console). Verificar com 100 eventos sintéticos que cada canal processa de forma independente.
Critério: evento que gera 3 canais resulta em 3 tasks (uma por fila). Se um worker (ex: email) é pausado, os outros continuam processando normalmente. Latência média do evento ao "send" simulado é mensurável e <100ms em fila vazia.
Estenda o Exercício 1 com deduplicação. Antes de enfileirar uma task, verificar (user_id, event_id) no Redis com SET NX EX 86400. Se já existe, log e descartar. Testar enviando o mesmo evento 5 vezes — apenas a primeira deve gerar tasks. Bonus: implementar dedup também por (user_id, content_hash) para capturar eventos diferentes com mesmo conteúdo.
Critério: 5 envios do mesmo (user_id, event_id) resultam em 1 set de tasks (uma por canal). Os outros 4 são descartados com log. A chave dedup expira após 24h. Sem race condition: se 5 requests chegam simultaneamente, apenas uma vence (SET NX é atomic).
Implemente um worker de push que falha 30% das vezes (random) e tem retry com backoff [1, 5, 30, 120] segundos. Após 4 tentativas falhadas, mover para uma "dead letter queue" (outra lista Redis). Adicionar jitter ao backoff (multiplicar por random entre 0.5 e 1.5). Verificar com 100 tasks: contar quantas foram sucesso na 1ª, 2ª, 3ª, 4ª tentativa, e quantas foram para DLQ. Expor um endpoint HTTP para listar tasks na DLQ.
Critério: tasks que falham são re-enfileiradas com delay correto (verificar timestamps). Após 4 falhas, vão para DLQ. Endpoint GET /dlq retorna a lista. Implementar também POST /dlq/{task_id}/reprocess que move a task de volta para a fila principal — útil para recovery manual.
Implemente rate limiting usando Redis ZSET. Limite: máximo 5 notificações por hora por usuário, por categoria. Cada notif é registrada como entry no ZSET com score = timestamp. Antes de enfileirar, contar entries no último 1h; se >=5, descartar (ou enfileirar como "deferred" para mais tarde). Testar com bursts: enviar 20 notif rapidamente para o mesmo user — apenas 5 devem passar.
Critério: primeira tentativa de 20 notif: exatamente 5 são processadas, 15 são rate-limited. Após 1 hora completa, 5 novas podem passar. Métricas: contador de notif rate-limited por user/category — útil para identificar produtos com configuração ruim. Bonus: implementar quiet hours (não enviar entre 22h e 8h, exceto categoria "critical").
Implemente um sistema de notificações agendadas usando Redis ZSET. Endpoint POST /schedule recebe (user_id, payload, send_at_timestamp) e adiciona ao ZSET schedule com score = send_at. Um scheduler worker roda a cada 1 segundo: ZRANGEBYSCORE schedule 0 NOW, move as tasks vencidas para a fila principal, e remove do ZSET (com ZREMRANGEBYSCORE). Testar agendando 100 notif para horários espalhados nos próximos 60s; verificar que todas são processadas em ≤1s do horário agendado.
Critério: notif agendada para T é enviada entre T e T+1s. Latência consistente independente do número de tasks pendentes no ZSET (medir com 10, 100, 10000 tasks pendentes). Se o scheduler reinicia no meio, tasks pendentes ainda são processadas corretamente. Bonus: implementar timezone-aware scheduling (notif para "amanhã 9h no fuso do usuário").
Referências
- docs Apple Developer — Sending Notification Requests to APNs
- docs Firebase — Cloud Messaging Architecture
- docs SendGrid — Event Webhook
- docs Twilio — Messaging Best Practices
- article LinkedIn Engineering — Building LinkedIn's Notifications Platform
- article Pinterest Engineering — A Notification System for Pinterest
- article Slack Engineering — Distributed Job Queue
- article Stripe — Designing Robust and Predictable APIs with Idempotency
- standard RFC 2822 — Internet Message Format
- docs Amazon SES — Sender Reputation and Deliverability
- article Snap Engineering — Reducing Notification Fatigue
- book Alex Xu — System Design Interview vol. 2 — Design a Notification System