“Design a messaging system like WhatsApp.”

Se o YouTube é o exercício clássico de throughput e storage, WhatsApp é o exercício clássico de latência e conexões persistentes. O desafio muda completamente: em vez de entregar arquivos grandes pra milhões de viewers passivos, precisamos entregar mensagens pequenas pra bilhões de usuários em tempo real — e garantir que nenhuma se perca.

WhatsApp processa mais de 100 bilhões de mensagens por dia com uma equipe historicamente pequena (~50 engenheiros quando foi adquirido pelo Facebook em 2014). Esse é o poder de boas decisões arquiteturais.

Vamos aplicar o framework da série.

Fase 1: Esclarecer requisitos

Requisitos funcionais

FuncionalidadeDetalhe
Mensagens 1:1Enviar texto, imagem, vídeo entre dois usuários
GruposMensagens pra até 1024 membros
Status de entregaEnviado (✓), entregue (✓✓), lido (✓✓ azul)
Presença onlineMostrar “online” ou “última vez às…”
HistóricoMensagens persistem e sincronizam entre devices

Requisitos não-funcionais

RequisitoTarget
Escala2 bilhões de usuários, 500M DAU
Mensagens/dia100 bilhões
Latência< 100ms pra entrega (usuário online)
Disponibilidade99.99% (< 53 minutos de downtime/ano)
ConsistênciaMensagens nunca podem ser perdidas (at-least-once delivery)
OrdenaçãoMensagens devem chegar na ordem correta por conversa
SegurançaCriptografia end-to-end (servidor não lê conteúdo)

Fora do escopo

  • Chamadas de voz/vídeo (protocolo diferente — WebRTC)
  • Stories/Status (mais parecido com feed)
  • Payments
  • Bots e business API

Uma observação importante sobre a escala

100 bilhões de mensagens/dia com 500M de usuários ativos = 200 mensagens por usuário por dia em média. Parece pouco, mas o pico é brutal: horário comercial, eventos ao vivo, Ano Novo (WhatsApp processa ~75 bilhões de mensagens só em 31/dez).

Fase 2: Estimativas

Mensagens

Mensagens/dia: 100.000.000.000 (100 bilhões)
Mensagens/segundo: 100B / 86.400 ≈ 1.150.000/s (média)
Pico: 1.15M × 5 = ~5.000.000 mensagens/segundo

Tamanho médio de mensagem (texto): ~100 bytes
Tamanho médio com metadata (timestamps, IDs, status): ~500 bytes

Conexões

Usuários online simultâneos (pico): ~200M
Cada usuário = 1 conexão WebSocket persistente
200 milhões de conexões TCP simultâneas

Se cada server aguenta ~500K conexões:
  Servers necessários: 200M / 500K = ~400 servers (só pra conexões)

400 servers mínimo só pra manter as conexões vivas. Na prática, com redundância e overhead, estamos falando de 1000+ servers.

Storage

Mensagens de texto/dia: 100B × 500 bytes = ~50 TB/dia
Mídia (fotos/vídeos): assume 5% das mensagens com média 200 KB
  = 5B × 200 KB = ~1 PB/dia

Retenção: mensagens ficam no server até serem entregues
  (E2E encryption = server não precisa guardar pra sempre)
Mídia: 30 dias de retenção = ~30 PB ativo

Fase 3: High-level design

O desafio core: entrega em tempo real

Diferente do YouTube (pull-based — usuário pede o vídeo), messaging é push-based — a mensagem precisa chegar no recipient sem ele pedir.

Isso exige conexão persistente entre client e server. HTTP request-response não funciona aqui — a latência de abrir/fechar conexão a cada mensagem seria inaceitável.

WebSockets: a espinha dorsal

┌──────────┐         WebSocket          ┌──────────────┐
│  Client  │ ◄════════════════════════► │  Chat Server  │
│  (app)   │   conexão persistente      │  (stateful)   │
└──────────┘     full-duplex            └──────────────┘

Por que WebSocket e não HTTP polling?

ApproachLatênciaOverheadEscala
HTTP Polling (cada 5s)0-5 segundosEnorme (milhões de requests vazios)Péssima
HTTP Long Polling~instantâneoMédio (reconexão a cada msg)Razoável
WebSocket~instantâneoMínimo (1 conexão persistente)Excelente
Server-Sent Events (SSE)~instantâneoBaixoBoa (mas unidirecional)

WebSocket é full-duplex: client e server enviam dados a qualquer momento pela mesma conexão. Uma vez estabelecida, a conexão fica aberta até o client desconectar.

Arquitetura geral

┌─────────────────────────────────────────────────────────────────┐
│                     ENVIO DE MENSAGEM                             │
├─────────────────────────────────────────────────────────────────┤
│                                                                   │
│  Sender ──WebSocket──→ Chat Server A                             │
│                              │                                    │
│                              ▼                                    │
│                      Message Service                              │
│                         │        │                                │
│                         ▼        ▼                                │
│                    Message DB   Session Service                    │
│                                 "Onde está o recipient?"          │
│                                      │                            │
│                              ┌───────┴───────┐                   │
│                              ▼               ▼                    │
│                     [recipient online]  [recipient offline]       │
│                              │               │                    │
│                              ▼               ▼                    │
│                     Chat Server B      Push Notification          │
│                         │              (APNs / FCM)               │
│                         ▼                                         │
│                     Recipient                                     │
│                                                                   │
└─────────────────────────────────────────────────────────────────┘

Componentes principais

ComponenteResponsabilidade
Chat ServerMantém WebSocket connections, roteia mensagens
Session ServiceSabe qual Chat Server cada user está conectado
Message ServicePersiste mensagens, garante entrega
Message DBArmazena mensagens até serem entregues
Push ServiceNotifica usuários offline via APNs/FCM
Media ServiceUpload/download de fotos e vídeos
Presence ServiceRastreia quem está online

API Design

Diferente dos artigos anteriores, aqui o protocolo principal não é REST — é WebSocket com mensagens estruturadas. REST é usado apenas pra operações não-realtime (login, upload de mídia, busca de histórico).

Protocolo WebSocket

// Client → Server: enviar mensagem
{
  "type": "message.send",
  "id": "msg_uuid_123",
  "conversation_id": "conv_abc",
  "recipient_id": "usr_bob",
  "content": {
    "type": "text",
    "body": "E aí, beleza?"
  },
  "timestamp": 1716163200000
}

// Server → Client: confirmação de recebimento (✓)
{
  "type": "message.ack",
  "message_id": "msg_uuid_123",
  "status": "sent",
  "server_timestamp": 1716163200042
}

// Server → Recipient: mensagem chegando
{
  "type": "message.receive",
  "id": "msg_uuid_123",
  "conversation_id": "conv_abc",
  "sender_id": "usr_alice",
  "content": {
    "type": "text",
    "body": "E aí, beleza?"
  },
  "timestamp": 1716163200042
}

// Recipient → Server: confirmação de entrega (✓✓)
{
  "type": "message.delivered",
  "message_id": "msg_uuid_123"
}

// Recipient → Server: confirmação de leitura (✓✓ azul)
{
  "type": "message.read",
  "message_id": "msg_uuid_123"
}

REST endpoints (operações auxiliares)

POST /v1/auth/login           autenticação, recebe token
POST /v1/media/upload         upload de foto/vídeo (pre-signed URL)
GET  /v1/conversations        lista de conversas
GET  /v1/messages/{conv_id}   histórico (paginado, cursor-based)

Por que gerar message_id no client?

O client gera o UUID da mensagem antes de enviar. Isso resolve:

  1. Deduplicação: se a mensagem for enviada duas vezes (retry por timeout), o server reconhece pelo ID e ignora duplicata
  2. Idempotência: operação pode ser repetida sem efeitos colaterais
  3. Offline-first: o client mostra a mensagem na UI imediatamente (optimistic update), antes do server confirmar

Deep Dive 1: Roteamento de mensagens

O problema central: Alice está conectada no Chat Server A. Bob está no Chat Server B. Como a mensagem chega de A em B?

Session Service: o “GPS” de usuários

Precisa de um mapeamento em tempo real:

user_id → { chat_server_id, connection_id, last_heartbeat }

Opções de storage:

OpçãoPrósContras
Redis (in-memory)Ultra-rápido (~0.1ms), TTL nativoCusto alto pra 2B entries
Consistent Hash RingCada server sabe seus usersComplexo, rebalanceamento
Redis Cluster (sharded)Rápido + distribuídoPrecisa gerenciar slots

Redis Cluster é a escolha mais comum. Chave: session:{user_id}, valor: {server_id, connected_at}. TTL de 5 minutos com heartbeat renovando.

Fluxo completo de uma mensagem

1. Alice envia msg pro Chat Server A (via WebSocket)
2. Chat Server A persiste msg no Message DB (status: "sent")
3. Chat Server A retorna ACK pra Alice (✓)
4. Chat Server A consulta Session Service: "onde está Bob?"
5. Session Service retorna: "Bob está no Chat Server B, connection_42"
6. Chat Server A envia msg pro Chat Server B (comunicação server-to-server)
7. Chat Server B entrega msg pro Bob via WebSocket
8. Bob responde com "delivered" ACK
9. Chat Server B atualiza Message DB (status: "delivered")
10. Chat Server B notifica Chat Server A → Alice vê (✓✓)

E se Bob estiver offline?

4. Session Service retorna: "Bob não está conectado"
5. Mensagem fica no Message DB com status "pending"
6. Push Service envia push notification (APNs/FCM)
7. Quando Bob reconecta:
   a. Chat Server busca mensagens pending no Message DB
   b. Entrega todas as mensagens acumuladas
   c. Bob confirma delivery de cada uma
   d. Message DB atualiza status

Comunicação server-to-server

Com 1000+ Chat Servers, eles precisam se comunicar. Opções:

  • RPC direto (gRPC): Chat Server A chama Chat Server B diretamente. Simples mas cria acoplamento.
  • Message broker (Kafka/RabbitMQ): desacopla servers. Mais resiliente mas adiciona latência.
  • Pub/Sub (Redis Pub/Sub): cada Chat Server subscribes no canal do user que está conectado nele. Quando msg chega, publica no canal do recipient.

Na prática, combinação: RPC direto pra casos normais (latência mínima), message queue como fallback pra quando o server destino está sobrecarregado ou indisponível.

Deep Dive 2: Garantia de entrega

Em messaging, perder uma mensagem é inaceitável. Diferente de um view count que pode tolerar inconsistência, uma mensagem perdida pode significar perder um negócio, um compromisso, ou pior.

At-least-once delivery

O sistema garante que toda mensagem será entregue pelo menos uma vez. Pode haver duplicatas (deduplicação no client resolve), mas nunca perda.

Mecanismo:

1. Client envia mensagem
2. Server persiste no DB ANTES de confirmar
3. Server retorna ACK pro sender
4. Server tenta entregar pro recipient
5. Se falha → mensagem fica pendente, retry periódico
6. Recipient confirma entrega
7. Server atualiza status

Se server crash entre 2 e 3: 
  → Client não recebeu ACK → faz retry → server deduplicata pelo message_id

Se server crash entre 4 e 6:
  → Mensagem está no DB → quando recipient reconecta, recebe

Ordenação de mensagens

Mensagens numa conversa devem chegar na ordem. Mas com múltiplos servers e rede assíncrona, como garantir?

Solução: sequence number por conversa

Cada conversa tem um counter atômico. Toda mensagem recebe um sequence_number incremental:

{
  "message_id": "msg_123",
  "conversation_id": "conv_abc",
  "sequence_number": 47,
  "content": "..."
}

O client reordena baseado no sequence number. Se recebe msg 49 antes da 48, segura até 48 chegar (ou pede retry após timeout).

Onde armazenar o counter? Redis com INCR atômico: INCR conv:{conv_id}:seq. Operação O(1) e thread-safe.

Message queue como buffer

Pra picos extremos (Ano Novo, eventos), mensagens entram mais rápido do que podem ser processadas. Sem buffer, o sistema vai dropar ou crashar.

Sender → Chat Server → Kafka (topic: messages) → Consumer → Recipient

Kafka absorve o pico. Se consumers ficam pra trás, mensagens acumulam no topic (persistente em disco) e são processadas quando o sistema recupera. Nenhuma mensagem é perdida.

Deep Dive 3: Presença online

“Online”, “last seen at 14:32”, “typing…” — essas features parecem simples mas são surpreendentemente caras em escala.

O problema

200 milhões de usuários online simultaneamente. Se cada um faz heartbeat a cada 30 segundos pra indicar que está vivo:

200M / 30 = ~6.7 milhões de heartbeats/segundo

6.7 milhões de writes/segundo só pra presença. E cada mudança de status (“ficou online”, “saiu”) precisa ser comunicada pra todos os contatos daquele usuário.

Se cada usuário tem 100 contatos em média:

Mudança de presença × contatos = fan-out massivo
Se 10M de usuários mudam status/minuto × 100 contatos = 1 bilhão de updates/minuto

Estratégias pra escalar presença

1. Lazy presence (WhatsApp approach):

  • Não faz broadcast pra todos os contatos
  • Presença é pull-based: só consulta quando o usuário abre a conversa com alguém
  • Reduz fan-out drasticamente

2. Threshold-based:

  • Só atualiza se mudança real de estado (online→offline, offline→online)
  • Ignora heartbeats intermediários
  • “Last seen” é atualizado periodicamente (a cada 5 min), não em real-time

3. Presença por conversa ativa:

  • Só envia “typing…” e “online” pra conversas abertas no momento
  • Se você não está olhando a conversa com Alice, não recebe os typing indicators dela

Implementação

┌──────────┐   heartbeat (cada 30s)    ┌───────────────────┐
│  Client  │ ─────────────────────────→ │  Presence Service  │
└──────────┘                            │  (Redis TTL)       │
                                        └───────────────────┘
                                     Key: presence:{user_id}
                                     Value: {status, last_seen}
                                     TTL: 60 seconds
                                     
Se heartbeat não renova dentro do TTL → key expira → user é considerado offline

Redis com TTL é elegante aqui: se o client crash sem mandar “going offline”, a key simplesmente expira e o status vira offline automaticamente. Sem necessidade de cleanup.

Deep Dive 4: Criptografia end-to-end (E2E)

O princípio

O server nunca tem acesso ao conteúdo da mensagem. Só o sender e o recipient conseguem ler.

Alice (plain) → encrypt com chave de Bob → [ciphertext] → Server → [ciphertext] → decrypt com chave de Bob → Bob (plain)

O server só vê ciphertext. Mesmo se alguém invadir o server, as mensagens são ilegíveis.

Signal Protocol (usado pelo WhatsApp)

O WhatsApp usa o Signal Protocol (Double Ratchet Algorithm):

  1. Key exchange (X3DH): quando Alice quer falar com Bob pela primeira vez, eles trocam chaves públicas pra estabelecer um segredo compartilhado. O server facilita essa troca mas não conhece o segredo.

  2. Double Ratchet: cada mensagem usa uma chave diferente (derivada da anterior). Se uma chave for comprometida, mensagens anteriores e futuras continuam protegidas (forward secrecy + backward secrecy).

  3. Pre-keys: Bob registra chaves públicas “descartáveis” no server. Alice pode iniciar conversa com Bob mesmo se ele estiver offline, usando uma pre-key.

Implicações pra arquitetura

AspectoImpacto
StorageServer armazena ciphertext (mesmo tamanho + overhead de ~40 bytes)
BuscaImpossível buscar conteúdo de mensagens no server-side
BackupBackup de mensagens precisa ser encrypt/decrypt no client
GruposCada mensagem é encrypted N vezes (uma pra cada membro)
MídiaMídia é encrypted com chave simétrica; chave é enviada via mensagem E2E

E2E em grupos

Mensagem pro grupo de 100 pessoas: o sender não encrypta 100 vezes individualmente. Seria lento e redundante.

Solução: Sender Key protocol

  1. Cada membro gera uma “sender key” e distribui (via E2E 1:1) pra todos os membros
  2. Quando envia mensagem pro grupo, encrypta uma vez com sua sender key
  3. Todos os membros têm a sender key do sender e podem decriptar
  4. Se alguém sai do grupo → nova sender key é gerada e redistribuída

Uma encrypt por mensagem (independente do tamanho do grupo). Eficiente.

Deep Dive 5: Database design

Message storage

Opção avaliada: Cassandra (escolha real do WhatsApp original com Erlang/Mnesia, depois migraram)

CREATE TABLE messages (
    conversation_id UUID,
    sequence_number BIGINT,
    message_id UUID,
    sender_id UUID,
    content_encrypted BLOB,
    content_type TEXT,        -- 'text', 'image', 'video'
    media_url TEXT,
    status TEXT,              -- 'sent', 'delivered', 'read'
    created_at TIMESTAMP,
    PRIMARY KEY (conversation_id, sequence_number)
) WITH CLUSTERING ORDER BY (sequence_number ASC);

Por que esse modelo funciona:

  • Partition key: conversation_id — todas as mensagens de uma conversa ficam no mesmo nó. Query “últimas 50 mensagens da conversa X” é uma leitura sequencial em disco (extremamente rápida).
  • Clustering key: sequence_number — mensagens são armazenadas ordenadas fisicamente. Range scan por order é O(N).
  • Write-optimized: Cassandra é append-only (LSM tree). Escrita é absurdamente rápida (~1ms).

Sizing

100B mensagens/dia × 500 bytes = 50 TB/dia
Retenção de 30 dias (pending messages) = ~1.5 PB
Mensagens entregues podem ir pra cold storage ou serem deletadas do server
  (E2E = server não precisa guardar após entrega)

Conversa metadata (separado)

-- PostgreSQL pra metadata relacional
CREATE TABLE conversations (
    id UUID PRIMARY KEY,
    type TEXT,                  -- 'direct', 'group'
    created_at TIMESTAMP,
    updated_at TIMESTAMP
);

CREATE TABLE conversation_members (
    conversation_id UUID REFERENCES conversations(id),
    user_id UUID,
    role TEXT DEFAULT 'member', -- 'admin', 'member'
    joined_at TIMESTAMP,
    PRIMARY KEY (conversation_id, user_id)
);

CREATE TABLE users (
    id UUID PRIMARY KEY,
    phone_number VARCHAR(20) UNIQUE,
    display_name VARCHAR(100),
    avatar_url TEXT,
    public_key BYTEA,          -- pra E2E key exchange
    created_at TIMESTAMP
);

Handling de cenários edge

Usuário troca de device

Mensagens são E2E encrypted. O server não tem o plaintext. Como sincronizar histórico pro novo device?

Opções:

  1. Backup na nuvem (encrypted): client encrypta todo o histórico com uma chave que só o usuário conhece (derivada de senha ou PIN) e faz upload. Novo device baixa e decripta. É o que WhatsApp faz com Google Drive/iCloud.

  2. Transferência local: devices se conectam via rede local e transferem diretamente. Seguro mas inconveniente.

  3. Sem transferência: novo device começa do zero. Simples pra o sistema mas UX ruim.

Grupo com 1024 membros — mensagem demora?

Fan-out de grupo grande:

Sender envia 1 mensagem → Server precisa entregar pra 1023 recipients
Se cada entrega leva 5ms → 5 segundos pra todos receberem?

Solução: paralelismo. Não entrega sequencialmente. O message service publica um evento no broker, múltiplos Chat Servers processam em paralelo. Entrega pra todos em <100ms.

Client está offline há 3 dias — 500 mensagens acumuladas

Quando reconecta:

  1. Client informa último sequence_number recebido por conversa
  2. Server retorna delta (mensagens após aquele sequence number)
  3. Entrega em batch, não uma por uma
  4. Client confirma em batch também
// Server → Client (sync batch)
{
  "type": "sync.batch",
  "conversation_id": "conv_abc",
  "messages": [
    {"seq": 48, "id": "msg_1", ...},
    {"seq": 49, "id": "msg_2", ...},
    {"seq": 50, "id": "msg_3", ...}
  ]
}

Trade-offs e decisões

DecisãoAlternativaPor que essa escolha
WebSocketHTTP Long PollingFull-duplex, menos overhead, escala melhor
Client-generated message IDServer-generatedIdempotência, deduplicação, offline-first
At-least-once deliveryExactly-onceExactly-once é exponencialmente mais complexo; dedup no client é simples
Sequence number por conversaTimestamp orderingTimestamps podem colidir e clocks são unreliable
Cassandra (messages)PostgreSQLWrite throughput de 1M+/s, partition by conversation
Redis (sessions/presence)DB relacionalLatência <1ms, TTL nativo, 6.7M writes/s
Lazy presenceBroadcast presenceFan-out de broadcast é insustentável em escala
Signal Protocol (E2E)TLS only (encrypt in transit)Privacidade real — server não lê conteúdo
Sender Key (grupos)E2E individual por membro1 encrypt vs N encrypts por mensagem de grupo
Push notifications (offline)Apenas entrega no reconnectUX — user precisa saber que tem msg pendente

Como escalar além

  1. Geo-routing: conectar usuário ao Chat Server mais próximo geograficamente (DNS anycast ou geo load balancer)
  2. Connection draining: antes de desligar um server pra manutenção, migrar conexões gracefully pro outro
  3. Message compaction: após entrega confirmada + E2E, deletar conteúdo do server (só manter tombstone pro audit)
  4. Rate limiting inteligente: detectar spam patterns (1000 msgs/minuto) sem bloquear usuários legítimos
  5. Multi-device: sincronizar estado entre phone + desktop + web (WhatsApp Web model via QR code e relay)

Resumo

ComponenteTecnologiaMotivo
Conexão client-serverWebSocketFull-duplex, baixa latência, persistente
RoteamentoSession Service (Redis Cluster)Lookup em <1ms: qual server tem qual user
Server-to-servergRPC + Kafka (fallback)Latência mínima + resiliência
Message storageCassandraWrite-heavy, partitioned by conversation
MetadataPostgreSQLRelacional, queries complexas
PresençaRedis com TTLAuto-expiry, alta escrita
Push notificationsAPNs + FCMAlcançar users offline
CriptografiaSignal Protocol (E2E)Privacidade real, forward secrecy
MediaBlob Storage + CDNArquivos grandes, entrega global
DeduplicaçãoClient-generated UUIDIdempotência sem server-side tracking

Esse é o terceiro artigo da série System Design na Prática. No próximo, vamos projetar o Uber — geolocalização em tempo real, matching de motoristas, e cálculo de ETA com milhões de pontos se movendo simultaneamente.