Chat and presence
A messaging app with one-to-one and group chats, delivery and read receipts, and who is online. The hard parts are routing to the right gateway, ordering, and losing nothing when a gateway dies.
- 1Each device holds a WebSocket to a gateway. A Redis registry says which gateway holds which device; gateways pass frames over Pub/Sub.
- 2Store first, then deliver. Each conversation numbers its messages under its row lock, and a client message ID makes a retry harmless.
- 3Delivery is best effort; the sequence number is the truth. A device that missed messages syncs after the last number it showed.
- 4Presence is a lease: a TTL key that heartbeats renew. Fetch it only for the people on screen.
"Design a messaging app: one-to-one and group chat, delivery receipts, and who is online."
| question | answer assumed |
|---|---|
| How many users? | 500 million daily; 30% online at peak. |
| Messages? | 40 a day per user, text up to 100 bytes on average. |
| Group size? | Up to 500 members. Larger channels are a follow-up. |
| Devices? | Several per user. Each shows the same history and read state. |
| History? | Kept on the server; a new device syncs it. |
| Offline users? | Get a push notification, then the messages on the next connect. |
| Encryption? | End to end is a follow-up; the server stores ciphertext then. |
I confirm group size, devices per user, how long history lives, and whether the server may read messages.
| functional |
|---|
| Send to a person or a group; see it on every device. |
| Delivered and read receipts. |
| Online or last seen for a contact. |
| History and catch-up after offline time. |
| non-functional | target |
|---|---|
| Send to delivery, both online | p99 < 300 ms |
| Acked messages lost | 0 |
| Order | The same per conversation on every device |
| Duplicates shown | 0 |
| Presence staleness | ≤ 1 TTL (60 s) |
Nothing acked may be lost, and every device must show a conversation in the same order.
| quantity | arithmetic | result |
|---|---|---|
| Messages | 500 M × 40 a day = 20 B ÷ 86,400 | ≈ 231,000/s; 3× peak ≈ 694,000/s |
| Open sockets | 500 M × 30% online | 150 M |
| Gateways | 150 M ÷ 100 k sockets each (a budget to load test) | ≈ 1,500 |
| Storage | 20 B × 100 B a day | 2 TB a day ≈ 730 TB a year, one copy |
| Heartbeats | 150 M ÷ 30 s | 5 M renewals/s, batched per gateway |
| Presence, push to all friends | 500 M × 10 changes × 200 friends ÷ 86,400 | ≈ 11.6 M events/s |
| Presence, lazy | 500 M × 10 changes × 2 viewers ÷ 86,400 | ≈ 116,000 events/s |
| Message stores | 231,000/s ÷ 4,400/s measured per node | ≈ 53 Postgres shards |
Inputs are the assumed answers in panel A. The per-node rate is the lab's one-to-one case on one laptop, so the shard count is an order of magnitude. Method: R2.
About 231,000 messages a second and 150 million open sockets at peak. Presence is the biggest number on the board, so I fetch it lazily.
| frame | direction | carries | answer |
|---|---|---|---|
| send | device → gateway | conv, client_msg_id, body | ack: seq, dup |
| msg | gateway → device | conv, seq, from, body | recv receipt |
| recv / read | device → gateway | conv, seq (a high-water mark) | receipt to others |
| sync | device → gateway | conv, after seq | msg ..., synced |
| hb | device → gateway | nothing; every 30 s | none |
| watch | device → gateway | users on screen | presence frames |
| call | success | errors |
|---|---|---|
| GET /v1/conversations/{id}/messages?after=&limit= | 200 + messages | 403, 404 |
| POST /v1/conversations {members} + Idempotency-Key | 201 + id | 400, 422 |
| POST /v1/devices {push_token} | 201 | 400 |
| GET /ws?device= (upgrade) | 101 | 401, 426 |
- The client makes the client message ID once per message and reuses it on every retry.
- A sequence number in the ack lets the sender place its own message in the right position.
Real-time traffic goes over one WebSocket as small JSON frames. History, groups and push tokens use plain HTTP.
| Redis key | holds | lifetime |
|---|---|---|
| sess:{user} | Hash: device → gateway | TTL, renewed by heartbeats |
| presence:{user} | String: the gateway of the last heartbeat | TTL 60 s |
| gw:{id} | Pub/Sub channel of one gateway | While it runs |
| pres:{user} | Pub/Sub channel of one user's presence | While someone watches |
CREATE TABLE conversations (
id bigint PRIMARY KEY,
kind text NOT NULL CHECK (kind IN ('direct', 'group')),
last_seq bigint NOT NULL DEFAULT 01
);
CREATE TABLE members (
conv_id bigint NOT NULL REFERENCES conversations (id),
user_id text NOT NULL,
delivered_seq bigint NOT NULL DEFAULT 0,
read_seq bigint NOT NULL DEFAULT 0,
PRIMARY KEY (conv_id, user_id),
CHECK (read_seq <= delivered_seq)2
);
CREATE INDEX members_user ON members (user_id, conv_id);
CREATE TABLE messages (
conv_id bigint NOT NULL,
seq bigint NOT NULL,
sender text NOT NULL,
client_msg_id text NOT NULL,
body text NOT NULL,
sent_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (conv_id, seq)3,
UNIQUE (conv_id, sender, client_msg_id)4
) PARTITION BY HASH (conv_id)5;
CREATE TABLE messages_p0 PARTITION OF messages FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE messages_p1 PARTITION OF messages FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE messages_p2 PARTITION OF messages FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE messages_p3 PARTITION OF messages FOR VALUES WITH (MODULUS 4, REMAINDER 3);- 1The counter for this conversation. The row lock on it orders every send.
- 2Receipts are marks, one row per member. Read implies delivered.
- 3History of one conversation is one index range, in order.
- 4A retry finds its first copy. The key includes conv_id, so each partition can enforce it alone.
- 5All of a conversation lives in one partition; later, one shard.
Messages are keyed by conversation and sequence number and partitioned by conversation. Redis holds only state that a reconnect rebuilds: the registry and presence.
Step 1: Connect
- The device opens a WebSocket through the load balancer to any gateway.
- The gateway writes device → gateway into the registry hash of the user.
- It subscribes to its own channel, gw:{id}, once at start.
If it fails
The gateway dies: the socket drops and the device reconnects to another gateway. Old entries stay until a router finds them dead or the TTL ends.
The sender's gateway stores the message, finds the recipient's gateway in the registry, and publishes to it. A device that was away syncs from Postgres.
| tool | capability | what it gives this design | also used for |
|---|---|---|---|
| Redis | Hashes with a key TTL | The registry: all devices of a user in one key that expires without heartbeats. | Sessions, carts |
| Redis | SET with EX and GET | Renew presence and learn, in one command, whether the user was offline. | Leases, locks |
| Redis | Pub/Sub; PUBLISH returns the receiver count | Gateway to gateway delivery. A count of 0 means the gateway is dead. | Cache invalidation, live updates |
| Redis | Pub/Sub stores nothing | Limit A frame for a dead gateway is gone. Store first, then publish. | |
| Redis | Sharded Pub/Sub (SPUBLISH, Redis 7) | Channels spread over cluster shards instead of every node. | Large fan-out |
| Redis | Scripts | Remove a registry entry only if it still names the dead gateway. | Compare-and-delete |
| Postgres | UPDATE ... RETURNING under a row lock | A gap-free sequence number per conversation. | Invoice numbers, versions |
| Postgres | Unique constraint | One stored copy per client message ID. | Idempotency keys |
| Postgres | Hash partitioning | Conversations spread over partitions; one conversation stays in one. | Tenants, devices |
| Postgres | GREATEST in an UPDATE | Receipt marks only move forward, in any arrival order. | Watermarks, offsets |
| Gateway | WebSocket (RFC 6455) | One long-lived, two-way socket per device. | Live dashboards, games |
| APNs, FCM | Push notifications | Wake an offline device. The text stays on the server. | Alerts, reminders |
Redis gives me a registry with expiry and a channel per gateway. Postgres gives me a per-conversation counter under a row lock and a unique key for retries.
| method | status | why |
|---|---|---|
| One server holds every socket | One machine's sockets | No routing at all, until it is full or down. |
| Broadcast every message to every gateway | Not approved | Work grows with gateways × messages. |
| Registry in Redis, Pub/Sub channel per gateway | Approved | One lookup and one publish per gateway that holds a device. |
| Registry, then a direct call to the gateway | Gateways can reach each other | No broker in the path; needs a gateway address book. |
| Sticky routing: hash user to gateway | One device per user | A second device or a rebalance breaks the rule. |
| A durable queue per user | Small scale | Millions of queues. Postgres already keeps the history. |
- Gateways hold no message state. Any gateway can serve any device after a reconnect.
- The load balancer spreads new sockets; it needs no stickiness for chat.
route(user, frame):
devices = HGETALL sess:{user}1 // device -> gateway
FOR EACH gateway g holding some of them:
n = PUBLISH gw:{g}2 (user, devices, frame)
IF n == 0:3 // nobody subscribed: g is dead
remove g's entries4 for user from the registry
IF no gateway took it AND frame is a message:
queue a push notification // the message waits in Postgres5- 1One key per user holds every device and its gateway.
- 2One publish per gateway, carrying the list of devices on it. A group send costs members × gateways publishes.
- 3PUBLISH returns how many subscribers got it. A crashed gateway has none.
- 4A script deletes an entry only if it still names g, so a fresh reconnect is never removed.
- 5Stored before routing, so a failed route loses nothing.
Tested source Go: route
// route looks up where the user's devices are connected and publishes one envelope per gateway.
// A gateway that receives nothing is dead: its entries leave the registry. A user with no live
// device gets a push notification for a message; the message itself waits in Postgres.
func (g *Gateway) route(ctx context.Context, user, except string, f Frame) error {
devices, err := g.rdb.HGetAll(ctx, g.cfg.sessKey(user)).Result()
if err != nil {
return fmt.Errorf("registry %s: %w", user, err)
}
byGW := map[string][]string{}
for d, gw := range devices {
if d != except {
byGW[gw] = append(byGW[gw], d)
}
}
delivered := false
for gw, ds := range byGW {
slices.Sort(ds)
p, err := json.Marshal(envelope{User: user, Devices: ds, Frame: f})
if err != nil {
return fmt.Errorf("marshal envelope: %w", err)
}
n, err := g.rdb.Publish(ctx, g.cfg.gwChan(gw), p).Result()
if err != nil {
return fmt.Errorf("publish to %s: %w", gw, err)
}
g.Stats.Published.Add(1)
if n > 0 {
delivered = true
continue
}
g.Stats.DeadRoutes.Add(1)
args := append([]any{gw}, anys(ds)...)
if err := dropStale.Run(ctx, g.rdb, []string{g.cfg.sessKey(user)}, args...).Err(); err != nil {
return fmt.Errorf("drop stale entries of %s: %w", user, err)
}
}
if !delivered && f.T == "msg" && except == "" {
if err := g.rdb.RPush(ctx, g.cfg.pushKey(user), fmt.Sprintf("%d:%d", f.Conv, f.Seq)).Err(); err != nil {
return fmt.Errorf("queue push for %s: %w", user, err)
}
g.Stats.Pushes.Add(1)
}
return nil
}
I keep a registry of device to gateway in Redis and publish on the target gateway's channel. A publish that nobody receives tells me the gateway is dead.
send(device, conv, client_msg_id, body):
BEGIN
seq = conversations.last_seq + 1 WHERE id = conv // locks the row1
IF (conv, sender, client_msg_id) already stored2:
ROLLBACK; ACK the stored seq3 // a retry
insert (conv, seq, sender, client_msg_id, body)
COMMIT
ACK seq to the sender
FOR EACH member m, FOR EACH device of m except this one4:
route(m, message)- 1Every send to this conversation waits here in turn. The number has no gaps.
- 2The unique key (conversation, sender, client message ID). A retry ends here.
- 3The retry gets the first number back. The rollback returns the number it took.
- 4The sender's other devices get the message too, so every device shows the same history.
Tested source Go: send and store
// send stores the message with the next sequence number of its conversation, acks the sender,
// and routes the message to every device of every member. A retry with the same client message
// ID gets the first sequence number back and is not routed again.
func (g *Gateway) send(ctx context.Context, s *session, f Frame) error {
seq, dup, err := g.store(ctx, f.Conv, s.user, f.CMID, f.Body)
if err != nil {
return err
}
g.reply(s, Frame{T: "ack", Conv: f.Conv, CMID: f.CMID, Seq: seq, Dup: dup})
if dup {
return nil
}
msg := Frame{T: "msg", Conv: f.Conv, Seq: seq, From: s.user, CMID: f.CMID, Body: f.Body}
members, err := g.membersOf(ctx, f.Conv)
if err != nil {
return err
}
for _, m := range members {
except := ""
if m == s.user {
except = s.device // the sender's other devices get a copy
}
if err := g.route(ctx, m, except, msg); err != nil {
return err
}
}
return nil
}
// store takes the next sequence number under the conversation's row lock. The lock also orders
// two copies of one retry, so the second finds the first and reuses its number.
func (g *Gateway) store(ctx context.Context, conv int64, sender, cmid, body string) (seq int64, dup bool, err error) {
tx, err := g.db.Begin(ctx)
if err != nil {
return 0, false, fmt.Errorf("begin: %w", err)
}
defer func() {
if err != nil {
err = errors.Join(err, tx.Rollback(ctx))
}
}()
if err := tx.QueryRow(ctx, Q("next_seq"), conv, sender).Scan(&seq); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return 0, false, fmt.Errorf("%s is not in conversation %d", sender, conv)
}
return 0, false, fmt.Errorf("next seq: %w", err)
}
var first int64
switch err := tx.QueryRow(ctx, Q("find_sent"), conv, sender, cmid).Scan(&first); {
case err == nil:
return first, true, tx.Rollback(ctx) // a retry: give back the number just taken
case !errors.Is(err, pgx.ErrNoRows):
return 0, false, fmt.Errorf("find sent: %w", err)
}
if _, err := tx.Exec(ctx, Q("insert_message"), conv, seq, sender, cmid, body); err != nil {
return 0, false, fmt.Errorf("insert message: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return 0, false, fmt.Errorf("commit: %w", err)
}
return seq, false, nil
}
| order by | status | why |
|---|---|---|
| Device clock | Not approved | Phones' clocks are wrong by minutes. |
| Gateway timestamp | Not approved | Two gateways' clocks differ (X4). |
| Sequence per conversation | Approved | One counter, one order, gaps are visible. |
| One global sequence | Not approved | Every message in the system waits on one counter. |
| Time-ordered ID per message | Order across chats only | Sorts roughly by time, but a gap cannot be detected. |
on_message(conv, seq):
IF seq <= last[conv]: drop it1 // already shown
IF seq > last[conv] + 1:
hold it; sync(conv, after = last[conv]) // fill the gap2
ELSE:
show it, then every held seq that now fits
send a delivered receipt up to last[conv]3- 1A live copy and a sync copy of the same number: show it once.
- 2Two gateways can deliver 8 before 7. Hold 8 and fetch from the last shown number.
- 3One mark covers every message up to it.
Tested source Go: the device
func (c *Client) handle(f Frame) {
c.mu.Lock()
defer c.mu.Unlock()
defer c.notify()
switch f.T {
case "ack": // the sender's own copy takes its place by sequence number, like any other
c.acks[f.CMID] = append(c.acks[f.CMID], f)
sent := c.outbox[f.CMID]
c.onMsg(Frame{T: "msg", Conv: f.Conv, Seq: f.Seq, From: c.User, CMID: f.CMID, Body: sent.Body})
case "msg":
c.onMsg(f)
case "synced":
c.synced[f.CMID] = true
c.syncing[f.Conv] = false
if len(c.held[f.Conv]) > 0 { // a newer gap opened during the sync
c.syncing[f.Conv] = true
go c.requestSync(f.Conv, c.last[f.Conv])
}
case "receipt":
if c.marks[f.Conv] == nil {
c.marks[f.Conv] = map[string]Marks{}
}
m := c.marks[f.Conv][f.User]
if f.Kind == "read" {
m.Read = max(m.Read, f.Seq)
}
m.Delivered = max(m.Delivered, f.Seq)
c.marks[f.Conv][f.User] = m
case "presence":
c.presence[f.User] = f.State
case "error":
c.errs = append(c.errs, f.Err)
}
}
// onMsg shows a message only when it is the next sequence number. A number already shown is a
// duplicate; a number past a gap waits until a sync fills the gap. The caller holds c.mu.
func (c *Client) onMsg(f Frame) {
conv, last := f.Conv, c.last[f.Conv]
if f.Seq <= last || c.held[conv][f.Seq].Seq != 0 {
c.dups++
return
}
m := Msg{Seq: f.Seq, From: f.From, CMID: f.CMID, Body: f.Body}
if f.Seq > last+1 { // a gap: hold this one and fetch what is missing
if c.held[conv] == nil {
c.held[conv] = map[int64]Msg{}
}
c.held[conv][f.Seq] = m
if !c.syncing[conv] {
c.syncing[conv] = true
go c.requestSync(conv, last)
}
return
}
for ok := true; ok; m, ok = c.held[conv][last+1] {
delete(c.held[conv], m.Seq)
c.shown[conv] = append(c.shown[conv], m)
last = m.Seq
if c.OnShow != nil {
c.OnShow(conv, m)
}
}
c.last[conv] = last
if c.AutoReceipt {
go c.receipt("recv", conv, last)
}
}
The conversation row lock hands out sequence numbers. Devices show messages in that order, drop numbers they have, and sync when a number is missing.
After this step
- stored
- 1
- alice/phone
- G1; shows 1
- bob/phone
- G2; shows 1
Two gateways run in one test process, each with its own listener and Redis connection; they share only Redis and Postgres. The lab also runs 7 devices sending 420 messages through a gateway crash, with a fifth of sends retried. It checks: no acked message lost, one stored row per client message ID, and every device shows sequence 1 to n in order.
When a gateway crashes, the message is already stored. The router finds the dead gateway, queues a push, and the device catches up by sequence number.
| group size | delivery | receipts |
|---|---|---|
| 2 to 500 | Route to every member's devices at send time. | Per member |
| Thousands or more (a channel) | Notify online members; others sync when they open it. | Counts only |
| presence method | status | cost here |
|---|---|---|
| Push every change to every friend | Not approved | ≈ 11.6 M events/s |
| Subscribe for people on screen | Approved | ≈ 116,000 events/s |
| Clients poll each friend | Small contact lists | Grows with friends × poll rate |
| Delete the key on disconnect only | Not approved | A crash leaves users online forever |
| TTL key renewed by heartbeats | Approved | Stale by at most one TTL |
In the lab a group of 10 delivered 5,500 copies a second for 600 messages: 9 copies each.
heartbeat(device): // every 30 s from each device
EXPIRE sess:{user} SESSION_TTL
old = SET presence:{user} gateway EX PRESENCE_TTL GET1
IF old was empty: PUBLISH pres:{user} "online"
watch(viewer, users): // only the people on screen2
SUBSCRIBE pres:{u} FOR EACH u // once per gateway, shared3
reply EXISTS presence:{u} FOR EACH u
clean disconnect of the last device:
DEL presence:{user}4; PUBLISH pres:{user} "offline"- 1One command renews the lease and returns the old value. Empty means the user just came online.
- 2The chat list and the open chat. Nobody else pays for this user's status changes.
- 3Many devices on one gateway watching one user cost one subscription.
- 4A clean exit is instant. A crash waits for the TTL.
Tested source Go: heartbeat and watch
// heartbeat renews the registry entry and the presence key. Each is a lease: without a
// heartbeat it expires, so a crashed gateway's users drop out by themselves. Only a change from
// offline to online is published.
func (g *Gateway) heartbeat(ctx context.Context, s *session) error {
if err := g.rdb.Expire(ctx, g.cfg.sessKey(s.user), g.cfg.SessionTTL).Err(); err != nil {
return fmt.Errorf("renew session %s: %w", s.key(), err)
}
err := g.rdb.SetArgs(ctx, g.cfg.presKey(s.user), g.ID, redis.SetArgs{TTL: g.cfg.PresenceTTL, Get: true}).Err()
if errors.Is(err, redis.Nil) { // there was no key: the user just came online
return g.publishPresence(ctx, s.user, "online")
}
if err != nil {
return fmt.Errorf("presence %s: %w", s.user, err)
}
return nil
}
// watch subscribes this gateway to the presence of some users, for one session, and replies
// with their state now. Clients watch only the people on screen.
func (g *Gateway) watch(ctx context.Context, s *session, users []string) error {
for _, u := range users {
g.mu.Lock()
first := len(g.watchers[u]) == 0
if first {
g.watchers[u] = map[*session]bool{}
}
g.watchers[u][s] = true
g.mu.Unlock()
if first {
if err := g.sub.Subscribe(ctx, g.cfg.presChan(u)); err != nil {
return fmt.Errorf("watch %s: %w", u, err)
}
}
n, err := g.rdb.Exists(ctx, g.cfg.presKey(u)).Result()
if err != nil {
return fmt.Errorf("presence of %s: %w", u, err)
}
g.reply(s, Frame{T: "presence", User: u, State: map[bool]string{true: "online", false: "offline"}[n == 1]})
}
return nil
}
A small group fans out per member per gateway. Presence is a heartbeat lease, and a gateway subscribes to a user's presence only while someone looks at that user.
| need | how |
|---|---|
| Same history on each device | Each device syncs after its own last shown seq. |
| Own sends on other devices | Route to the sender's devices, except the one that sent. |
| Read on phone clears the laptop | The read receipt goes to the reader's other devices. |
| A new device | Sync history page by page from seq 0. |
| End-to-end encryption | Keys per device; the sender encrypts once per device. The server stores ciphertext and routes it unread. |
- With end-to-end encryption, search and history sync move to the devices.
- Sequence numbers, receipts and routing still work: they never read the body.
Each device is its own session with its own last shown number. Read marks route to the user's other devices, so badges clear everywhere.
| event | why it is safe | saved by |
|---|---|---|
| Gateway crashes | Messages were stored first. Devices reconnect anywhere and sync. | Store first |
| Registry names a dead gateway | PUBLISH reaches 0; the entry is removed and a push queued. | Receiver count |
| Ack lost, client retries | Same client message ID, same seq, nothing routed again. | Unique key |
| Live copy and sync copy both arrive | The device drops a seq it already shows. | Device dedupe |
| 8 arrives before 7 | The device holds 8 and syncs from 7. | Gap check |
| Redis loses the registry | Devices reconnect and re-register; history is in Postgres. | Rebuild |
| A user crashes, looks online | The presence key expires after its TTL. | TTL |
| step | add | it handles | move up when you see |
|---|---|---|---|
| 1 | One server and Postgres; sockets in a map in memory. | One machine's sockets. In the lab, 4,400 messages a second, each committed before its ack. | Sockets or CPU of one machine run out; one restart drops everyone. |
| 2 | Many gateways, a Redis registry and Pub/Sub, as on this sheet. | Sockets grow with gateways. Any gateway serves any device. | Message writes pass one Postgres primary. |
| 3 | Shard messages by conversation, or move them to a wide-column store (X2). | Each conversation stays on one shard, so its counter stays local. About 53 shards at this prompt's average. | Pub/Sub traffic or registry size passes one Redis. |
| 4 | Redis Cluster with sharded Pub/Sub, presence in its own cluster. | Channels and keys spread over shards. | Users far away see high latency. |
| 5 | Regions: gateways near users, each conversation with a home region. | Local sockets everywhere; one writer per conversation keeps the order. | Top of the ladder. |
Demand from panel C. Lab rates: senders wait for each ack, two gateways over loopback, one Postgres and one Redis on a shared laptop, median of 3 runs of 2 s. Read them as orders of magnitude.
I start with one server and Postgres. I add gateways with a Redis registry when one machine's sockets run out, and shard messages by conversation when one primary's writes do.
| question | answer | deeper |
|---|---|---|
| How do you balance millions of long-lived sockets? | Use a layer 4 balancer and least connections. On a deploy, drain a gateway slowly so devices reconnect over minutes, not all at once. | B2 |
| WebSocket, long polling or server-sent events? | WebSocket gives one two-way socket. Long polling works through any proxy but costs a request per message batch. | B1 |
| How do you detect a dead gateway sooner? | Gateways heartbeat into a membership list; a missed beat removes them. The publish count of 0 is the fallback. | X6 |
| Why not order by timestamps? | Clocks on two gateways differ, and a phone's clock can be minutes off. A counter per conversation gives one order. | X4 |
| Where do push notifications come from? | Write a notification job to a queue; workers call APNs or FCM and retry. Dedupe on (device, conversation, seq). | B6 |
| Images and voice notes? | Upload to object storage with a presigned URL; the message carries the key. The CDN serves the bytes. | B9 |
| How do you store years of history cheaply? | Keep recent months in the hot store; move older partitions to cheaper storage and page them on demand. | D4 |
0 of 9 known
Alice is on gateway G1 and Bob on G2. How does Alice's message reach Bob's socket?
Alice's app times out and sends the same message again. Why does Bob see it once?
Why number messages per conversation and not with server timestamps?
Bob's gateway crashes. What happens to the next two messages for him?
A device receives seq 9 but has shown only up to 7. What does it do?
Why are receipts high-water marks and not one row per message?
Why does a crashed user still look online for a while?
Why subscribe to presence lazily?
A group of 10 sends at once. Why did the lab carry only 600 messages a second, against 4,400 for 32 one-to-one chats?
- demand
- About 231,000 messages a second; 150 M sockets at peak.
- lab, 1:1
- About 4,400 messages a second; p50 10 ms, p99 17 ms.
- lab, group
- 600 messages a second on one row; 5,500 deliveries.
- heartbeats
- 5 M renewals a second at a 30 s interval.
- presence
- 11.6 M events a second pushed to all friends; about 116,000 lazily.
- storage
- 2 TB a day at 100 bytes a message.
Postgres 16 and Redis 8 on an 8-core laptop shared with other runs. Latency is send to shown on another device, over loopback.