Skip to content

Case Study: WhatsApp

WhatsApp is a real-time delivery problem at a scale that makes the infrastructure choices non-obvious. The defining constraint is 2 billion concurrent WebSocket connections distributed across a fleet of gateway servers. Any message delivery requires knowing which gateway server the recipient is connected to at this instant: that lookup is the architectural linchpin. The key insight is that a single Redis hash table, user_id -> gateway_server_id, solves the routing problem for all 2 billion connections. This same pattern reappears in Uber (driver_id -> gateway_server_id for real-time location delivery to riders).

An existing Chat System case study covers this design in full detail from first principles. This entry focuses on WhatsApp-specific features (multi-device sync, end-to-end encryption, status and stories) and on the WebSocket routing pattern that carries forward to other entries in this series.

Series concepts

Introduced here

  • WebSocket connection fleet with Redis routing table: user_id -> gateway_server_id in Redis. On message delivery, look up the recipient’s current gateway server, then route to it. 2 billion connections at 1M per server = 2,000 gateway servers.
  • Message ordering via per-conversation sequence numbers: timestamps are unreliable across devices (clocks drift, messages arrive out of order). Each conversation has a monotonically increasing sequence number; messages are ordered by sequence, not by received timestamp.
  • Exactly-once delivery via idempotency: the client generates a client_msg_id (UUID). The server deduplicates on this key before processing. Retries return the same message record without re-delivery.
  • Online presence: user_id -> {status, last_seen} in Redis with TTL. Presence is eventually consistent by design: a user who loses connectivity may appear online for up to 30 seconds.

Carried forward from prior entries

  • Kafka (Bitly): messages that cannot be delivered immediately (recipient offline) are queued in Kafka for later delivery.
  • Redis (Bitly): connection routing table uses the same Redis cluster pattern introduced for URL caching.
  • ID generation (Bitly): message IDs use Snowflake-style generation; per-conversation sequence numbers use a Redis counter.

Clarifying questions

Ask these before drawing anything:

  • Scale: how many users? DAU? Messages per day?
  • Message types: text only, or media (images, video, audio, documents)?
  • Group chats: how large? What is the max group size?
  • Multi-device: can users be logged in on multiple devices simultaneously?
  • End-to-end encryption: is E2EE required?
  • Offline delivery: how long should undelivered messages be retained?
  • Read receipts: single check (delivered) vs double check (read)?

What the answers reveal:

  • Media support adds a separate upload/CDN path; messages become metadata pointers to S3
  • Large group chats (1,000+ members) change the fanout model (similar to celebrity problem in News Feed)
  • Multi-device means delivery is to a set of connections, not a single connection
  • E2EE means the server cannot read, search, or moderate message content
  • Offline retention determines how long Kafka/DB must buffer undelivered messages

For this walkthrough: 2B users, 1B DAU, 40 messages/day/DAU, text + media, groups up to 1,000 members, multi-device (up to 5 devices), E2EE required, 30-day offline retention.

Estimation

Message volume:
1B DAU * 40 messages/day = 40B messages/day
40B / 86,400 = 463,000 write QPS
WebSocket connections:
2B users, assume 50% online at peak = 1B concurrent connections
Each gateway server handles ~1M WebSocket connections
1B / 1M = 1,000 gateway servers at peak
Scale to 2,000 for headroom
Message storage:
40B messages/day * 30-day retention
Average message: 200 bytes (text; media is a pointer)
40B * 30 * 200 bytes = 240 TB (30-day buffer)
After 30 days: delivered messages can be deleted from server
(E2EE: server stores only ciphertext; no long-term storage needed)
Media storage:
~20% of messages contain media
40B * 20% = 8B media messages/day
Average media size: 200 KB (compressed images/voice)
8B * 200 KB = 1.6 PB/day raw
With dedup (same media forwarded): ~30% reduction = ~1.1 PB/day
Connection routing table:
2B users * (user_id: 8 bytes + server_id: 4 bytes) = ~24 GB Redis
Fits comfortably in a single Redis Cluster

Capacity driver: 463K write QPS for message delivery is the primary scaling constraint. The connection routing table (24 GB Redis) is small and fast. Media storage (1.1 PB/day) requires S3 and a CDN; text message retention (240 TB/30 days) is the buffer sizing concern.

High-level design

flowchart TD
SenderDevice -->|WebSocket| GW1[Gateway Server 1]
RecipientDevice -->|WebSocket| GW2[Gateway Server 2]
GW1 -->|1. receive message| MsgSvc[Message Service]
MsgSvc -->|2. lookup recipient gateway| Redis[(Redis\nRouting Table)]
Redis -->|3. gateway_server_id| MsgSvc
MsgSvc -->|4. route via pub/sub| GW2
GW2 -->|5. push to WebSocket| RecipientDevice
MsgSvc -->|if offline| Kafka[Kafka: msg.pending]
Kafka -->|on reconnect| DeliverySvc[Delivery Service]
DeliverySvc --> Redis
DeliverySvc -->|deliver| GW2
MsgSvc --> MsgDB[(Message DB\nCassandra)]
MsgSvc --> SeqSvc[Sequence Service\nRedis counter]

Core endpoints / WebSocket message types:

WebSocket: wss://ws.whatsapp.com/
Client -> Server message types:
{ type: 'send_message', to: user_id, content: ciphertext, client_msg_id: uuid,
conversation_id: string }
{ type: 'ack', message_id: string, status: 'delivered' | 'read' }
{ type: 'presence', status: 'online' | 'away' }
Server -> Client message types:
{ type: 'message', from: user_id, content: ciphertext, message_id: string,
sequence_number: int }
{ type: 'ack', client_msg_id: uuid, message_id: string, status: 'sent' }
{ type: 'presence_update', user_id: string, status: string, last_seen: timestamp }

Deep dive: WebSocket connection routing

When a recipient is connected to gateway server G2, and the sender’s message arrives at gateway server G1, G1 must forward the message to G2. The routing table in Redis makes this a single O(1) lookup.

import redis
import json
r = redis.Redis(host='redis-routing', port=6379)
# On client connection:
def register_connection(user_id: str, device_id: str, gateway_server_id: str):
key = f"conn:{user_id}:{device_id}"
r.setex(key, 300, gateway_server_id) # 5-min TTL, refreshed by heartbeat
r.sadd(f"user:devices:{user_id}", device_id)
# On client disconnect or TTL expiry:
def deregister_connection(user_id: str, device_id: str):
r.delete(f"conn:{user_id}:{device_id}")
r.srem(f"user:devices:{user_id}", device_id)
# On message delivery:
def deliver_message(to_user_id: str, message: dict) -> str:
device_ids = r.smembers(f"user:devices:{to_user_id}")
if not device_ids:
# user is offline: queue for later delivery
return 'queued'
delivered_to = []
for device_id in device_ids:
gateway_id = r.get(f"conn:{to_user_id}:{device_id.decode()}")
if not gateway_id:
continue
gateway = gateway_id.decode()
if gateway == THIS_SERVER_ID:
# recipient is on this server: push directly
push_to_websocket(to_user_id, device_id, message)
else:
# recipient is on another server: publish via Redis pub/sub
channel = f"gateway:{gateway}:inbox"
r.publish(channel, json.dumps({
'user_id': to_user_id,
'device_id': device_id.decode(),
'message': message,
}))
delivered_to.append(device_id)
return 'delivered' if delivered_to else 'queued'
import { createClient } from 'redis';
const r = createClient({ url: 'redis://redis-routing:6379' });
await r.connect();
const THIS_SERVER_ID = process.env.GATEWAY_SERVER_ID!;
// On client connection:
async function registerConnection(userId: string, deviceId: string, gatewayServerId: string): Promise<void> {
const key = `conn:${userId}:${deviceId}`;
await r.setEx(key, 300, gatewayServerId); // 5-min TTL, refreshed by heartbeat
await r.sAdd(`user:devices:${userId}`, deviceId);
}
// On client disconnect or TTL expiry:
async function deregisterConnection(userId: string, deviceId: string): Promise<void> {
await r.del(`conn:${userId}:${deviceId}`);
await r.sRem(`user:devices:${userId}`, deviceId);
}
// On message delivery:
async function deliverMessage(toUserId: string, message: object): Promise<string> {
const deviceIds = await r.sMembers(`user:devices:${toUserId}`);
if (deviceIds.length === 0) {
return 'queued';
}
const deliveredTo: string[] = [];
for (const deviceId of deviceIds) {
const gatewayId = await r.get(`conn:${toUserId}:${deviceId}`);
if (!gatewayId) continue;
if (gatewayId === THIS_SERVER_ID) {
pushToWebSocket(toUserId, deviceId, message);
} else {
const channel = `gateway:${gatewayId}:inbox`;
await r.publish(channel, JSON.stringify({ user_id: toUserId, device_id: deviceId, message }));
}
deliveredTo.push(deviceId);
}
return deliveredTo.length > 0 ? 'delivered' : 'queued';
}
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"github.com/redis/go-redis/v9"
)
var rdb = redis.NewClient(&redis.Options{Addr: "redis-routing:6379"})
var thisServerID = os.Getenv("GATEWAY_SERVER_ID")
// On client connection:
func registerConnection(ctx context.Context, userID, deviceID, gatewayServerID string) error {
key := fmt.Sprintf("conn:%s:%s", userID, deviceID)
if err := rdb.SetEx(ctx, key, gatewayServerID, 300*1e9).Err(); err != nil {
return err
}
return rdb.SAdd(ctx, fmt.Sprintf("user:devices:%s", userID), deviceID).Err()
}
// On client disconnect or TTL expiry:
func deregisterConnection(ctx context.Context, userID, deviceID string) error {
rdb.Del(ctx, fmt.Sprintf("conn:%s:%s", userID, deviceID))
return rdb.SRem(ctx, fmt.Sprintf("user:devices:%s", userID), deviceID).Err()
}
// On message delivery:
func deliverMessage(ctx context.Context, toUserID string, message map[string]interface{}) (string, error) {
deviceIDs, err := rdb.SMembers(ctx, fmt.Sprintf("user:devices:%s", toUserID)).Result()
if err != nil || len(deviceIDs) == 0 {
return "queued", err
}
var deliveredTo []string
for _, deviceID := range deviceIDs {
gatewayID, err := rdb.Get(ctx, fmt.Sprintf("conn:%s:%s", toUserID, deviceID)).Result()
if err != nil {
continue
}
if gatewayID == thisServerID {
pushToWebSocket(toUserID, deviceID, message)
} else {
channel := fmt.Sprintf("gateway:%s:inbox", gatewayID)
payload, _ := json.Marshal(map[string]interface{}{
"user_id": toUserID,
"device_id": deviceID,
"message": message,
})
rdb.Publish(ctx, channel, payload)
}
deliveredTo = append(deliveredTo, deviceID)
}
if len(deliveredTo) > 0 {
return "delivered", nil
}
return "queued", nil
}

Each gateway server subscribes to its own inbox channel. Messages arrive as pub/sub events and are pushed to the appropriate WebSocket.

Deep dive: message ordering with sequence numbers

Clocks across devices are not reliable. If user A sends message 1 at 12:00:00.000 and message 2 at 12:00:00.001, both may arrive at the server out of order due to network jitter. Ordering by server-received timestamp introduces similar problems. The correct primitive is a per-conversation monotonic sequence number.

def assign_sequence_number(conversation_id: str) -> int:
"""
Atomically increment and return the sequence number for this conversation.
Redis INCR is atomic and O(1).
"""
key = f"seq:{conversation_id}"
return r.incr(key)
def send_message(
sender_id: str,
conversation_id: str,
content: bytes, # ciphertext
client_msg_id: str,
) -> dict:
# idempotency check
existing = message_db.get_by_client_msg_id(client_msg_id, sender_id)
if existing:
return existing # duplicate: return original result
seq_num = assign_sequence_number(conversation_id)
message_id = generate_snowflake_id()
message = {
'message_id': message_id,
'conversation_id': conversation_id,
'sender_id': sender_id,
'content': content, # ciphertext; server cannot read this
'sequence_number': seq_num,
'client_msg_id': client_msg_id,
'created_at': now_ms(),
}
# persist first, then deliver
message_db.insert(message)
deliver_message(get_recipients(conversation_id), message)
return message
async function assignSequenceNumber(conversationId: string): Promise<number> {
const key = `seq:${conversationId}`;
return await r.incr(key);
}
interface Message {
message_id: string;
conversation_id: string;
sender_id: string;
content: Buffer;
sequence_number: number;
client_msg_id: string;
created_at: number;
}
async function sendMessage(
senderId: string,
conversationId: string,
content: Buffer,
clientMsgId: string
): Promise<Message> {
const existing = await messageDb.getByClientMsgId(clientMsgId, senderId);
if (existing) {
return existing;
}
const seqNum = await assignSequenceNumber(conversationId);
const messageId = generateSnowflakeId();
const message: Message = {
message_id: messageId,
conversation_id: conversationId,
sender_id: senderId,
content,
sequence_number: seqNum,
client_msg_id: clientMsgId,
created_at: Date.now(),
};
await messageDb.insert(message);
await deliverMessage(await getRecipients(conversationId), message);
return message;
}
func assignSequenceNumber(ctx context.Context, conversationID string) (int64, error) {
key := fmt.Sprintf("seq:%s", conversationID)
return rdb.Incr(ctx, key).Result()
}
type Message struct {
MessageID string
ConversationID string
SenderID string
Content []byte // ciphertext; server cannot read this
SequenceNumber int64
ClientMsgID string
CreatedAt int64
}
func sendMessage(ctx context.Context, senderID, conversationID string, content []byte, clientMsgID string) (*Message, error) {
existing, err := messageDB.GetByClientMsgID(ctx, clientMsgID, senderID)
if err == nil && existing != nil {
return existing, nil
}
seqNum, err := assignSequenceNumber(ctx, conversationID)
if err != nil {
return nil, err
}
messageID := generateSnowflakeID()
msg := &Message{
MessageID: messageID,
ConversationID: conversationID,
SenderID: senderID,
Content: content,
SequenceNumber: seqNum,
ClientMsgID: clientMsgID,
CreatedAt: time.Now().UnixMilli(),
}
if err := messageDB.Insert(ctx, msg); err != nil {
return nil, err
}
recipients, _ := getRecipients(ctx, conversationID)
deliverMessage(ctx, recipients, msg)
return msg, nil
}

Clients sort displayed messages by sequence_number, not by created_at. If a message with seq 5 arrives before seq 4 (out-of-order delivery), the client buffers seq 5 and waits briefly for seq 4. After a timeout, it requests the missing message explicitly.

Deep dive: multi-device sync

A user has a phone, a laptop, and a web browser all logged in simultaneously. Each maintains an independent WebSocket connection. Every message must be delivered to all active devices.

def sync_message_to_devices(user_id: str, message: dict):
"""Deliver a message to all active devices for a user."""
device_ids = get_active_devices(user_id)
for device_id in device_ids:
# each device gets the same message
# the device tracks which messages it has displayed (by sequence_number)
deliver_to_device(user_id, device_id, message)
def handle_read_receipt(user_id: str, device_id: str, message_id: str):
"""
When a user reads a message on one device, mark it read on all devices.
"""
conversation_id = get_conversation_for_message(message_id)
seq_num = get_sequence_number(message_id)
# update read position for this user in this conversation
r.set(f"read_pos:{conversation_id}:{user_id}", seq_num)
# notify all other devices belonging to this user
other_devices = [d for d in get_active_devices(user_id) if d != device_id]
for other_device_id in other_devices:
deliver_to_device(user_id, other_device_id, {
'type': 'read_sync',
'conversation_id': conversation_id,
'read_through_seq': seq_num,
})
async function syncMessageToDevices(userId: string, message: object): Promise<void> {
const deviceIds = await getActiveDevices(userId);
for (const deviceId of deviceIds) {
await deliverToDevice(userId, deviceId, message);
}
}
async function handleReadReceipt(userId: string, deviceId: string, messageId: string): Promise<void> {
const conversationId = await getConversationForMessage(messageId);
const seqNum = await getSequenceNumber(messageId);
await r.set(`read_pos:${conversationId}:${userId}`, seqNum);
const allDevices = await getActiveDevices(userId);
const otherDevices = allDevices.filter(d => d !== deviceId);
for (const otherDeviceId of otherDevices) {
await deliverToDevice(userId, otherDeviceId, {
type: 'read_sync',
conversation_id: conversationId,
read_through_seq: seqNum,
});
}
}
func syncMessageToDevices(ctx context.Context, userID string, message map[string]interface{}) {
deviceIDs, _ := getActiveDevices(ctx, userID)
for _, deviceID := range deviceIDs {
deliverToDevice(ctx, userID, deviceID, message)
}
}
func handleReadReceipt(ctx context.Context, userID, deviceID, messageID string) error {
conversationID, err := getConversationForMessage(ctx, messageID)
if err != nil {
return err
}
seqNum, err := getSequenceNumber(ctx, messageID)
if err != nil {
return err
}
readPosKey := fmt.Sprintf("read_pos:%s:%s", conversationID, userID)
if err := rdb.Set(ctx, readPosKey, seqNum, 0).Err(); err != nil {
return err
}
allDevices, _ := getActiveDevices(ctx, userID)
for _, otherDeviceID := range allDevices {
if otherDeviceID == deviceID {
continue
}
deliverToDevice(ctx, userID, otherDeviceID, map[string]interface{}{
"type": "read_sync",
"conversation_id": conversationID,
"read_through_seq": seqNum,
})
}
return nil
}

The read position (read_pos:{conversation_id}:{user_id}) is a single integer in Redis: the highest sequence number the user has read. Any device can compute “unread count” as max_seq - read_pos. When the user reads on one device, the sync event propagates to all others within milliseconds.

Deep dive: end-to-end encryption implications

WhatsApp uses the Signal Protocol. The server stores only ciphertext. Keys are managed entirely on client devices.

# What the server stores (ciphertext blob, opaque to server):
CREATE TABLE messages (
message_id BIGINT PRIMARY KEY,
conversation_id UUID NOT NULL,
sender_id BIGINT NOT NULL,
ciphertext BYTEA NOT NULL, -- server cannot decrypt this
sequence_number BIGINT NOT NULL,
client_msg_id UUID UNIQUE NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
-- No content column. No plaintext. No search index.
-- The server is a routing and storage intermediary, nothing more.
// What the server stores (ciphertext blob, opaque to server):
// CREATE TABLE messages (
// message_id BIGINT PRIMARY KEY,
// conversation_id UUID NOT NULL,
// sender_id BIGINT NOT NULL,
// ciphertext BYTEA NOT NULL, -- server cannot decrypt this
// sequence_number BIGINT NOT NULL,
// client_msg_id UUID UNIQUE NOT NULL,
// created_at TIMESTAMPTZ NOT NULL
// );
//
// No content column. No plaintext. No search index.
// The server is a routing and storage intermediary, nothing more.
interface StoredMessage {
message_id: bigint;
conversation_id: string;
sender_id: bigint;
ciphertext: Buffer; // server cannot decrypt this
sequence_number: bigint;
client_msg_id: string;
created_at: Date;
}
// What the server stores (ciphertext blob, opaque to server):
// CREATE TABLE messages (
// message_id BIGINT PRIMARY KEY,
// conversation_id UUID NOT NULL,
// sender_id BIGINT NOT NULL,
// ciphertext BYTEA NOT NULL,
// sequence_number BIGINT NOT NULL,
// client_msg_id UUID UNIQUE NOT NULL,
// created_at TIMESTAMPTZ NOT NULL
// );
//
// No content column. No plaintext. No search index.
// The server is a routing and storage intermediary, nothing more.
type StoredMessage struct {
MessageID int64
ConversationID string
SenderID int64
Ciphertext []byte // server cannot decrypt this
SequenceNumber int64
ClientMsgID string
CreatedAt time.Time
}

Operational implications the interview should address:

# 1. Server cannot search message content
# Full-text search is impossible server-side.
# WhatsApp's "Search messages" feature runs entirely on the device,
# searching the local decrypted message store.
# 2. Server cannot filter spam or CSAM by content
# Content moderation must be done differently:
# - hash-based matching on media (perceptual hashes sent alongside ciphertext)
# - metadata analysis (send frequency, group membership patterns)
# - user reports (sender identity, not content)
# 3. Key loss = message loss
# If a user loses their device and did not back up their keys,
# all past messages are irrecoverable. WhatsApp offers optional
# encrypted backups to Google Drive / iCloud, but this is opt-in.
# 4. Group key distribution
# For a group of N members, the sender encrypts the message N times
# (once per member's public key) or uses a group session key
# (Signal's Sender Keys protocol). Sender Keys: one encryption,
# each member can decrypt with their copy of the group session key.
def encrypt_for_group(plaintext: bytes, group_session_key: bytes) -> bytes:
"""
With Sender Keys: encrypt once with the group session key.
Each member has a copy of this key (distributed out-of-band).
Server receives one ciphertext; each member decrypts independently.
"""
return aes_gcm_encrypt(plaintext, group_session_key)
// 1. Server cannot search message content
// Full-text search is impossible server-side.
// WhatsApp's "Search messages" feature runs entirely on the device,
// searching the local decrypted message store.
// 2. Server cannot filter spam or CSAM by content
// Content moderation must be done differently:
// - hash-based matching on media (perceptual hashes sent alongside ciphertext)
// - metadata analysis (send frequency, group membership patterns)
// - user reports (sender identity, not content)
// 3. Key loss = message loss
// If a user loses their device and did not back up their keys,
// all past messages are irrecoverable. WhatsApp offers optional
// encrypted backups to Google Drive / iCloud, but this is opt-in.
// 4. Group key distribution
// For a group of N members, the sender encrypts the message N times
// (once per member's public key) or uses a group session key
// (Signal's Sender Keys protocol). Sender Keys: one encryption,
// each member can decrypt with their copy of the group session key.
function encryptForGroup(plaintext: Buffer, groupSessionKey: Buffer): Buffer {
// With Sender Keys: encrypt once with the group session key.
// Each member has a copy of this key (distributed out-of-band).
// Server receives one ciphertext; each member decrypts independently.
return aesGcmEncrypt(plaintext, groupSessionKey);
}
// 1. Server cannot search message content
// Full-text search is impossible server-side.
// WhatsApp's "Search messages" feature runs entirely on the device,
// searching the local decrypted message store.
// 2. Server cannot filter spam or CSAM by content
// Content moderation must be done differently:
// - hash-based matching on media (perceptual hashes sent alongside ciphertext)
// - metadata analysis (send frequency, group membership patterns)
// - user reports (sender identity, not content)
// 3. Key loss = message loss
// If a user loses their device and did not back up their keys,
// all past messages are irrecoverable. WhatsApp offers optional
// encrypted backups to Google Drive / iCloud, but this is opt-in.
// 4. Group key distribution
// For a group of N members, the sender encrypts the message N times
// (once per member's public key) or uses a group session key
// (Signal's Sender Keys protocol). Sender Keys: one encryption,
// each member can decrypt with their copy of the group session key.
func encryptForGroup(plaintext []byte, groupSessionKey []byte) ([]byte, error) {
// With Sender Keys: encrypt once with the group session key.
// Each member has a copy of this key (distributed out-of-band).
// Server receives one ciphertext; each member decrypts independently.
return aesGCMEncrypt(plaintext, groupSessionKey)
}

Failure modes

Gateway server crash: all connections on that server disconnect. Clients detect the TCP close and reconnect to a different gateway (with exponential backoff + jitter). The routing table entries expire after 5 minutes (TTL), or are explicitly cleaned up by the crashed server’s health-checker. Messages delivered during the reconnect window are queued in Kafka and delivered on reconnect.

Redis routing table failure: delivery is impossible for online users (cannot look up their gateway). Fail open to the offline queue (Kafka); deliver when Redis recovers. This is safer than guessing gateways randomly.

Kafka consumer lag: if the delivery service falls behind, offline users see delayed messages on reconnect. Add consumer instances; Kafka partitioning by conversation_id ensures ordering within a conversation even with multiple consumers.

Sequence number counter gap: if the Redis counter for a conversation is lost (Redis failure without persistence), sequence numbers restart from 0. Clients detect the discontinuity and request a full re-sync from the message database. Mitigation: persist the sequence counter with Redis AOF.

Group message fanout storm: a message in a 1,000-member group requires 1,000 delivery operations. At 463K messages/sec globally, if 1% are group messages, that is 4.63M fanout deliveries/sec. Keep group fanout on a separate worker pool from direct messages; bound group size to 1,000 to limit fanout fan-out.

Key takeaways

The connection routing table is the architectural linchpin. Every real-time delivery problem (WhatsApp, Uber, multiplayer games) reduces to the same lookup: who is this user connected to right now? A Redis hash keyed by user_id answers this in O(1) for any scale.

Use sequence numbers, not timestamps, for message ordering. Clocks drift. Networks reorder packets. A monotonically increasing sequence number per conversation is the only reliable ordering primitive. Clients that receive out-of-order messages buffer and wait, then request gaps explicitly.

E2EE is an architectural constraint, not a feature toggle. Once you commit to server-side ciphertext only, you lose content search, content moderation, and spam filtering by content. These must be solved through metadata analysis and client-side mechanisms. Design the entire system around this constraint from the start.

Multi-device sync is a read-position synchronization problem. Every device needs to know the user’s read position in every conversation. Storing read_pos:{conversation_id}:{user_id} as a single Redis key makes this O(1) to update and propagate.

Offline delivery via Kafka decouples availability from real-time routing. If the recipient is offline, the message is not lost: it lives in Kafka until delivery is possible. The routing layer (Redis) and the persistence layer (Kafka/Cassandra) serve different failure modes and can recover independently.

References