TL;DR

1. The problem: why is messaging different from everything else?

Almost everything else in Instagram is client-initiated: the app asks ("give me my feed", "like this post") and the server answers. Direct messages break that model. When your friend sends you "are you coming?", nobody on your side asked for it — the server has to reach your phone on its own, within a fraction of a second, while you're looking at a chat screen.

That single difference creates a cascade of hard problems:

🧠 Analogy Think of a huge hotel switchboard. Every guest who's in their room has a phone line kept open to the front desk (the persistent connection). The desk has many operators (gateway servers), each handling thousands of lines, and a room directory on the wall saying which operator holds which guest's line (the session registry). When a message comes in for room 512, the desk first writes it in the logbook (persistence), checks the directory, and the right operator reads it down the open line. If the guest is out, the message stays in the logbook, a note is slipped under their door (push notification), and when they return they ask "anything since my last message, number 41?" (sync from a cursor).

2. Requirements and numbers

2.1 Functional requirements

2.2 Non-functional requirements

2.3 Back-of-the-envelope (assumptions)

Assume 300M daily DM users, 40 messages sent per user per day
  → 12B messages/day → 12B / 86,400 ≈ 140,000 msgs/s average, ~400,000/s peak
  → deliveries are higher: a message to a 10-person group = 9 deliveries

Storage: ~300 bytes per message row (text + IDs + metadata; media stored separately in S3)
  → 12B × 300 B ≈ 3.6 TB/day ≈ 1.3 PB/year, ×3 replicas ≈ 4 PB/year

Concurrent connections at peak: assume ~150M online devices
  Each gateway server holds ~500k connections (tuned event-loop server)
  → 150M / 500k = 300 gateway servers (+ headroom for failures → ~400-500)

Heartbeats: 150M connections pinging every 30 s → 5M tiny frames/s
  (this is why heartbeats are handled at the gateway and never touch a database)

Notice the two very different workloads: a connection-holding tier (huge numbers of mostly idle sockets, low CPU) and a message-processing tier (hundreds of thousands of writes per second). Separating them is the first architectural decision.

3. Real-time transport: how can the server reach the phone?

HTTP is request/response: the client speaks first, the server answers, done. There are four classic ways to get "server push" anyway, plus MQTT.

 SHORT POLLING                         LONG POLLING
 client          server                client          server
   |-- any new? ----->|                  |-- any new? ----->|
   |<--- no ---------|                  |                  |  (server HOLDS the request
   |   ...wait 5 s... |                  |                  |   until data or ~30 s timeout)
   |-- any new? ----->|                  |<--- msg #1 ------|
   |<--- no ---------|                  |-- any new? ----->|  (immediately re-asks)
   |-- any new? ----->|                  |                  |
   |<--- msg #1 -----|  up to 5 s late   |<--- msg #2 ------|

 SERVER-SENT EVENTS (SSE)              WEBSOCKET
 client          server                client          server
   |-- GET /events -->|                  |-- HTTP Upgrade ->|
   |<== 200, stream open, never ends    |<-- 101 Switching-|
   |<--- event: msg #1                  |<=== full-duplex frames ===>|
   |<--- event: msg #2                  |--- send msg ---->|
   |  (one direction only: server→client) |<--- msg #1 ------|
   |  client sends via separate POSTs     |<--- typing… -----|

3.1 The options compared

Short pollingLong pollingServer-Sent EventsWebSocketMQTT (over TCP/TLS or WebSocket)
How it worksClient asks every N secondsServer holds each request open until data arrives, then client re-asksOne HTTP response that streams events foreverHTTP request "upgraded" to a persistent, bidirectional frame channelLightweight publish/subscribe protocol over a persistent connection
DirectionClient → server onlySimulated pushServer → client onlyBoth waysBoth ways (pub/sub topics)
LatencyUp to the poll intervalNear real-time (one RTT after each delivery)Real-timeReal-timeReal-time
OverheadVery high: mostly empty responses, full HTTP headers each timeMedium: one request per message batchLowVery low: 2–14 byte frame headersVery low: 2-byte minimum header; designed for flaky mobile networks and battery
Battery (mobile)Bad — radio wakes constantlyOKGoodGoodVery good
InfrastructurePlain HTTP, trivially scalableMany hanging requests; timeouts at proxiesPlain HTTP; some proxies buffer streamsNeeds WebSocket-aware LBs and stateful gatewaysNeeds an MQTT broker/gateway tier
Good forRare updates, simplest fallbackFallback when WebSockets are blockedLive feeds, notifications, dashboardsChat, games, collaborationMobile messaging, IoT
💡 Push notifications are not a transport APNs/FCM push notifications wake the app or show a banner when it's not connected; they're best-effort, rate-limited and may be delayed or collapsed. They complement the persistent connection for offline devices — they never replace it (see Notifications).

4. Gateway servers: holding millions of connections

A gateway (also called connection server, edge server or "front-end") is a server whose main job is to keep sockets open and shuttle frames between devices and the backend. It contains no business logic, which keeps it simple, stable and rarely redeployed.

📱 Alicesender 📱 Bobrecipient Gateway 12~500k open WebSockets Gateway 47~500k open WebSockets Chat Servicesequence · persist · route Session registryRedis: user → gateway Messages storeCassandra / HBase Kafkamessage events Notification SvcAPNs / FCM if offline ① send ④ deliver ③ lookup ② persist persistent connection
Gateways hold connections; the stateless Chat Service persists, looks up where the recipient is connected, and routes the message to that gateway.

4.1 How one server holds 500,000+ connections

4.2 Load balancing long-lived connections

A load balancer spreads new connections across gateways (least-connections works well). But connections live for hours, so the usual "add servers and traffic rebalances" doesn't happen by itself:

5. The session registry: who is connected where?

When Alice's message for Bob arrives, the Chat Service must know: is Bob online, on which devices, and which gateway holds each device's connection? That mapping is the session registry, typically a Redis cluster (fast, in-memory, with TTLs).

# Bob's phone connects to gateway 47 (connection id c-88123)
HSET   sess:bob  phone-7f3a  "gw47|c-88123|1730000000"
EXPIRE sess:bob  90                    # refreshed by every heartbeat; auto-expires if gateway dies

# Bob's laptop connects to gateway 12
HSET   sess:bob  web-21cc    "gw12|c-55102|1730000004"

# Chat Service routing a message to Bob
HGETALL sess:bob
→ phone-7f3a: gw47|c-88123|…   web-21cc: gw12|c-55102|…   → deliver to BOTH gateways

# Clean disconnect
HDEL   sess:bob  phone-7f3a

6. Sending a message: the full sequence

Here is the complete path of one message from Alice (connected to gateway 12) to Bob (connected to gateway 47), including every acknowledgement:

 Alice app      Gateway 12      Chat Service        Messages store    Session registry   Gateway 47     Bob app
    |                |                |                     |                 |               |              |
    | 1. SEND {conv:c9, client_msg_id:"a1b2", text:"pizza?"} (shown locally with a 🕓 clock)    |              |
    |--------------->|                |                     |                 |               |              |
    |                |-- 2. forward (+ authenticated user=alice) -->|          |               |              |
    |                |                | 3. check alice ∈ c9, not blocked, rate limit, size    |              |
    |                |                | 4. dedup: seen client_msg_id "a1b2" before? → no      |              |
    |                |                | 5. assign msg_id (time-ordered) + seq=42 for c9       |              |
    |                |                |-- 6. write row ---->|                 |               |              |
    |                |                |<-- ok (quorum) -----|                 |               |              |
    |                |<-- 7. ACK {client_msg_id:"a1b2", msg_id, seq:42} ---  |               |              |
    |<-- SENT ✓ -----|                |                     |                 |               |              |
    |                |                |-- 8. publish msg event to Kafka (inbox update, notif, analytics)     |
    |                |                |-- 9. HGETALL sess:bob --------------->|               |              |
    |                |                |<-- phone-7f3a @ gw47 -----------------|               |              |
    |                |                |-- 10. deliver(conn c-88123, msg) --------------------->|              |
    |                |                |                     |                 |               |-- 11. push ->|
    |                |                |                     |                 |               |<- 12. ACK ---|
    |                |                |<-- 13. delivered(bob, c9, seq 42) ---------------------|  (stores msg,|
    |                |                |   update delivered watermark          |               |   dedups by  |
    |                |<-- 14. receipt: delivered up to seq 42 --------------  |               |   msg_id)    |
    |<-- DELIVERED ✓✓|                |                     |                 |               |              |
    |                |                |                     |                 |               |  Bob opens chat
    |                |                |<-- 15. read(bob, c9, up to seq 42) ---------------------------------- |
    |<-- SEEN -------|<---------------|   update read watermark, relay receipt to alice's devices            |

Important details hidden in those steps:

7. Storing messages

7.1 Access patterns decide the model

Chat storage is overwhelmingly write-heavy and read in one specific way: "the latest N messages of conversation X, then the N before that as I scroll up", plus "everything in X after sequence S" for sync. Messages are almost never updated (except edits, unsends and reactions). That's a perfect fit for a wide-column store (see Databases) built on LSM-trees, which turns random writes into sequential ones (see Indexing).

-- Cassandra-style schema (CQL)
CREATE TABLE messages (
  conversation_id  bigint,
  bucket           int,        -- e.g. month number: bounds partition size
  message_id       bigint,     -- 64-bit time-ordered ID (Snowflake/Instagram-style)
  seq              bigint,     -- per-conversation sequence number
  sender_id        bigint,
  client_msg_id    uuid,       -- sender-generated idempotency key
  type             text,       -- text | media | post_share | reaction | system
  body             blob,       -- text, or ciphertext if E2EE
  media_ref        text,
  PRIMARY KEY ((conversation_id, bucket), message_id)
) WITH CLUSTERING ORDER BY (message_id DESC);

-- "Load the chat": newest 30 messages
SELECT * FROM messages WHERE conversation_id = 9 AND bucket = 658 LIMIT 30;
-- "Scroll up": 30 older than the oldest I have
SELECT * FROM messages WHERE conversation_id = 9 AND bucket = 658
  AND message_id < 7234011234567890 LIMIT 30;

7.2 The inbox (conversation list)

The DM inbox shows your conversations sorted by latest activity with a preview and unread badge. That's a different access pattern ("all conversations for user U, newest first"), so it gets its own table, maintained asynchronously from the Kafka message events:

user_inbox: PRIMARY KEY (user_id, last_activity_ts DESC, conversation_id)
            → last message preview, unread count, muted flag
conversation_members: PRIMARY KEY (conversation_id, user_id) → role, joined_at, read/delivered watermarks

Because Cassandra can't "update the sort key in place", moving a conversation to the top means delete-old-row + insert-new-row; many systems instead keep the inbox in Redis sorted sets (score = last activity) backed by the durable table.

7.3 Why HBase / Cassandra?

Property neededWide-column store (Cassandra / HBase)Sharded relational DB
~400k writes/s, growing forever✅ LSM-tree writes are sequential and cheap; scale by adding nodes⚠️ Possible with many shards, but B-tree write amplification and manual resharding
"Latest N in a conversation"✅ One partition, pre-sorted by clustering key✅ Index on (conversation_id, message_id)
Multi-row transactions❌ Limited (single-partition only)✅ Full ACID
Multi-datacenter replication✅ Built in (Cassandra)⚠️ Usually leader-based, more operational work
Petabytes of mostly cold data✅ Compaction + tiered storage friendly⚠️ Expensive
📸 Public precedent Facebook publicly explained (2010) that it chose HBase to store the new Facebook Messages system because of its high write throughput and good handling of a small, ever-growing working set; Facebook later wrote (2018) about migrating Messenger's storage to a MySQL/MyRocks-based system (also an LSM-tree engine). Instagram has publicly described running large Cassandra clusters. Either way the shape is the same: partition by conversation, sort by time-ordered ID, LSM storage.

8. Ordering: everyone sees the same sequence

8.1 Why timestamps alone fail

8.2 Per-conversation sequence numbers

The robust solution is a monotonic sequence number per conversation: 1, 2, 3, … assigned by the server at write time. It gives a total order within a conversation (which is all users can perceive) while different conversations proceed fully in parallel — no global ordering bottleneck.

Who assigns seq without races?

ApproachHowTrade-off
Single writer per conversation ⭐Route all sends for conversation c9 to one owner (e.g. hash(conversation_id) → a Chat Service shard, or a Kafka partition keyed by conversation_id consumed by one worker). The owner increments an in-memory counter, persisted with each message.Simple, fast, strictly ordered. Needs failover: a new owner must read the last seq from storage before continuing.
Atomic counterINCR seq:c9 in Redis before writing the message.Easy, but the counter becomes a durability concern (lost on failover without persistence) and every message needs an extra round trip.
Conditional writeCompare-and-set / lightweight transaction: insert seq = last+1 only if it doesn't exist.Correct, but consensus-based writes (e.g. Cassandra LWT/Paxos) are several times slower.

On the client side:

9. Delivery guarantees: at-least-once plus dedup

Networks offer no "exactly-once delivery". A send can fail in three indistinguishable ways: the request was lost, the server crashed after processing, or the ACK was lost. The client can't tell which — so it must retry, and retries create duplicates. The standard recipe (see delivery guarantees and idempotency keys):

  1. The client generates a unique client_msg_id (a UUID) when the user hits send, and stores the message in a local outbox.
  2. It sends and waits for an ACK; on timeout it retries with the same client_msg_id (with backoff), across reconnects and even app restarts.
  3. The server keeps a dedup record (sender, client_msg_id) → (msg_id, seq) for a period (e.g. 24 h in Redis, or a unique index in the store). A duplicate send simply returns the original ACK — idempotent.
  4. Server → recipient delivery is also at-least-once: the gateway pushes, waits for the device's ACK, and the backend relies on sync to fill anything un-acked. The recipient dedups by msg_id (a local unique index) before displaying.
// client-side outbox (simplified)
function send(convId, text) {
  const msg = { clientMsgId: uuidv4(), convId, text, state: "PENDING" };
  localDb.outbox.insert(msg);  ui.append(msg);
  trySend(msg);
}
function trySend(msg, attempt = 0) {
  socket.send({ type: "SEND", ...msg });
  timers.set(msg.clientMsgId, setTimeout(
    () => trySend(msg, attempt + 1),
    Math.min(30000, 1000 * 2 ** attempt) * (0.5 + Math.random())));  // backoff + jitter
}
socket.on("ACK", ({ clientMsgId, msgId, seq }) => {
  clearTimeout(timers.get(clientMsgId));
  localDb.outbox.markSent(clientMsgId, msgId, seq);    // ✓ sent
});

The combination — at-least-once transport + idempotent processing + dedup on display — gives users the experience of exactly-once, which is the only kind of exactly-once real distributed systems can offer.

10. Offline users, push and sync

Most of the time the recipient is not connected: the app is closed or the phone is asleep. The design must treat offline as the normal case.

  1. Store: the message is already durable in the messages store (step 6 above). Nothing is "queued for Bob" separately — the conversation log is the queue.
  2. Notify: the Notification Service, consuming the message event from Kafka, sends a push via APNs/FCM: "Alice: pizza?" (or a generic "New message" when content must stay private). Pushes for the same conversation are collapsed so 20 messages don't produce 20 banners.
  3. Reconnect: when Bob opens the app, it establishes a connection, registers in the session registry, and immediately syncs.
  4. Sync from a cursor: the device sends the last position it has fully processed; the server returns everything after it, in order, in pages.

Two common cursor designs:

DesignHow sync worksPros / cons
Per-conversation cursorsDevice sends {c9: 41, c12: 1007, …}; server returns messages with seq greater than each.Simple; but a user in 500 conversations must check 500 cursors, most unchanged.
Per-user update log ⭐Every event relevant to a user (new message, receipt, reaction, conversation renamed) is appended to that user's own ordered log with a user-level sequence. Device sends one number: "I have up to 88,214."One cheap range read per sync, covers every event type; costs an extra write per recipient (fan-out on write). Facebook has publicly described a per-user ordered queue design ("Iris") for Messenger sync.
GET /v1/sync?cursor=88214&limit=200
→ { "events": [
      {"useq": 88215, "type": "message", "conv": "c9", "seq": 42, "from": "alice", "text": "pizza?"},
      {"useq": 88216, "type": "read",    "conv": "c12", "user": "carol", "up_to_seq": 1007},
      {"useq": 88217, "type": "reaction","conv": "c9", "seq": 42, "emoji": "❤️", "from": "dan"} ],
    "next_cursor": 88217, "has_more": false }

This same mechanism solves multi-device sync: each device keeps its own cursor into the same per-user log, so the phone and laptop independently catch up to the same state. A device offline for months may get a "cursor too old" response and do a full resync of recent conversations instead.

11. Receipts: sent, delivered, seen

🕓 Pendingin local outbox
→server ACK
✓ Sentdurably stored
→device ACK
✓✓ Deliveredon recipient device
→chat opened
👁 Seenread watermark

A naive design stores a status row per message per recipient and updates it twice. For a 100-member group that's 200 writes per message — far more than the message itself. The efficient design uses watermarks:

12. Presence and typing indicators

12.1 Presence via heartbeats

Presence = is the user online now, or when were they last active? The gateway already knows when a connection opens, closes or goes silent, so presence is derived from connection state plus heartbeats:

# on connect and on every heartbeat (~30 s), the gateway batches updates:
SET presence:bob  "online"  EX 70          # expires if 2 heartbeats are missed
SET last_active:bob 1730000123             # for "Active 5m ago"

# a viewer's inbox shows 20 conversations → one batched read:
MGET presence:alice presence:carol presence:dan ...

12.2 Typing indicators

"Alice is typing…" is the most ephemeral signal of all:

💡 Classify every signal by its guarantees Messages: durable, ordered, at-least-once, synced. Receipts: durable watermarks, monotonic, coalesced. Presence: in-memory, TTL'd, pulled on demand. Typing: fire-and-forget. Giving each signal only the guarantees it needs is what keeps the system affordable.

13. Group conversations and fan-out

A message to a group of n members needs n − 1 deliveries (times the number of devices each). The question — just like in the news feed — is where to fan out.

Shared conversation log (write once) ⭐Per-member copies (write diffusion)
Storage write1 row in messages for the conversationn rows, one in each member's mailbox
Reading historyRead the conversation partitionRead your own mailbox
Per-member workSmall: inbox pointer/unread count + user update-log entry + delivery to online devicesFull message copy per member
Edits, unsend, reactionsChange one rowChange n rows
Best forGroups of any size; the defaultSmall groups where per-user mailboxes simplify sync (some systems do this)

Delivery itself always fans out: the Chat Service looks up the members (cached conversation_members), batch-reads their sessions from the registry (one pipelined call), groups the target devices by gateway, and sends one batched RPC per gateway containing all of its recipients. For a 250-member group with ~1.5 devices online each, that's ~370 device pushes but only a few dozen RPCs. Offline members get a (collapsed, possibly muted) push notification and pick the message up on sync.

14. End-to-end encryption (E2EE) basics

With normal (transport) encryption, TLS protects messages on the wire, but the server decrypts and stores them — so the company (or an attacker who breaches it) could read them. With end-to-end encryption, only the participants' devices hold the keys; the server stores and forwards ciphertext it cannot read.

14.1 The Signal protocol idea

The Signal protocol, used by Signal and WhatsApp and adopted by Meta for Messenger and Instagram DMs, combines two pieces:

  1. Key publication: each device generates a long-term identity key pair, a signed prekey and a batch of one-time prekeys, and uploads only the public halves to the server's key directory. Private keys never leave the device.
  2. X3DH (initial key agreement): to start a chat with Bob even while he's offline, Alice fetches Bob's public "prekey bundle" and combines several Diffie-Hellman calculations (her identity and a fresh ephemeral key with his identity, signed prekey and a one-time prekey). Both sides can derive the same shared secret, but the server — which only saw public keys — cannot.
  3. Double Ratchet (ongoing messages): every message is encrypted with a new key derived by "ratcheting" a chain forward, mixed with fresh Diffie-Hellman exchanges as replies flow. Stealing today's key doesn't decrypt past messages (forward secrecy), and the conversation heals after a compromise once new DH exchanges happen (post-compromise security).
 Bob's device                    Server (relay + key directory)                 Alice's device
 gen IK_B, SPK_B, OPK_B[1..100]
 ── upload PUBLIC keys ─────────► stores IK_B, SPK_B, OPK_B…
                                 ◄──────────────────── fetch Bob's bundle ───── (first message)
                                                         X3DH: DH(IK_A,SPK_B), DH(EK_A,IK_B),
                                                               DH(EK_A,SPK_B), DH(EK_A,OPK_B) → secret
                                 ◄─── ciphertext + EK_A(public) + OPK id ────── Double Ratchet: msg key #1
 ◄── relay ciphertext ──────────  (sees: sender, recipient, time, size —
 same DH math → same secret        NOT the content)
 decrypt; ratchet forward

14.2 What E2EE changes in the system design

15. Scaling: the numbers end to end

ComponentLoad (assumptions from section 2.3)How it scales
Gateways~150M concurrent connections; ~5M heartbeats/s~300–500 servers at ~300–500k connections each; add servers, drain gracefully; regional deployment near users
Chat Service~400k sends/s peak, plus receipts (often more than messages)Stateless instances behind internal LB; conversation-affinity routing for sequencing; scale horizontally
Session registry~1M+ lookups/s (one per recipient), writes on connect/disconnectRedis cluster sharded by user_id, replicas; TTL-based cleanup
Messages store~400k writes/s; ~3.6 TB/day raw; ~4 PB/year with replicationCassandra/HBase cluster of hundreds of nodes; partition by conversation; tiered/cold storage for old buckets
Per-user update log / inboxWrites = deliveries (×group size)Partition by user_id; trim old entries (devices that fall too far behind do full resync)
KafkaMessage events for notifications, inbox, analytics, search-of-own-inbox (if not E2EE)Partition by conversation_id; consumer groups per downstream
Push notificationsTens of thousands/s, collapsed per conversationNotification Service → APNs/FCM with batching and rate limits

Latency budget for an online-to-online message (assumptions): client → gateway ~30–80 ms (mobile network), gateway → Chat Service ~1 ms, persist with quorum ~5–10 ms, registry lookup ~1 ms, route to recipient gateway ~1–2 ms (same region), gateway → device ~30–80 ms. Total ≈ 70–180 ms; cross-region conversations add an inter-region hop (~50–150 ms).

Multi-region: users connect to the nearest region's gateways. A conversation needs one home for sequencing (e.g. the region of its creator or a consistent hash), and cross-region delivery goes through region-to-region links; the message store replicates across regions for disaster recovery (see Multi-region).

16. How Instagram uses real-time messaging

  1. When the app is open, it keeps a persistent connection through the load balancer to a gateway (the diagram's "Direct Messaging (WebSocket)" service).
  2. Sending a DM goes over that connection to the Direct Messaging service, which validates, sequences and persists it in the Messages Store (Cassandra/HBase), then routes it via the session registry to the recipient's gateway.
  3. A Kafka event drives the inbox update, analytics, and the Notification Service → APNs/FCM push for offline recipients.
  4. Photos, videos and voice notes go to Object Storage via pre-signed URLs and are served by the CDN; shared posts and Reels are sent as references (IDs), hydrated like feed posts.
  5. On reconnect, the app syncs from its cursor, so the phone, tablet and web client converge on the same history and read state.
📸 In Instagram Instagram Direct launched in 2013. In 2020 Meta publicly announced cross-app messaging between Instagram and Messenger, which implies the two share messaging infrastructure. Facebook's public engineering posts describe Messenger's evolution: MQTT persistent connections on mobile (2011), HBase message storage (2010), a per-user ordered sync queue called Iris (2014), a move of storage to MySQL/MyRocks (2018), and Signal-protocol-based E2EE rolled out for Messenger and Instagram DMs. Exact internals and numbers are not public; the design in this lesson is consistent with those public descriptions and with how other large chat systems (WhatsApp, Discord, Slack) describe theirs.

17. Trade-offs & common pitfalls

✅ Persistent connections + durable log + sync: benefits

  • Sub-second delivery to online users
  • No message loss: persist-before-ack, sync fills every gap
  • No visible duplicates thanks to client IDs and dedup
  • Same order on every device via per-conversation seq
  • Multi-device and offline handled by one mechanism (cursor sync)
  • Gateways are simple and stable; logic lives in stateless services

⚠️ Costs

  • Hundreds of stateful gateway servers and reconnect-storm risk
  • Session registry is a soft-state dependency that can be stale
  • Per-conversation sequencing needs single-writer routing and failover care
  • Receipts and presence can generate more traffic than messages
  • E2EE removes server-side search, previews and content moderation
  • Multi-region conversations add latency and complexity

Common pitfalls

18. Real-world technologies

TechnologyRoleWhen to use it
WebSocket servers (Netty, Go, Node.js ws, Elixir Phoenix)Gateway tier holding persistent connectionsBrowser and app chat; the default choice
MQTT brokers (EMQX, HiveMQ, Mosquitto)Pub/sub over persistent connections, small headers, QoS levelsMobile messaging on poor networks, IoT
Erlang/Elixir (BEAM)Massive concurrency with lightweight processesConnection-heavy systems (WhatsApp, Discord publicly use it)
RedisSession registry, presence with TTLs, dedup keys, inbox sorted sets, pub/subSoft, fast, in-memory state
Cassandra / ScyllaDB / HBaseDurable message history partitioned by conversationHigh write throughput, time-ordered data (Discord publicly moved from Cassandra to ScyllaDB)
KafkaMessage events to notification, inbox and analytics consumersDecoupled asynchronous side effects
APNs / FCMOS push notifications to offline devicesWaking or alerting devices without a live connection
libsignalSignal protocol implementation (X3DH, Double Ratchet)Adding E2EE
Managed: Firebase Realtime DB/Firestore, Pusher, Ably, AWS AppSync / API Gateway WebSocketsHosted real-time messaging infrastructureSmall and medium apps that don't want to run gateways

19. Interview corner

Design Instagram Direct. Walk me through sending a message.

Online devices keep a WebSocket/MQTT connection to a gateway; the gateway registers user/device → gateway in a Redis session registry. Alice sends {conversation_id, client_msg_id, body}; the gateway forwards to the stateless Chat Service, which authorises, dedups on client_msg_id, assigns a time-ordered msg_id and a per-conversation seq (single writer per conversation), writes to Cassandra (partition conversation_id+bucket, clustered by msg_id), then ACKs Alice ("sent"). It publishes a Kafka event (inbox, notifications, analytics), looks up Bob's devices in the registry and pushes to their gateways; Bob's device ACKs ("delivered") and later sends a read watermark ("seen"). If Bob is offline, he gets an APNs/FCM push and syncs from his cursor on reconnect.

WebSockets vs long polling vs SSE — which and why?

WebSockets (or MQTT on mobile): full-duplex, tiny frame overhead, real-time in both directions — exactly what chat with typing indicators and receipts needs. Long polling works through any proxy and is a good fallback, but costs a full HTTP request per delivery and hanging requests at every hop. SSE is server→client only, so sends need separate HTTP calls; good for notification streams, not interactive chat. Short polling wastes enormous capacity and still adds seconds of latency.

How do you guarantee messages are not lost and not duplicated?

No loss: persist to a replicated store before ACKing the sender; the client keeps unacked messages in a local outbox and retries; recipients get messages either by push or by cursor-based sync from the durable log, so a failed push is never a lost message. No duplicates: the client attaches a UUID client_msg_id reused across retries; the server dedups (sender, client_msg_id) and returns the original ACK; recipients dedup by msg_id before display. That's at-least-once delivery with idempotent processing — effectively exactly-once to the user.

How do you order messages in a group chat?

A per-conversation monotonically increasing sequence number assigned by a single writer for that conversation (route by hash of conversation_id to an owner; on failover the new owner reads the last seq from storage). Every member orders by seq, so everyone sees the same order; clients detect gaps (got 45, have 43) and fetch the missing range via sync. Client timestamps are never used for ordering; time-ordered IDs alone aren't strictly ordered across servers.

A gateway server with 400k connections crashes. What happens?

Those clients detect the broken connection (TCP reset or missed heartbeat) and reconnect with exponential backoff + jitter, landing on other gateways via the load balancer. Registry entries for the dead gateway expire via TTL (or are cleaned by a health monitor). Messages routed to the dead gateway in the meantime fail to deliver but are already persisted; each reconnecting client syncs from its cursor and receives them. Backend sync capacity must be rate-limited to absorb the burst; no data is lost.

How would you implement presence for hundreds of millions of users?

Derive it from connections: gateways batch heartbeat updates into Redis keys with TTL (~60–70 s) plus a last_active timestamp. Don't push presence changes to all contacts; clients fetch presence for visible conversations (batched MGET) and subscribe to changes only while a chat or inbox is on screen. Debounce flapping by waiting for TTL expiry before showing offline. Keep it in memory only; it's approximate by design and respects privacy settings.

Why Cassandra/HBase for messages, and what's the key design?

The workload is write-heavy, append-only, time-ordered, enormous, and read as "latest N of a conversation" or "everything after seq S" — ideal for an LSM-based wide-column store that scales horizontally with built-in replication. Partition key (conversation_id, time bucket) keeps a conversation together and bounds partition size; clustering key message_id DESC stores rows pre-sorted for newest-first reads. Facebook publicly used HBase for Messages. The inbox (per-user conversation list) is a separate table/sorted set because it's a different access pattern.

What changes if we add end-to-end encryption?

Devices generate key pairs and upload public prekeys; senders run X3DH to establish a shared secret and Double Ratchet to derive per-message keys (forward secrecy). The server stores and routes ciphertext only. Each device of each participant needs its own encrypted copy (or sender keys for groups), so client-side fan-out grows. We lose server-side message search, link previews and content scanning; history on new devices needs encrypted backup. Ordering, receipts, storage and sync stay the same — they operate on metadata.

20. Check your understanding

150M devices short-poll every 5 seconds. How many requests per second is that, and what fraction is wasted if a device receives a message on average once every 10 minutes?

150M / 5 = 30M requests/s. A device polls 120 times per 10 minutes and gets data in ~1 of them, so ~99% of requests return nothing — and messages still wait up to 5 s. This is why persistent connections are used.

Why does the Chat Service persist the message before sending the ACK to the sender, rather than after delivering to the recipient?

The ACK is a promise that the message won't be lost. Persisting first makes that true even if the service crashes immediately after. Waiting for delivery would make sending as slow as the recipient's network — and impossible when the recipient is offline. Delivery is a separate, retriable step backed by sync.

Bob's phone received messages with seq 41, 42 and 44 in conversation c9. What should it do?

Display 41 and 42; hold 44 (or display it with a gap) and request the missing range via sync (seq > 42). Seq 43 may still be in flight or may have been lost on a broken connection; the sync from the durable log guarantees it arrives, and then 43 and 44 are shown in order.

Alice taps send in a tunnel; the request reaches the server and is stored, but the ACK is lost. She retries 3 times. How many messages does Bob see, and why?

One. All retries carry the same client_msg_id; the server finds (alice, client_msg_id) already processed and returns the original msg_id/seq without storing a new row. Even if a duplicate delivery reached Bob, his app dedups by msg_id.

A group has 200 members and receives 1,000 messages a day. Compare receipt writes for per-message status rows vs watermarks if each member reads the chat 5 times a day.

Per-message rows: 1,000 messages × 199 recipients × 2 statuses ≈ ~400,000 writes/day. Watermarks: each member advances read (and delivered) watermarks a handful of times per session — e.g. 200 members × 5 reads × ~2 updates ≈ ~2,000 writes/day. Roughly 200× fewer.

Which of these should be persisted durably: (a) a message, (b) a typing indicator, (c) a read receipt, (d) "Active now"?

(a) yes — the source of truth. (c) yes, as a compact watermark, so read state syncs across devices. (b) no — fire-and-forget relay. (d) no — in-memory with a TTL; only the coarse last_active timestamp might be persisted.

Why should gateways contain no business logic?

Every gateway deploy disconnects hundreds of thousands of clients, causing reconnect storms and sync load. Keeping gateways as dumb connection holders means they rarely change, while logic (validation, sequencing, routing) lives in stateless services that can be redeployed freely without dropping connections.

With 500k connections per gateway at ~30 KB each, how much RAM does connection state need, and how many gateways for 150M connections with 30% headroom?

500k × 30 KB ≈ 15 GB per server. 150M / 500k = 300 servers; with 30% headroom (to absorb a failed server's or zone's reconnects) ≈ ~390–400 gateways.

🎯 Key takeaways

Related lessons