Design a Chat and Instant Messaging System
This page is one interview loop in three rounds. All three rounds design the same system. Each round opens with the interviewer raising the scope, and the design from the round before has to evolve to meet it.
| Round 1: Mid-level | Round 2: Senior | Round 3: Architect | |
|---|---|---|---|
| Story | 1-on-1 chat inside a marketplace app | A messaging app: groups, presence, receipts, phones plus desktops | Worldwide; end-to-end encryption; channels with 100K+ members; retention rules |
| Level (Amazon) | SDE II (L5) | Senior SDE (L6) | Principal (L7) |
| Traffic | 1M DAU; ~100K concurrent connections; ~700 messages/s at peak | 100M DAU; 20M concurrent sockets; ~174K messages/s and ~660K deliveries/s at peak | ~500M DAU in 4 home regions; ~100M sockets; ~290K messages/s on average |
| Survives | Losing a gateway or an AZ | Losing an AZ, and the reconnect storm that follows | Losing a region, with no acknowledged message lost |
| Targets | 99.9%; delivery P99 < 500 ms | 99.99%; delivery P99 < 100 ms; presence P99 < 500 ms | 99.99% per region; same-region delivery P99 < 100 ms |
| Reading time | ~35 min | ~40 min | ~45 min |
You can start at any round. Rounds 2 and 3 open with a "Where we left off" summary that catches you up.
Loop Opener: What Is a Chat System?
You Already Use One: a Phone Line That Stays Open
When you make a phone call, the line stays open. Either side can talk at any moment, and nobody has to redial to hear the answer. A chat app works the same way. Each app keeps a connection open to our servers, so when someone sends you a message, we can push it to you straight away instead of waiting for your phone to ask.
Every loop before this one was request and response: the client asks, the server answers. Chat is the first loop where the server speaks first.
Four things make up the whole product:
| You do | The system does |
|---|---|
| Open the app | Opens a long-lived connection from your phone to one of our servers, and remembers which server holds it |
| Send "Is the bike still for sale?" | Stores the message in the conversation, in order, then tells you it's safe |
| Receive a reply | Finds the server holding your connection and pushes the reply down it |
| Come back after a day offline | Hands you everything you missed, in order, with nothing missing |
What Makes It Hard
- Millions of open connections spread over hundreds of servers. Bob's phone is connected to exactly one of them. To deliver to Bob, we have to know which one, right now, and that answer changes every time his phone switches from Wi-Fi to 4G.
- Order with no gaps. If Bob sees message 1 and message 3, he must be able to tell that message 2 exists and fetch it. If he sees them in the wrong order, the conversation reads as nonsense.
- People are offline most of the time. A message to an offline person must wait safely, trigger a notification on their phone, and appear in order when they return.
The Question the Whole Loop Answers
How do we get a message to every device of the right people, in order, instantly if they're online and reliably if they're not?
The answer gets sharper every round:
- Round 1: one open connection per user, a registry that says which server holds it, a durable log per conversation with a gap-free sequence number, and catch-up by cursor.
- Round 2: everything multiplies: group members, devices, receipts, presence. The fix is to store once and deliver many times, to turn receipts into cursors, and to survive a million phones reconnecting at once.
- Round 3: geography, encryption and giant channels change what the server is even allowed to know. Each conversation gets a home region, the server carries sealed envelopes, and huge channels switch from push to pull.
Round 1 · Mid-level · "1-on-1 Chat for a Small App"
~35 min · SDE II (L5) · 1 region, 3 AZs · 1M DAU · ~100K concurrent connections · ~700 messages/s peak · 99.9%
R1.1 Establish Design Scope
The interviewer says: "Our second-hand marketplace wants buyers and sellers to chat inside the app. Design it." Before we draw anything, we ask questions, and we say out loud what each answer changes.
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| 1-on-1 only, or groups too? | 1-on-1 only, for now: a buyer and a seller. | Every conversation has exactly two members. Delivery is to one person. |
| Text only? | Text, plus links to photos of the item. | Photos go to object storage through a pre-signed upload; a message carries only the link. Messages stay small (well under 1 KB of text). |
| How fast should delivery be? | If both people have the app open, it should feel instant. | We need the server to push, not the client to poll. We set a target: P99 under 500 ms inside our system. |
| What if the other person is offline? | They should get a phone notification, and see the message when they open the app. | We need durable storage, a push-notification path, and a way to catch up. |
| How long do we keep history? | Months. People come back to old chats about a sale. | History lives in a database, not in memory. |
| More than one device per user? | Not yet. One phone per user. | One connection per user. The registry maps a user to one server. |
| How many users? | About 1M daily users. At the evening peak, about 100K have the app open. | Small numbers. We derive the traffic in R1.7. |
Out of scope for this round:
- Groups.
- Presence ("online", "last seen").
- Read receipts ("seen").
- More than one device per user.
The interviewer will widen this scope later. Write your out-of-scope list where you can see it: in a multi-round loop, it all comes back.
R1.2 Functional Requirements, Derived Step by Step
We read the problem one phrase at a time and turn each phrase into a requirement:
| Phrase from the problem | Requirement |
|---|---|
| "Talk in real time" | A persistent connection from each app to our servers, and a send operation over it. The server pushes new messages down the same connection. |
| "See the old conversation" | History: read a conversation's messages in order, a page at a time. |
| "I was offline" | Catch-up: on return, get every message after the last one I have. And a push notification while I'm away. |
| "Start a chat about this item" | Create a conversation between a buyer and a seller, once (tapping "Chat" twice must not create two chats). |
Not yet: groups, presence, receipts, multiple devices.
R1.3 Non-Functional Requirements: the Questions
Numbers come in R1.7. For now, the questions and why each matters:
- Latency. How long from Alice pressing send to the message leaving our servers towards Bob? We measure it inside our system, from the moment Alice's frame reaches us to the moment Bob's frame leaves us, because a phone on a weak mobile network can add hundreds of milliseconds we don't control.
- No lost messages once "sent" is shown. When Alice's app shows the single tick, the message must survive any single server crash. This is the rule that shapes step 1.3.
- Order within a conversation. Everyone sees the messages of a conversation in the same order, and can tell if one is missing.
- Connection scale. How many open connections at peak, and how many can one server hold? This sizes the fleet.
- Availability. If chat is down, buyers and sellers can't close deals. 99.9% allows about 43.8 minutes of downtime a month.
R1.4 The API
The app speaks two protocols: a WebSocket for live traffic, and plain HTTPS for everything that is a normal request (creating a chat, reading history).
A WebSocket is a connection that starts as an HTTP request and then "upgrades" into a two-way channel over the same TCP connection. After the upgrade, either side can send a message (a frame) at any time.
1. Connect. Browsers can't add an Authorization header to a WebSocket request, so the app first gets a short-lived, single-use connect ticket over normal HTTPS:
httpPOST /v1/ws-ticket HTTP/1.1 Host: chat.example-market.com Authorization: Bearer <access_token>
httpHTTP/1.1 200 OK Content-Type: application/json { "ticket": "wst_4Kq9x2", "expires_in_s": 30 }
Then it opens the socket with that ticket:
httpGET /v1/ws?ticket=wst_4Kq9x2 HTTP/1.1 Host: chat.example-market.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Version: 13 Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
The gateway checks the ticket once and throws it away. A ticket in a URL can end up in a log, which is why it lives 30 seconds and works once.
2. Frames over the socket (JSON; IDs are strings because 64-bit numbers don't fit exactly in JavaScript):
Alice sends:
json{ "type": "SEND_MESSAGE", "client_msg_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "conversation_id": "c_7Hk2", "body": { "text": "Is the bike still for sale?", "image_url": null } }
The server replies to Alice once the message is stored:
json{ "type": "ACK", "client_msg_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "conversation_id": "c_7Hk2", "server_msg_id": "718293847561029384", "seq": 10482, "sent_at": "2026-09-26T18:04:11.135Z" }
Bob receives:
json{ "type": "RECEIVE_MESSAGE", "conversation_id": "c_7Hk2", "server_msg_id": "718293847561029384", "seq": 10482, "sender_id": "u_401", "body": { "text": "Is the bike still for sale?", "image_url": null }, "sent_at": "2026-09-26T18:04:11.135Z" }
If the message can't be accepted, Alice gets a NACK with the same client_msg_id and a reason (NOT_A_MEMBER, TOO_LARGE, RATE_LIMITED).
Why a client message ID? Alice's phone sends a message, then goes into a lift. The ACK never arrives. Did the server store it or not? The phone can't know, so it sends the message again. The client_msg_id is a random ID the phone generated before the first attempt, and it stays the same on every retry. The server uses it to recognise the second copy and answer with the first copy's seq instead of storing it twice. Without it, every weak signal would produce duplicate messages.
3. Create a conversation (idempotent: the ID of a buyer-seller chat about one listing is derived from the three IDs, so a second tap returns the same chat):
httpPOST /v1/conversations HTTP/1.1 Authorization: Bearer <access_token> Content-Type: application/json { "listing_id": "l_88", "participant_id": "u_802" }
httpHTTP/1.1 200 OK Content-Type: application/json { "conversation_id": "c_7Hk2", "members": ["u_401", "u_802"], "created": false }
4. Read history and catch up.
httpGET /v1/conversations/c_7Hk2/messages?after_seq=10481&limit=50 HTTP/1.1 Authorization: Bearer <access_token>
httpHTTP/1.1 200 OK Content-Type: application/json { "messages": [ { "seq": 10482, "server_msg_id": "718293847561029384", "sender_id": "u_401", "body": { "text": "Is the bike still for sale?" }, "sent_at": "2026-09-26T18:04:11.135Z" } ], "has_more": false }
after_seq reads forwards (catch-up); before_seq reads backwards (scrolling up through history). Both are cursors: they say where you are in the conversation, so a page never shifts when new messages arrive.
| Status | When |
|---|---|
401 Unauthorized | Missing or expired token or ticket |
403 Forbidden | The caller isn't a member of the conversation |
429 Too Many Requests | Over the send or read limit (with Retry-After) |
Recap
- A WebSocket per user for live traffic; HTTPS for creating chats and reading history.
- Three frames:
SEND_MESSAGE(with a client message ID),ACK(server ID and sequence),RECEIVE_MESSAGE. - History and catch-up by sequence cursor.
- "Sent" means stored. No gaps, same order for both people.
Let's build it, starting with the simplest thing that works.
R1.5 Design Evolution: From Polling to a Pushed, Ordered Log
Every step below follows the same pattern: a problem, your turn to think, the answer, and what the answer costs us. The cost is always the next problem.
Step 1.0: The Baseline
Store messages in a database. Every few seconds, each app calls GET /v1/conversations/{id}/messages?after_seq=... for its open chats and shows anything new.
Synthesizing vector architecture diagram...
What's good about it: it is plain HTTP, every server is stateless, and nothing new is needed. What it costs us: a message waits on average half a polling interval before Bob sees it, almost every poll comes back empty, and a phone that wakes its radio every few seconds drains its battery.
Step 1.1: Polling Is Slow and Costly
The problem: with a 3-second poll, a reply takes 1.5 seconds on average to show up, and chat feels sluggish. 100K open apps polling every 3 seconds is about 33,000 requests a second, and nearly all of them return nothing. What would you do?
Primitive: WebSocket, SSE & Long Polling
Step 1.2: Alice Is on Gateway 3, Bob Is on Gateway 7
The problem: we have 9 gateways. Alice's socket is on gateway 3 and Bob's is on gateway 7. Alice's message arrives at gateway 3. How does it get to Bob? What would you do?
Primitive: Distributed Cache Patterns & Eviction
Step 1.3: A Message Was Shown as Sent, Then Lost
The problem: in a first version, the gateway sent Alice her ACK as soon as it received the frame, then passed the message on to be stored and delivered. A gateway crashed between the two. Alice's app shows the message as sent; Bob never got it, and it isn't in the history.
What would you do?
Primitive: Distributed Unique ID Generators · Loop: Design a Distributed Unique ID Generator
Step 1.4: Messages Appear in the Wrong Order
The problem: Alice sends "Is it still for sale?" and, a moment later, "Can you do $80?". Two different message-service instances handle them, and their clocks differ by a few milliseconds. Sorted by server timestamp, Bob sees the second message first. Separately: when Bob's phone has messages 10481 and 10483, how does it know whether 10482 exists? What would you do?
Step 1.5: Bob Was Offline for a Day
The problem: Bob's phone was off all day. Alice sent him four messages. When the message service looked him up in the registry, there was no entry. What happens to those messages, and what does Bob see when he opens the app? What would you do?
Step 1.6: A Gateway Restarted and Dropped 11,000 Connections
The problem: we deploy a new gateway version. Gateway 4 restarts, and the ~11,000 phones connected to it lose their sockets at the same instant. Every one of them reconnects immediately, and they all try again every second when the first attempts fail. What would you do?
Drill: WebSocket reconnect thundering herd
Round 1 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 1.0 | (baseline) | Poll for new messages every few seconds | Slow, wasteful, drains batteries |
| 1.1 | Polling is slow | WebSocket gateways that push; heartbeats every 30 s | Gateways hold state |
| 1.2 | Which gateway has Bob? | Connection registry in ElastiCache (90 s TTL, refreshed by heartbeats); direct call to that gateway; compare-and-delete on disconnect | A new dependency; stale entries for up to 90 s |
| 1.3 | "Sent" then lost | Store first (DynamoDB, 3 AZs), then acknowledge; idempotency record keyed by client message ID | A durable write before the tick |
| 1.4 | Wrong order, invisible gaps | Two identifiers; gap-free seq claimed by a conditional write of MSG#<seq>; zero-padded sort keys | A read per send; a hot spot for busy groups |
| 1.5 | Bob was offline | The log is the mailbox: catch-up by cursor; SNS → APNs/FCM push as a nudge | Push is best-effort, outside our control |
| 1.6 | Gateway restart | Backoff with full jitter, graceful drain, resume by cursor | Slower reconnects for some users |
R1.6 Architecture v1
Now the concepts get AWS names.
Synthesizing vector architecture diagram...
Follow Alice's message. It enters through the NLB to her gateway, which hands it to the message service. The service claims the next sequence number and stores the message and its idempotency record in one transaction, acknowledges Alice, looks Bob up in the registry and calls his gateway directly. Independently, the table's change stream triggers a Lambda function that moves the conversation to the top of both inboxes and, if Bob has no registry entry, sends a push notification through SNS. If the call to Bob's gateway fails (NOT_HERE or an error), the message service deletes the stale entry and requests the push itself. Pushes from either path are deduplicated per (recipient, conversation, seq) with a short-lived create-if-absent key in the registry cache, so Bob gets one.
The pieces:
- Network Load Balancer (NLB) with a plain TCP listener. It passes the encrypted bytes through, and each gateway terminates TLS itself. (An NLB TLS listener would decrypt for us, but it bills far more capacity units per open connection: R2.6 shows why that matters.) The NLB's TCP idle timeout (350 seconds by default) is far longer than our 30-second heartbeat, so live sockets are never cut for being idle.
- Gateways on ECS (EC2 launch type, Graviton), spread over three AZs.
- Message service on ECS: stateless.
- ElastiCache (Valkey) for the registry: one primary and one replica in another AZ.
- DynamoDB table
ChatTable, on-demand capacity, with one global secondary index for the inbox. - DynamoDB Streams → Lambda for work that must happen after every stored message but isn't on Alice's critical path: updating inboxes and sending push notifications. A stream here is DynamoDB's ordered log of every change to the table; Lambda reads it and retries on failure, so each change is processed at least once.
- SNS mobile push to APNs and FCM.
The table design (one table, ChatTable; one GSI, a second copy of chosen items that DynamoDB keeps in sync asynchronously, keyed differently):
| Item | PK | SK | GSI1PK | GSI1SK (Number) | Other attributes |
|---|---|---|---|---|---|
| Conversation meta | CONV#<conv_id> | META | – | – | listing_id, members, created_at |
| Member | CONV#<conv_id> | MEMBER#<user_id> | – | – | role, joined_at |
| Idempotency record | CONV#<conv_id> | CMSG#<client_msg_id> | – | – | seq, server_msg_id, ttl (7 days) |
| Message | CONV#<conv_id> | MSG#<seq, 12 digits> | – | – | server_msg_id, client_msg_id, sender_id, body, sent_at |
| Inbox entry | USER#<user_id> | CONV#<conv_id> | USER#<user_id> | last_activity_ms | last_seq, last_read_seq, peer_id, muted |
GSI1SK is a Number, so it sorts as a number. Note that CMSG#, MEMBER# and META all sort below MSG# (C < M, and ME < MS), which is why the message range reads use BETWEEN MSG#<from> AND MSG#999999999999.
Access patterns:
- "Newest message" (to claim the next
seq) →Query PK = CONV#c, SK BETWEEN MSG#000000000000 AND MSG#999999999999, newest first,Limit 1, strongly consistent. - "Catch up after 10477" → the same range starting after
MSG#000000010477, oldest first. - "Scroll up" →
SK < MSG#<before>, newest first. - "My inbox, most recent first" →
Query GSI1 where GSI1PK = USER#me, descending. Unread count =last_seq − last_read_seq, computed, never counted. - "Is Alice a member?" →
GetItem CONV#c / MEMBER#u_401(cached briefly in the service).
The registry layout:
| Part | Value |
|---|---|
| Key | conn:<user_id> |
| Value | `<gateway_id> |
| Expiry | 90 seconds, refreshed on each heartbeat (every 30 s) |
| Delete | Only if the value still names this connection (atomic compare-and-delete) |
Why inbox updates are "set to the larger value", never "add one". The Lambda function may process a change twice (Lambda retries, and streams are read at least once). If it did unread_count = unread_count + 1, a retry would count one message twice, and the badge would say 5 when there are 4. Instead it writes last_seq = 10482 and last_activity_ms = … only if the stored last_seq is smaller. On the sender's inbox entry, the same update also sets last_read_seq = 10482 (again only if larger), so Alice never sees her own message as unread. A replay writes the same values, or is refused because they're already there. Unread is computed from two cursors, so it can't drift.
Trace 1: an online delivery
Synthesizing vector architecture diagram...
Alice's tick waits on one read and one transactional write. Bob's delivery happens right after, on the same service instance.
Trace 2: an offline delivery, then catch-up
Synthesizing vector architecture diagram...
Nothing was queued for Bob. His cursor and the log together say exactly what he's missing.
One gap we accept in Round 1. If the message service crashes after the transaction commits but before it calls Bob's gateway, an online Bob doesn't get the message in real time. It's stored and acknowledged, so it isn't lost: Bob sees it on his next catch-up, or as soon as the next message arrives with a higher seq and his phone notices the hole. Round 2 closes this by driving delivery from a durable log.
R1.7 Numbers
Traffic (assumptions: each daily user sends 20 messages a day; peak is 3× the daily average; 10% of daily users are connected at peak)
| Quantity | Math | Value |
|---|---|---|
| Messages per day | 1M × 20 | 20M |
| Messages/s, average | 20,000,000 ÷ 86,400 | ≈ 231 |
| Messages/s, peak | 231 × 3 | ≈ 694, call it 700 |
| Deliveries/s, peak | 1 recipient per message | ≈ 700 |
| Concurrent connections, peak | 1M × 10% | 100K |
| Heartbeats/s (and registry refreshes) | 100,000 ÷ 30 s | ≈ 3,300 |
Gateways. Assumption, to confirm with a load test: one gateway task on a c7g.large (2 vCPU, 4 GiB) holds 25,000 connections comfortably. At this size the limit isn't CPU (700 messages a second over the whole fleet is nothing); we cap connections per task to keep a crash small. We want the fleet to survive losing an AZ: two AZs must hold 100K, so each AZ needs at least tasks. We run 3 per AZ, 9 tasks: about 11,100 connections each normally, 16,700 each after an AZ loss.
Storage. A message item averages about 600 bytes (a short text, an optional photo link, two IDs, a timestamp and attribute names); the idempotency record about 150 bytes.
The idempotency records (20M × 150 B = 3 GB a day) expire after 7 days, so they hold steady at about 21 GB.
Write capacity, honestly counted. DynamoDB bills writes in write request units: 1 unit per started KB for a normal write, 2 per started KB inside a transaction. A GSI is billed separately for every change to its copy, and an update that changes the index sort key costs two index writes (remove the old entry, add the new one).
| Write, per message | Units |
|---|---|
| Message item, in the transaction (600 B → 1 KB × 2) | 2 |
| Idempotency record, in the transaction | 2 |
| Inbox entries of both members: 1 base write + 2 index writes each | 6 |
Read cursor (about one per message: Bob reads soon after it arrives): 1 base + 1 index write (last_read_seq is projected into the index) | 2 |
| Total | 12 |
The message itself is 2 of those 12 units. The rest is bookkeeping, and that ratio will get worse in Round 2.
Availability budget. 99.9% of a 30.4-day month: minutes.
Monthly cost (us-east-1 list prices, 730 hours a month; rounded)
| Item | Math | Monthly |
|---|---|---|
| DynamoDB writes (on-demand) | 20M × 12 units × 30.4 ≈ 7.3B units × $0.625 per million | ≈ $4,560 |
| DynamoDB reads | claim reads 20M/day × 1 unit; catch-up and history ≈ 10M opens/day × ~4 units; inbox ≈ 5M/day: ≈ 65M units/day × 30.4 × $0.125 per million | ≈ $250 |
| DynamoDB storage, a year in | ~4.4 TB × $0.25 per GB | ≈ $1,100 |
Gateways, 9 × c7g.large | 9 × $0.0725/h × 730 | ≈ $480 |
Message service, 3 × c7g.large | 3 × $0.0725 × 730 | ≈ $160 |
ElastiCache (Valkey) registry, 2 × cache.r7g.large | 2 × ~$0.175/h × 730 (Valkey nodes list about 20% below Redis OSS) | ≈ $256 |
| SNS mobile push | assume half the messages reach an offline recipient: 10M/day × 30.4 ≈ 304M × ($0.50 publish + $0.50 delivery) per million | ≈ $300 |
| NLB | $0.0225/h + a few capacity units (bytes processed dominate: ~4 GB an hour) | ≈ $35 |
| Lambda, data transfer out, CloudWatch, misc. | ≈ $420 | |
| Total | ≈ $7,600/month |
Say the headline: the biggest line is DynamoDB writes, and most of that is bookkeeping, not messages. The gateways, the part everyone draws first, are about 6% of the bill.
R1.8 Trade-Offs
WebSocket vs SSE vs long polling. Covered in step 1.1: chat is two-way and chatty, so WebSockets. SSE would work for receiving, but every send would be a separate HTTPS request. Long polling stays as a fallback for networks that block WebSockets.
API Gateway WebSocket APIs vs our own gateway fleet. At this size, the managed option deserves an honest look. API Gateway WebSocket APIs hold the connections for us, route each incoming frame to a backend (a Lambda function or an HTTP endpoint), and give each connection an ID we can post frames to.
| API Gateway WebSocket API | Our own gateways (chosen) | |
|---|---|---|
| What we run | Nothing for connections; Lambda or a service behind it | 9 tasks, an NLB, deploys and draining |
| Limits (verified) | Frames up to 32 KB, messages up to 128 KB; a connection lives at most 2 hours and closes after 10 minutes idle; 500 new connections a second per account per Region by default (can be raised) | Whatever we build and load-test |
| Cost at our size | Connection time: ~60K average connections × 43,800 min ≈ 2.6B minutes × $0.25 per million ≈ $660; messages (sends, deliveries, acks ≈ 60M/day, metered in 32 KB units; ping and pong control frames are free) ≈ 1.82B a month: the first 1B at $1.00 per million and the rest at $0.80 ≈ $1,660. ≈ $2,300/month, plus Lambda | ≈ $500/month (gateways + NLB) |
| Reconnect behaviour | Every connection is forced to reconnect at least every 2 hours | Connections live as long as the app stays open |
At 1M users, API Gateway is a legitimate choice: about $1,800 a month more, and nobody to page when a gateway misbehaves. We choose our own fleet because the business expects to grow into Round 2, where the 2-hour reconnect and per-message pricing start to dominate, and because owning the gateway lets us drain gracefully and resume by cursor exactly as we want. It is a judgment call; say so in the interview.
DynamoDB vs a Cassandra-style store for message logs. Both fit the shape "partition by conversation, sort by sequence, range-read the tail". Cassandra (or ScyllaDB) is cheaper per write at very large scale if you run it well, and Discord famously stores messages that way. DynamoDB gives us conditional writes (which our gap-free sequence relies on), three-AZ durability, and no cluster to operate. Cassandra's equivalent, a lightweight transaction, is a Paxos round and much slower than a normal write. At Round 1's size, the managed store wins easily.
R1.9 Failure Modes
| Failure | What you'd see | How the design responds |
|---|---|---|
| A gateway crashes | ~11K sockets drop; a burst of reconnects | Apps back off with jitter and reconnect to other gateways; they resume by cursor. Messages sent to them meanwhile fail with NOT_HERE or a connection error and fall back to push. |
| A stale registry entry (gateway gone, entry not yet expired) | Deliveries to a dead gateway fail | On NOT_HERE or a call error, the message service compare-and-deletes the stale entry and requests the push itself (the stream Lambda still sees the entry and wouldn't). Pushes are deduplicated per (recipient, conversation, seq). |
| The message service crashes mid-send | Alice gets no ACK, retries | The retry carries the same client_msg_id: either the transaction never committed (it commits now) or it did (the CMSG# record answers with the original seq). No duplicate, no loss. |
Two senders claim the same seq | One conditional write fails | The loser re-reads the newest item and claims the next number. |
| Push provider delays | Notifications arrive late or not at all | Nothing is lost: the log holds the messages. We watch SNS delivery failures, and remove device tokens APNs or FCM report as no longer valid. |
| The registry's primary fails | A few seconds of lookup errors | The replica in another AZ is promoted. Lookups that fail are treated as "offline" (push). Gateways rewrite their entries on the next heartbeat. |
| An AZ is lost | A third of gateways and service tasks gone | Six gateways remain for 100K connections (16.7K each); DynamoDB and the registry replica are multi-AZ. |
R1.10 Pillar Check
| Pillar | What Round 1 covers |
|---|---|
| Reliability | Store before acknowledging; idempotent retries by client message ID; gateways, services and data across three AZs; reconnects with backoff and full jitter REL 4 · REL 5 · REL 10 |
| Performance Efficiency | Push over a WebSocket instead of polling; the registry answers in about a millisecond; catch-up is one range read PERF 1 · PERF 3 |
| Security | TLS on every connection; short-lived, single-use connect tickets from a signed-in session; membership checked on every send and read SEC 2 · SEC 9 |
| Cost Optimization | About $7,600/month, derived; the managed WebSocket option priced honestly; writes named as the big line COST 5 |
| Operational Excellence | Light this round: alarms on delivery latency, gateway connection counts and push failures OPS 8 |
| Sustainability | Skipped this round. |
R1.11 Round 1 Rubric and Follow-Ups
What a strong mid-level (L5) answer shows
- Starts from polling, names its cost, and moves to a persistent connection with a reason.
- Explains how a message finds the recipient's server (a registry with expiry), and why broadcasting to every server doesn't scale.
- Stores before acknowledging, and makes retries idempotent with a client-generated ID.
- Separates a global message ID from a per-conversation sequence, and can say whether the sequence has gaps and why.
- Uses the log as the mailbox: catch-up by cursor, push as a nudge.
- Plans for reconnect storms with backoff and jitter.
- Derives traffic, gateway count and a monthly cost, and sees that writes, not connections, dominate.
Follow-up questions
-
"Why not have the gateway write to DynamoDB directly and skip the message service?" Answer: we could. We keep gateways as thin as possible because they hold state: every deploy of a gateway disconnects users, so we want to deploy them rarely. Business logic (membership checks, limits, sequencing) changes often and lives in a stateless service we can deploy any time without dropping a single socket.
-
"How does Alice's app show the message before the
ACKarrives?" Answer: it shows it at once as "sending" (a clock icon), at the bottom of the conversation, keyed byclient_msg_id. When theACKarrives, it attaches theseqand moves the message to its place in the order (almost always where it already is). If noACKcomes after a timeout, it retries with the same ID; after several failures it shows "not sent, tap to retry". -
"A photo is 3 MB. Does it go over the WebSocket?" Answer: no. The app uploads it straight to S3 through a pre-signed URL, then sends a normal message carrying the photo's URL. The socket carries small frames only, and photos are served to the other side through a CDN.
Interview gotchas from this round's wrong answers
| Gotcha | Why it's wrong |
|---|---|
| "Acknowledge, then store" | A crash in between shows "sent" for a message that no longer exists anywhere. |
| "Order by server timestamp" | Clocks on different machines disagree; nearby messages swap, and gaps are invisible. |
| "No client message ID" | Every retry after a lost ACK stores a duplicate. |
"A counter item with ADD 1" | A retried or orphaned increment skips a number: a hole no client can ever fill. |
| "Keep the registry in DynamoDB with a TTL" | TTL deletes eventually (typically within days); dead routes linger. |
| "Everyone reconnects immediately" | A synchronized wave of reconnects can take down the servers that survived. |
Round 2 · Senior · "Groups, Presence, Receipts, Many Devices, 100M DAU"
~40 min · Senior SDE (L6) · 1 region, 3 AZs · 100M DAU · 20M concurrent sockets · ≈ 174K messages/s and ≈ 660K deliveries/s peak · 99.99% · delivery P99 < 100 ms
R2.0 Where We Left Off
This is what the candidate says aloud in the first 60 seconds of Round 2. If you're starting here, it's everything you need from Round 1.
Round 1 in 60 seconds. "We built 1-on-1 chat for a marketplace: 1M daily users, about 100K open connections and 700 messages a second at peak, one region, three AZs, 99.9%. Each app holds a WebSocket to a gateway fleet behind an NLB, with a heartbeat every 30 seconds. A registry in ElastiCache maps each user to the gateway holding their socket, with a 90-second expiry refreshed by heartbeats, and deliveries are a direct call to that gateway. A stateless message service stores every message before acknowledging it: it claims the next per-conversation sequence number by writing
MSG#<seq>with a 'must not exist' condition, in one DynamoDB transaction with an idempotency record keyed by the client's message ID. So the sequence is gap-free, and retries never duplicate. Offline users get a push through SNS to APNs or FCM, and catch up by cursor with one range read. A stream-triggered Lambda keeps each user's inbox sorted by activity, setting values to the larger one so replays can't double-count. Apps reconnect with jittered backoff and resume by cursor. About $7,600 a month, and DynamoDB writes, mostly bookkeeping, are the biggest line. Open costs: one delivery per message, one device per user, the read-then-claim loop won't survive a busy group, and if the service crashes after storing but before delivering, an online recipient only sees the message on their next catch-up."
Architecture v1, compact
textapp ──WebSocket──► NLB ──► gateways (9) ──► message service ──► DynamoDB ChatTable │ ▲ │ 1. Query newest MSG# (strong) conn:<user> 90 s ◄──┘ │ │ 2. Transact: MSG#<seq> + CMSG#<client id>, "must not exist" (ElastiCache) └── deliver ◄─────┘ 3. ACK, then look up recipient, call its gateway DynamoDB Streams ──► Lambda: inbox "set if larger" (GSI1 by last activity); push via SNS if no registry entry catch-up: GET /messages?after_seq=… → Query SK BETWEEN MSG#<after+1> AND MSG#999999999999
Round 1 step summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 1.1 | Polling is slow | WebSocket gateways | Gateways hold state |
| 1.2 | Which gateway has Bob? | Registry with a 90 s TTL; direct call | Stale entries |
| 1.3 | "Sent" then lost | Store, then acknowledge; idempotency record | A durable write before the tick |
| 1.4 | Order and gaps | Gap-free seq claimed by a conditional write | A hot spot for busy groups |
| 1.5 | Offline users | Log as mailbox, cursor catch-up, push as a nudge | Push is best-effort |
| 1.6 | Gateway restart | Full-jitter backoff, drain, resume | Slower reconnects |
Open costs: groups, devices, receipts and presence don't exist yet; the sequence claim won't survive a busy group; delivery isn't driven by a durable log.
R2.1 The Scope Raise
Interviewer: "We're now a standalone messaging app. 100 million people use it daily, and at the evening peak 20 million sockets are open. People chat in groups of up to 1,000. They want to see who's online, and whether their message was delivered and read. Everyone has a phone, many also have a desktop app or a browser tab, and all of them must stay in sync. Losing an AZ must not become an outage. Delivery must feel instant: under 100 milliseconds at P99 inside our system."
A scope raise is not the end of scoping. We ask back, and say what each answer changes.
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| How are messages split between 1-on-1 and groups, and how big is a typical group? | About 40% of messages go to groups. The average group has about 25 members, of whom about 8 are online when a message arrives. A few groups have 1,000. | One message becomes many deliveries, but it must be stored once. A 1,000-member group needs a different delivery path from a 5-person one (step 2.1). |
| How busy can one conversation get? | Community groups can have dozens of people typing at once during a live event. | Round 1's read-then-claim loop contends on one conversation. We need a single place that assigns each conversation's numbers (step 2.2). |
| What does "online" mean, and how quickly must it update? | A green dot for contacts, and "last seen" otherwise. Changes should show within half a second, but a phone going through a tunnel shouldn't flicker. | Presence needs a debounce and must not be broadcast to everyone (step 2.3). |
| What exactly are receipts in a group? | 1-on-1: sent, delivered, read. Groups: "read by 12", and a list if you tap. | Receipts can't be a row per member per message (step 2.4). |
| How many devices per user? | Up to 5: phones, desktop, web. Read on one, cleared on all. | The registry maps a user to several connections; cursors are per device (step 2.4). |
| What happens when an AZ fails? | Users may reconnect, but must not see an outage beyond that. | A third of the sockets reconnect at once. We size for it (step 2.5). |
| Is 100 ms measured end to end? | From our gateway receiving the send to the recipient's gateway writing it. | No slow hop on the delivery path; every stage gets a budget (step 2.2). |
Scope change
| Round 1 | Round 2 | |
|---|---|---|
| Users | 1M DAU | 100M DAU |
| Concurrent connections | ~100K | 20M sockets |
| Messages | 20M/day; ~700/s peak | 5B/day; ≈ 57,870/s average, ≈ 174K/s peak |
| Deliveries | 1 per message | ≈ 3.8 per message: ≈ 660K/s peak |
| Conversations | 1-on-1 | + groups up to 1,000 |
| Devices | 1 per user | Up to 5 per user, in sync |
| New features | – | Presence; sent / delivered / read receipts |
| Data | ~15 GB/day | 5 TB/day; ≈ 9.13 PB over 5 years; old messages tiered to S3 |
| Availability | 99.9% (43.8 min/month) | 99.99% (4.4 min/month) |
| Targets | Delivery P99 < 500 ms | Delivery P99 < 100 ms; presence P99 < 500 ms |
| Survives | A gateway, an AZ | An AZ and the reconnect storm that follows |
The "Not yet" list from R1.2 is now mandatory: groups, presence, receipts and multiple devices.
R2.2 What Breaks in the Round 1 Design
| Round 1 choice | What breaks at the new scope |
|---|---|
| One delivery per message | A group message goes to up to 1,000 members, each with up to 5 devices. Copying the message per member would multiply storage by the group size. |
Read the newest item, then claim seq + 1 | In a busy group, many senders race for the same number: most conditional writes fail and retry. And every claim is a strongly consistent read plus a transaction on one partition. |
| Registry: one entry per user | A user now has several live connections. One key holding one gateway can't say where the desktop and the phone both are. |
| No presence | Naive presence (tell every contact on every connect and disconnect) would send tens of millions of updates a second (R2.6). |
| No receipts | A receipt row per member per message would be 1,000 writes for one message in a big group. |
| Reconnects are a small event | An AZ holds a third of the sockets: about 6.7 million phones reconnect at once. |
| Deliver right after the write, from the same process | A crash between storing and delivering leaves an online user without the message until their next catch-up. At 174K messages a second, "rare" happens every day. |
R2.3 New Requirements and API Additions
Groups.
httpPOST /v1/conversations HTTP/1.1 Authorization: Bearer <access_token> Content-Type: application/json { "type": "GROUP", "name": "Bike club", "member_ids": ["u_401", "u_802", "u_913"] }
httpHTTP/1.1 201 Created Content-Type: application/json { "conversation_id": "c_9Jq1", "type": "GROUP", "member_count": 3, "membership_version": 1 }
POST /v1/conversations/{id}/members adds members and DELETE /v1/conversations/{id}/members/{user_id} removes one. Each change bumps membership_version, and the change is also stored as a system message in the conversation's log (so every member sees "Sam added Priya" in order with the chat).
Resume, per device. On connect, each device says what it has:
json{ "type": "RESUME", "device_id": "d_phone_1", "inbox_sync_token": "1767225600135", "open_conversation": "c_9Jq1" }
The server answers with the conversations that changed after the token (from the inbox index), and the device fetches after_seq for each.
Receipts are cursors, one frame per conversation, not per message:
json{ "type": "DELIVERED", "conversation_id": "c_9Jq1", "up_to_seq": 20417 }
json{ "type": "READ", "conversation_id": "c_9Jq1", "up_to_seq": 20417 }
The server tells the user's other devices, so their badges clear:
json{ "type": "READ_SYNC", "conversation_id": "c_9Jq1", "up_to_seq": 20417 }
and tells senders what happened to their messages (1-on-1 per recipient; groups as a summary):
json{ "type": "RECEIPT_UPDATE", "conversation_id": "c_9Jq1", "seq": 20415, "delivered_count": 21, "read_count": 12, "member_count": 25 }
Presence, only for people currently on screen:
json{ "type": "PRESENCE_SUBSCRIBE", "user_ids": ["u_802", "u_913"] }
json{ "type": "PRESENCE_UPDATE", "user_id": "u_802", "status": "OFFLINE", "last_seen": "2026-09-26T18:22:40Z" }
A new subscribe replaces the previous set, and the app sends one whenever the visible contacts change (at most 50 users, at most once a second).
Large-group activity nudge, and gap repair:
json{ "type": "NEW_ACTIVITY", "conversation_id": "c_big7", "latest_seq": 88210 }
json{ "type": "SYNC_GAP", "conversation_id": "c_9Jq1", "missing_seq": [20416] }
The inbox (a user's conversation list, most recent first):
httpGET /v1/inbox?limit=50 HTTP/1.1 Authorization: Bearer <access_token>
httpHTTP/1.1 200 OK Content-Type: application/json { "conversations": [ { "conversation_id": "c_9Jq1", "type": "GROUP", "name": "Bike club", "last_seq": 20417, "last_read_seq": 20409, "unread": 8, "last_activity": "2026-09-26T18:22:41Z" } ], "sync_token": "1767226961000" }
R2.4 Design Evolution: Fan-Out, Ownership, Presence and Recovery
Step 2.1: A Message to a 1,000-Member Group
The problem: Priya sends "See you at 7!" to a 1,000-member group. About 300 members are online, on about 400 devices. The other 700 should get it when they come back. What would you do?
Primitive: Distributed Cache Patterns & Eviction
Step 2.2: The Busy Group's Sequence Claim Throttles
The problem: during a live football match, 200 people in one group send about 50 messages a second. With Round 1's method (read the newest item, then write MSG#<seq+1> if it doesn't exist), most attempts lose the race and retry. DynamoDB starts throttling the group's partition, sends time out, and phones retry, which makes it worse.
What would you do?
Primitive: Message Queues vs Event Streams · Primitive: Distributed Locks & Leases · Drill: Distributed lock fencing token · Loop: Design a Distributed Message Queue
Step 2.3: Presence Flaps and Floods Everyone
The problem: a first version marked a user online on connect and offline on disconnect, and told all their contacts each time. A commuter's phone drops its connection a dozen times on the way to work; each of their 300 contacts sees the dot flicker, and the presence traffic dwarfs the messages. What would you do?
Primitive: Gossip Protocol & Failure Detection
Step 2.4: Sent, Delivered, Read, on Every Device
The problem: Bob has a phone and a laptop. He reads Priya's group message on the laptop; his phone still shows a badge of 8. Priya wants to see "read by 12" on her message. And the registry still maps Bob to one connection. What would you do?
Step 2.5: An AZ Died and 6.7 Million Phones Reconnected at Once
The problem: one AZ goes dark during the evening peak. It held a third of our 20M sockets: about 6.7 million connections drop within seconds, and every one of those apps starts reconnecting to the two AZs left. What would you do?
Drill: WebSocket reconnect thundering herd · Primitive: Circuit Breaker, Bulkhead & Fault-Tolerance Patterns
Step 2.6: A Phone Got Message 20416 Before 20415
The problem: Bob's phone has everything up to 20414. It receives 20416 as a live frame; 20415 was in a catch-up response that is still on its way (or a delivery was retried after a worker restart). If the app renders 20416 now, the conversation reads out of order. What would you do?
Round 2 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 2.1 | 1,000-member group | Store once; push full messages to ≤ 100-member groups; one sharded-pub/sub publish and nudges for bigger ones | Two paths; a threshold to tune |
| 2.2 | Hot sequence claim | MSK ingest partitioned by conversation → one sequencer per partition; plain conditional put as the fence; sequenced topic drives delivery; 100 msg/s per conversation limit | Order tied to partitions; a Kafka cluster |
| 2.3 | Presence floods | 20 s heartbeats end at the gateway; write on change; 45 s debounce via due-time sweep; gateway liveness keys; viewport subscriptions | Offline is 45 s late; presence is approximate |
| 2.4 | Receipts on every device | Per-device delivery cursors (ZADD GT), per-member read cursor ("set if larger"); READ_SYNC; group summaries; per-device registry | Cursor bookkeeping; summaries not lists |
| 2.5 | AZ loss, 6.7M reconnects | Two-AZ headroom; full jitter; admission control at TCP accept, before TLS; cheap resume; a read-capacity floor | A third more gateways; a slow minute for some |
| 2.6 | Out-of-order arrival | 200 ms quarantine, then a strongly consistent gap fetch; tombstones keep slots | A little delay on real gaps |
R2.5 Architecture v2
Synthesizing vector architecture diagram...
Follow a message. The gateway publishes it to ingest, keyed by conversation, so one sequencer owns each conversation. The sequencer numbers it, stores it with a conditional put, acknowledges the sender and publishes it to sequenced. From there, independent consumers deliver it (per member for small groups, one publish for large ones), update inboxes and cursors, send pushes to offline members, and archive it to S3. The routing cluster holds everything short-lived: the per-device registry, presence, delivery cursors and the pub/sub channels.
The pieces added in this round:
- MSK: 12 brokers, 4 per AZ (sized in R2.6), replication factor 3,
min.insync.replicas = 2. Topicingestand topicsequenced, each with 600 partitions, both keyed by conversation ID. Retention 24 hours: the log is a pipeline, not the store. - Sequencers, delivery workers, inbox updaters, archiver, presence service, push workers: ECS services on Graviton, all in three AZs.
- Two ElastiCache clusters, on purpose. The routing cluster (registry, presence, cursors, pub/sub) runs with
maxmemory-policy noevictionand lots of spare memory: silently evicting a registry entry would make a user look offline. An eviction policy likevolatile-ttlwould be exactly wrong here, because it evicts the keys closest to expiring first, which are our 90-second registry entries. The recent-messages cache (the last 200 messages of each active large group) is disposable and runsallkeys-lru. When a reader finds a group's window missing and rebuilds it from DynamoDB, it creates the key only if it's still absent (SET … NX), so a slightly older snapshot can never overwrite a window the delivery worker has just extended with newer messages. - Member lists cached in delivery workers, keyed by
membership_version. The membership service writes the new list into the cache on every change, and a worker that refills after a miss only creates the entry if absent, for the same reason. A removed member stops receiving within about a second.
Trace 1: a message to a 25-member group
textPriya's phone ─► gw-112 ─► ingest (key c_9Jq1) ─► sequencer p-311: known client_msg_id? no → seq = 20418 → PutItem MSG#000000020418 (must not exist) ✓ ─► ACK to gw-112 (seq 20418) ─► publish to sequenced (key c_9Jq1) (later, off the critical path: META.last_seq ← 20418 "set if larger", at most hourly) delivery worker (owns sequenced partition of c_9Jq1): members (25, cached, version 7) → pipelined registry read of 25 conn:<user> sets → 11 live devices ─► direct calls to 9 gateways ─► RECEIVE_MESSAGE on 11 devices (not Priya's phone; yes Priya's laptop) ─► 14 members with no live device → push queue (unless muted) inbox updater: last_seq / last_activity "set if larger" on members' inbox items (coalesced; see R2.6)
Trace 2: presence debounce in a tunnel
textt = 0 s Bob's last heartbeat reaches gw-40 t = 45 s gw-40 closes the silent socket; Bob has no other device presence: pres:u_802 = OFFLINE_PENDING (last_seen = t0); offdue:<b> add u_802 at t0 + 45 s = now t = 45.2 s sweeper: u_802 due and still pending → OFFLINE → watchers' gateways (watch:u_802 = {gw-7, gw-301}) alternative: t = 20 s Bob reconnects on gw-9 → pres:u_802 = ONLINE, due entry removed, nobody notified
Trace 3: an AZ loss
textt = 0 us-east-1b fails: 220 gateways and ~6.7M sockets gone t = 0–2 s apps notice (socket error or missed heartbeat) and wait random(0, 1 s) … random(0, 2 s) … t = 1–60 s 440 gateways admit ≤ 300/s each (132K/s); the excess are closed at TCP accept, before TLS → back off each admitted app: RESUME → inbox changes since token → fetch after_seq for 0–2 conversations t ≈ 60–120 s the last apps are back; presence never flickered for most (they reconnected inside 45 s) meanwhile MSK keeps 2 of 3 replicas; ElastiCache promotes replicas in the surviving AZs; DynamoDB unaffected
The key schema, complete for this round (still one table, one GSI):
| Item | PK | SK | GSI1PK | GSI1SK (Number) | Other attributes |
|---|---|---|---|---|---|
| Conversation meta | CONV#<id> | META | – | – | type, name, member_count, membership_version, last_seq (never expires) |
| Member | CONV#<id> | MEMBER#<user> | – | – | role, joined_at |
| Message | CONV#<id> | MSG#<seq, 12 digits> | – | – | server_msg_id, client_msg_id, sender_id, sender_device, body, sent_at, deleted, ttl (90 days) |
| Archive manifest | CONV#<id> | ARCH#<yyyy-mm> | – | – | list of (S3 object, byte offset, length, first and last seq) |
| Inbox entry | USER#<user> | CONV#<id> | USER#<user> | last_activity_ms | last_seq, last_read_seq, delivered_seq, muted (index projects last_seq, last_read_seq) |
| Device | USER#<user> | DEVICE#<device> | – | – | platform, push_token, last_seen |
The CMSG# idempotency item from Round 1 is gone: the sequencer's memory of recent client_msg_ids replaced it, which saves 2 write units per message and a transaction.
Primitive: Database Sharding & Partition Keys
R2.6 Numbers and Cost
Traffic (the current track's figures, re-derived)
| Quantity | Math | Value |
|---|---|---|
| Messages/s, average | 5,000,000,000 ÷ 86,400 | ≈ 57,870 |
| Messages/s, peak | 57,870 × 3 | ≈ 173,611, call it 174K |
| Deliveries per message | 60% 1-on-1 × 1 + 40% group × 8 online members = 0.6 + 3.2 | 3.8 |
| Deliveries/s, peak | 173,611 × 3.8 | ≈ 659,722, call it 660K |
| Ingress, peak (1 KB per message) | 174,000 × 1 KB = 174 MB/s × 8 | ≈ 1.39 Gbps |
| Egress, peak | 660,000 × 1 KB = 660 MB/s × 8 | ≈ 5.28 Gbps |
(Multiple devices add to the deliveries: if an online recipient has 1.2 connected devices on average, that's about 790K frames a second at peak. We size on the carried 660K and keep the difference as margin.)
Gateways: the track's 400 has no headroom. The track assumes about 10 KB of memory per idle socket and caps each task at 50,000 sockets. Both are assumptions to load-test: 10 KB is optimistic for a TLS connection (a TLS library's buffers alone can be tens of KB per connection unless it releases them when idle). And 400 × 50,000 is exactly 20M: no room for a single failure, let alone an AZ.
| Quantity | Math | Value |
|---|---|---|
| Socket state (the track's figure) | 20M × 10 KB | ≈ 200 GB across the fleet |
| Tasks for 20M sockets at 50K each | 20,000,000 ÷ 50,000 | 400 (zero headroom) |
| Survive an AZ loss: 2 AZs must hold 20M | 20M ÷ 2 AZs ÷ 50K | 200 per AZ |
| Plus 10% for deploys and uneven spread | 200 × 1.1 | 220 per AZ, 660 tasks |
| Normal load | 20M ÷ 660 | ≈ 30,300 sockets per task |
| After losing an AZ | 20M ÷ 440 | ≈ 45,500 per task |
Each task is a c7g.xlarge (4 vCPU, 8 GiB). Messages aren't the constraint: even after an AZ loss, 660K deliveries ÷ 440 ≈ 1,500 frames a second per task. The 50K cap is a choice about blast radius: a crashed task drops 0.25% of users, and a storm of 50K reconnects is easy to absorb.
The reconnect storm, sized. One AZ ≈ 20M ÷ 3 ≈ 6.67M sockets. 440 surviving tasks × 300 admissions/s = 132,000/s → at least s. Catch-up reads while admitting at that rate: one inbox query each (≈ 0.5 read units) plus, for about 30% of apps, 1–2 conversation fetches (≈ 1 unit each): read units a second, for about a minute. We provision a floor of about 125K spare read units during peak hours.
Presence traffic.
| Math | Updates/s at peak | |
|---|---|---|
| Naive: every raw connect/disconnect to every contact | assume 4B raw connection changes a day (≈ 46K/s average, ≈ 140K/s peak) × 200 contacts | ≈ 28M |
| Ours: debounced changes to on-screen watchers | assume 2B real online/offline changes a day (≈ 23K/s average, ≈ 70K/s peak) × 0.3 watchers on average | ≈ 21K |
About a thousand times less work, and the half-second target only applies to 21K updates a second.
DynamoDB writes: the track's 174,000 WCU is too low. It counts one 1 KB write per message. It misses the item size rounding (a 1 KB body plus IDs and attribute names is just over 1 KB, and a write is billed per started KB, so most message items cost 2 units) and every index and bookkeeping write:
| Write, per message (average) | Units |
|---|---|
| Message item, conditional put (1–2 KB) | 2 |
Inbox bump: 1 base write + 2 index writes (the index sort key changes). Assumption: coalescing (at most one bump per member per conversation per 60 s; the first message of each window bumps at once, so RESUME never misses a changed conversation, and later ones are folded into one bump at the end of the window) leaves about 1 bump per message on average | 3 |
Read cursor: about 1 per message (1 base + 1 index write, since last_read_seq is projected) | 2 |
| Delivered cursor, coalesced to one write per member per 10 s: about 0.5 per message (not projected) | 0.5 |
| Total | ≈ 7.5 |
So the honest peak is about 7.5 times the track's figure. Two consequences: the default table quota (40,000 write units a second per table) must be raised far ahead of launch, and the capacity mode matters for cost:
- On-demand: trillion write units a month × $0.625 per million ≈ $713K/month.
- Provisioned with auto scaling (target 70% utilization, so on average ≈ 620K units provisioned): 620{,}000 \times \0.00065 \times 730 \approx **\294K/month**. We choose this; the traffic is a smooth daily curve, which auto scaling follows well.
DynamoDB reads (assumptions: 20 conversation opens per daily user per day, ~30 KB each ≈ 4 read units eventually consistent; 10 inbox reads per user per day ≈ 1 unit): units/s on average. Provisioned at 70% ≈ 150K, plus the 125K storm floor ≈ 275K units × $0.00013 × 730 ≈ $26K/month.
Storage and tiering. 5 TB a day (5B × 1 KB); over 5 years TB ≈ 9.13 PB. We keep 90 days hot in DynamoDB, and the archiver writes everything to S3 as it happens:
- The archiver (a consumer of
sequenced) packs messages into compressed segment files of several MB, grouped by a hash of the conversation ID, and writes them to S3 every few minutes. It reads the log at least once, so a segment can contain a message twice; the monthly compaction job keeps one copy per (conversation,seq) and writes each conversation's archive manifest item (ARCH#<yyyy-mm>) pointing at byte ranges. - Hot items expire through DynamoDB TTL at 90 days. Here TTL is the right tool: it's cleanup, its lateness only costs a few days of storage, and TTL deletes consume no write capacity. A daily job checks that every conversation-day is in the archive before its items' TTL date. The conversation's numbering survives the expiry because
META.last_seqnever expires (step 2.2). - History older than 90 days is read with an S3 byte-range
GETusing the manifest. We store segments in S3 Glacier Instant Retrieval: millisecond access, $0.004 per GB-month, a retrieval fee ($0.03 per GB) and a 128 KB minimum billable object size and 90-day minimum, which our multi-MB, never-deleted segments don't mind. Text and JSON compress well; we assume 3×.
| Where the 5 TB/day lives | A year in | Monthly |
|---|---|---|
| DynamoDB, last 90 days | 450 TB × $0.25 per GB | ≈ $112.5K |
| S3 Glacier IR, older | (365 − 90) × 5 TB ≈ 1,375 TB ÷ 3 ≈ 458 TB × $0.004 per GB | ≈ $1.8K |
| Without tiering: DynamoDB only | 1,826 TB × $0.25 | ≈ $456K |
At the 5-year mark the gap is wider: 8.68 PB of old history is ≈ 2.9 PB compressed, ≈ $11.6K a month in Glacier IR, against about $2.28M a month for all 9.13 PB in DynamoDB. The "eleven nines" durability figure belongs to S3 (it's S3's design target for objects); DynamoDB doesn't publish a durability number in nines. It keeps three copies in three AZs and answers only after a majority has durably logged a write, and our promise ("no acknowledged message lost") comes from storing before acknowledging, not from a number of nines.
The routing cluster. Mostly operations, not memory (20M users' registry sets and presence entries are a few GB):
| Operation | Per second at peak |
|---|---|
| Registry refreshes (20M devices ÷ 30 s, batched per gateway) | ≈ 667K |
| Registry lookups for small groups (174K × (0.6 × 1 + 0.4 × 25 members)) | ≈ 1.84M |
| Presence, cursors, pub/sub, connects and disconnects | ≈ 300K |
| Total | ≈ 2.8M |
Planning figure (to load-test): ~100K pipelined operations a second per shard primary, kept under 80% → 36 shards, each a primary and a replica in another AZ: 72 × cache.r7g.xlarge.
MSK. Two topics each carrying ≈ 174 MB/s at peak, replication factor 3: ≈ 1 GB/s of broker writes at peak. We assume 12 brokers of kafka.m7g.2xlarge (4 per AZ) and confirm with MSK's sizing guidance and a load test. Replication traffic between brokers isn't charged by MSK, but client traffic across AZs is; we turn on rack-aware consumers (fetch from the replica in the same AZ) to cut most of it.
Monthly cost (us-east-1 list prices, 730 hours a month; rounded)
| Item | Math | Monthly |
|---|---|---|
| DynamoDB writes (provisioned) | above | ≈ $294K |
| DynamoDB reads (provisioned, with the storm floor) | above | ≈ $26K |
| DynamoDB storage (90 days hot) | above | ≈ $112.5K |
| S3 archive, a year in | above, plus PUTs | ≈ $2K |
Gateways, 660 × c7g.xlarge | 660 × $0.145 × 730 | ≈ $69.9K |
| Data transfer out | ≈ 310 MB/s average (deliveries, acks, receipts, pongs) ≈ 814 TB: 10 TB × $0.09 + 40 TB × $0.085 + 100 TB × $0.07 + 664 TB × $0.05 | ≈ $44.5K |
| SNS mobile push | assume ~1B pushes/day after coalescing and mutes: 30.4B × $1.00 per million (publish + delivery) | ≈ $30.4K |
ElastiCache (Valkey) routing, 72 × cache.r7g.xlarge | 72 × ~$0.350 × 730 | ≈ $18.4K |
| ElastiCache (Valkey) recent-messages cache | 4 × cache.r7g.xlarge | ≈ $1.0K |
| MSK | 12 brokers × ~$0.82/h (assumed from list prices; confirm) ≈ $7.2K + 24 h of storage ≈ 30 TB ≈ $3K + cross-AZ client traffic ≈ $10K | ≈ $20K |
| Workers (sequencers, delivery, inbox, archiver, presence, push) | assume 90 × c7g.xlarge | ≈ $9.5K |
| NLB | 200 capacity units for 20M active connections, but processed bytes dominate: ≈ 450 MB/s ≈ 1,620 GB/h → 1,620 units × $0.006 × 730 | ≈ $7.1K |
| CloudWatch, SQS, misc. | ≈ $15K | |
| Total | ≈ $650K/month |
About 0.65 cents per daily user a month. Read the table from the top: DynamoDB is two thirds of the bill, and about half of that is bookkeeping (inbox bumps and cursors), not messages. The levers are coalescing harder and reserved capacity for the steady part of the write curve, not cheaper gateways.
Why a TCP listener and not TLS on the NLB? An NLB bills by capacity units, taking the largest of three dimensions each hour. For TLS listeners, one unit covers only 3,000 active connections (TCP: 100,000). 20M TLS connections would be about 6,700 units ≈ $29K a month in that dimension alone, against 200 units for TCP. Terminating TLS in our gateways costs some CPU, which they have spare.
R2.7 Trade-Offs
| Choice | We chose | What we give up |
|---|---|---|
| Group delivery | Push full messages to groups ≤ 100; nudge-and-pull above | Two code paths; large-group members see text a fetch later; a threshold to tune |
| Who numbers a conversation | One sequencer per log partition, with the conditional put as a fence | Order tied to partitioning; a brief pause on rebalance; a Kafka cluster to run. The alternatives: Round 1's claim loop (contention, 6 units a message) or batching counter increments (fewer writes, but a crash between reserving a block and writing its messages leaves holes, so it's only "monotonic, with gaps") |
| Presence accuracy vs cost | Debounced (45 s), viewport-only, change-driven | "Offline" is late; someone not looking at you doesn't learn your status until they do |
| Receipts | Cursors; group summaries; exact lists on request | No per-member push for big groups |
| Our gateways vs API Gateway at 20M sockets | Our own fleet | At this size the managed option costs more and adds forced reconnects. Assume 12M average connections: 12M × 43,800 min ≈ 526B connection-minutes × $0.25 per million ≈ $131K; messages ≈ 24B frames/day ≈ 730B a month: the first 1B at $1.00 per million, the rest at $0.80 ≈ $584K, plus the backend behind it. The 2-hour connection limit forces 20M ÷ 7,200 s ≈ 2,800 reconnects a second at peak, above the default 500 new connections a second (raisable). Our gateways + NLB: ≈ $77K. |
| Capacity mode | Provisioned with auto scaling, plus a read floor for storms | Capacity to manage; a sudden spike beyond the curve is throttled until scaling catches up |
Per million connection-minutes, our fleet costs about \77{,}000 \div 526{,}000 \approx $0.15, against API Gateway's \0.25 before a single message is sent.
R2.8 Failure Modes
| Failure | What you'd see | How the design responds |
|---|---|---|
| An AZ is lost | Connected sockets drop by a third; a reconnect wave | Step 2.5: two-AZ headroom, jitter, admission control, cheap resume. MSK keeps 2 of 3 replicas; ElastiCache promotes replicas; DynamoDB is unaffected. |
| A routing-cluster shard fails over | Registry reads for some users return nothing for a few seconds | Replication is asynchronous, so entries written just before the failover can be lost: those users look offline and get a push instead. Gateways rewrite all their entries within 30 s. Presence entries are rebuilt from gateway state the same way. |
| A hot group | One conversation near its limit | The 100 msg/s limit answers NACK RATE_LIMITED; the partition's other conversations are unaffected unless the whole Kafka partition is hot, in which case we move busy conversations to a dedicated topic partition set. |
| A presence storm (a carrier outage drops a city) | Millions of disconnects in a minute | Debounce absorbs anyone back within 45 s; the sweeper's work is bounded per 200 ms tick; if flap rate stays high, we widen the debounce by configuration. |
| The push provider throttles us | APNs or FCM return errors or throttling responses | Push workers back off per provider; the SQS queue holds the backlog; pushes are coalesced per user so a backlog shrinks as it waits. Tokens reported as invalid or unregistered are deleted from the device item. |
| A sequencer crashes | A partition pauses while Kafka reassigns it | The new owner rebuilds lazily (last seq = the larger of META.last_seq and the newest MSG#); the conditional put fences the old one; duplicates are re-acknowledged and re-published; unacknowledged sends are retried by apps with the same client_msg_id. |
| Multi-device race | Phone and laptop send READ for 20410 and 20417 in either order | "Set if larger" keeps 20417; READ_SYNC carries the maximum. |
R2.9 Production Gotchas
| Gotcha | Symptom | Cause | Fix |
|---|---|---|---|
| Broadcasting presence to everyone | Presence traffic dwarfs messages; the routing cluster's CPU climbs in the rush hour | Every connect and disconnect sent to every contact | Debounce; change-driven writes; viewport subscriptions |
| Per-message receipt rows in big groups | Write throttling on busy groups | A row per member per message | Cursors, "set if larger"; summaries |
| No backoff, or backoff without jitter | Every gateway restart turns into an outage | Apps retry in lockstep | Full jitter; admission control with a close code and a wait |
| A copy of each message per member | Storage and write bills grow with group size; deletes miss copies | Treating chat like a feed | Store once; fan out delivery |
| An eviction policy on the routing cluster | Random users appear offline and get pushes instead of live messages | volatile-ttl or LRU evicting registry entries | noeviction, spare memory, alarm on usage |
| Kinesis on a 100 ms delivery path | P99 far over target with no single slow component | Polling or push propagation delay, twice | A low-latency log (Kafka), measured per stage |
| Sizing gateways to exactly the socket count | An AZ loss becomes a region outage | 400 × 50K = 20M | Two AZs must hold everything |
R2.10 Pillar Check
| Pillar | What Round 2 adds |
|---|---|
| Reliability | Delivery driven by a durable log (replayable, at-least-once, deduplicated by seq); a fenced single owner per conversation; two-AZ headroom and admission control for the reconnect storm; table quotas raised ahead of need REL 1 · REL 5 · REL 10 · REL 11 |
| Performance Efficiency | A 100 ms delivery budget split by stage; Kafka instead of a polling stream; one publish per large-group message; presence only for what's on screen PERF 1 · PERF 3 · PERF 5 |
| Security | TLS terminated in our gateways on every socket; signed, short-lived connect tickets checked without a database call; membership versions so removed members stop receiving within a second SEC 2 · SEC 9 |
| Cost Optimization | ≈ $650K/month, derived; the write bill counted honestly (7.5 units per message, not 1); provisioned capacity instead of on-demand; 90-day tiering to Glacier IR; TCP instead of TLS on the NLB COST 4 · COST 5 · COST 6 |
| Operational Excellence | Per-stage latency, sequencer lag and reconnect rate as first-class metrics; gateways drained over minutes on every deploy OPS 4 · OPS 6 · OPS 8 |
| Sustainability | Light this round: Graviton everywhere; heartbeats end at the gateway; coalesced receipts and nudges; cold history in compressed, infrequently touched storage SUS 3 · SUS 4 · SUS 5 |
Alarms and first actions
| Signal | Alarm | First action |
|---|---|---|
| Delivery P99 (gateway in → gateway out) | > 100 ms for 5 min | Per-stage timings: which of publish, sequence, store, deliver grew? |
Sequencer lag (ingest consumer lag) | > 2 s on any partition | Hot conversation? Rebalance stuck? Scale sequencers |
Delivery lag (sequenced consumer lag) | > 1 s | Scale delivery workers; check the routing cluster's latency |
| Connected sockets per AZ | drop > 10% in 1 min | Reconnect storm procedure (R3.9); check the AZ's health |
| Admission rejections (closed at accept) | sustained > 5 min | Is headroom gone? Add gateways; confirm jitter in the SDK version mix |
| DynamoDB throttled requests | any, sustained | Find the hot key; check provisioned vs consumed and quotas |
| Push failures | > 2% for 10 min | Provider status; token cleanup; worker backoff |
R2.11 Round 2 Rubric and Follow-Ups
What a senior (L6) answer adds over L5
- Stores once and fans out delivery, with a size threshold and a reason for it.
- Knows DynamoDB limits are per partition, and why a growing sort key can't be split around; replaces a contended claim with a single owner and a conditional-write fence.
- Picks a log for latency, not by habit, and budgets the P99 by stage.
- Designs presence around churn: a debounce, change-driven writes, viewport subscriptions, and no reliance on best-effort expiry events.
- Turns receipts into monotonic cursors and handles multiple devices without races.
- Sizes for an AZ loss and its reconnect storm, and notices that the carried fleet size has no headroom.
- Recounts the carried write figure (item rounding, index writes, bookkeeping) and finds the real cost driver.
Follow-up questions
-
"Typing indicators?" Answer: they're presence's little brother: ephemeral, never stored, and only useful to people looking at the chat. The gateway forwards
TYPINGto the conversation's members who have it open (the same viewport idea), rate-limited to one every few seconds per user, and the indicator expires on the receiving side after about 5 seconds unless refreshed. Nothing goes through the sequencer or the table. -
"A user is in 300 groups and comes back after a week." Answer:
RESUMEreturns the conversations that changed since the device's token, from the inbox index, a page at a time. The app fetches the newest page of each conversation as it's opened, not all 300 up front. Badges come fromlast_seq − last_read_seq, so the list is right before any messages are downloaded. -
"Why not assign the sequence number in the gateway and skip the ingest log?" Answer: the gateway that receives a message is whichever one holds the sender's socket, so many gateways would number the same conversation: that's Round 1's race again. The ingest log's partitioning is what gives each conversation one owner without us building a coordination service.
Interview gotchas from this round
| Gotcha | Why it's wrong |
|---|---|
| "More table capacity fixes the busy group" | Limits are per partition, and a growing sort key keeps the hot spot on one partition. |
| "Presence from key-expiry notifications" | They're best-effort: missed while disconnected, and fired when the key is actually deleted. |
| "174K write units covers 174K messages a second" | Items round up to 2 KB, and inbox, cursor and index writes add ~5.5 units per message. |
| "400 gateways for 20M sockets" | Exactly full; one AZ loss leaves a third of users with nowhere to go. |
| "A status field per message" | Delivered and read differ per member and per device; use cursors. |
Round 3 · Architect · "Global, Encrypted, and Huge Channels"
~45 min · Principal (L7) · 4 home regions, each with a standby region · ~500M DAU · ~100M sockets · ≈ 290K messages/s average · 99.99% per region · no acknowledged message lost to a region failure
R3.0 Where We Left Off
This is what the candidate says aloud in the first 60 seconds of Round 3. If you're starting here, it's everything you need from Rounds 1 and 2.
Round 2 in 60 seconds. "We run messaging for 100 million daily users in one region: 20 million sockets at peak, 5 billion messages a day (about 174,000 a second at peak, 660,000 deliveries), 99.99%, delivery P99 under 100 ms. 660 gateways, sized so two AZs hold every socket, accept at most 300 new connections a second each, and apps reconnect with full jitter and resume by cursor. Gateways publish sends to a Kafka topic on MSK, keyed by conversation, so one sequencer owns each conversation: it numbers the message, stores it with a 'must not exist' conditional put (which also fences a stale owner), acknowledges, and publishes it to a second topic. Delivery workers push full messages to groups of up to 100 and a single pub/sub nudge for bigger ones; offline members get coalesced pushes. Presence is debounced 45 seconds and sent only to people who have you on screen. Receipts are cursors that only move forward. 90 days of history live in DynamoDB, older history in compressed segments in S3 Glacier Instant Retrieval. About $650K a month, two thirds of it DynamoDB, about half of that bookkeeping. Open costs: one region; the server can read every message; the largest group is 1,000; and nothing handles retention or residency."
Architecture v2, compact
textapp ─► NLB ─► gateways (660, ≤ 50K each; admission ≤ 300/s) ─► MSK ingest (key = conversation) ─► sequencer (one owner per partition): seq = last + 1 → PutItem MSG#<seq> "must not exist" → ACK ─► MSK sequenced ─► delivery (≤ 100: per-member lookups + direct calls | > 100: SPUBLISH conv:<id> nudge) ─► inbox/cursor updaters ("set if larger") ─► archiver → S3 Glacier IR (> 90 days) routing cluster (noeviction): conn:<user> per-device sorted set, presence + 45 s debounce, delivery cursors
Steps so far
| Step | Problem | Component |
|---|---|---|
| 1.1–1.2 | Push; find the gateway | WebSocket gateways; registry with expiry |
| 1.3–1.4 | Durability; order | Store then ack; gap-free seq via conditional write |
| 1.5–1.6 | Offline; restarts | Log as mailbox + push; jittered reconnect |
| 2.1 | Groups | Store once; push ≤ 100, nudge above |
| 2.2 | Hot sequence | Kafka partition owner + conditional-put fence |
| 2.3–2.4 | Presence; receipts; devices | Debounce + viewport; monotonic cursors; per-device registry |
| 2.5–2.6 | AZ loss; reordering | Headroom + admission; 200 ms quarantine + gap fetch |
Open costs: one region; the server reads everything; groups stop at 1,000; no residency or retention.
R3.1 The Scope Raise
Interviewer: "We're global: about 500 million daily users on every continent, chatting across continents. Direct messages and small groups must be end-to-end encrypted: we should be unable to read them. We're adding community channels with 100,000 or more members, mostly readers, like Discord. EU users' data has to stay in the EU. Workspace admins set retention, and legal can put a hold on data. And we must survive losing a whole region without losing a single message we've acknowledged."
We ask back before we design:
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| Where are the users, and how many conversations cross regions? | Americas 30%, Europe 25%, South Asia 25%, East Asia 20%. About 10% of messages are in conversations whose members live in different regions. | Each conversation needs one place where its order is decided, and users should connect near where they are (step 3.1). |
| What exactly is end-to-end encrypted? | Direct messages and groups up to 1,000, by default. Channels are not. | The server must store and route messages it can't read; features that read text move to devices or go away (step 3.2). |
| How are channels used? | 100K to a few million members. A handful of people post; nearly everyone reads, and most members open a channel rarely. | Pushing every post to every member is waste; channels become pull-first (step 3.3). |
| Which data must stay in the EU? | Message content and history of EU users and EU workspaces, including backups. If an EU user is in a conversation, the conversation follows the EU rule. | Conversations get a residency-pinned home; the stricter rule wins (step 3.4). |
| What can admins and legal set? | Retention from 30 days to forever per workspace; legal holds that stop deletion; user deletion requests. | Deletion must reach DynamoDB, S3 and backups, and holds must override it (step 3.4). |
| What does "survive a region" mean? | A region can disappear. Real-time delivery for its users may degrade for some minutes, but history and push must keep working, and no acknowledged message may be lost. | Acknowledgement must wait until a second region has the message: a recovery point objective (RPO) of zero for messages (step 3.5). |
| Should we keep running our own gateways? | Leadership wants a recommendation, with numbers. | A build-vs-buy analysis (step 3.6). |
Scope change
| Round 2 | Round 3 | |
|---|---|---|
| Users | 100M DAU | ~500M DAU |
| Sockets | 20M | ~100M |
| Messages | 5B/day | 25B/day; ≈ 289K/s average; peaks per region up to ≈ 260K/s |
| Footprint | 1 region, 3 AZs | 4 home regions, each with a standby region (in the same legal area where one exists) |
| Encryption | TLS; we can read content | End-to-end for DMs and groups ≤ 1,000 |
| Largest audience | 1,000-member group | Channels with 100K+ members |
| Data rules | None | EU residency; admin retention; legal holds |
| Availability | 99.99% | 99.99% per region; history and push keep working when real-time degrades |
| Loss on disaster | Survive an AZ | Survive a region; RPO 0 for acknowledged messages |
R3.2 What Breaks in the Round 2 Design
| Round 2 choice | What breaks at the new scope |
|---|---|
| One region | A user in Tokyo talking to one in Paris pays two ocean crossings per message if both connect to Virginia, and one region's outage is everyone's. |
| The server reads message bodies | Under end-to-end encryption, it can't: server-side search, link previews, notification text and content moderation all stop working. |
| Push full messages or nudges to members | A 150K-member channel with 2 posts a second would be 300,000 member deliveries a second from one channel. |
| Receipts and inbox bumps per member | Per-member bookkeeping on every channel post is 150K writes a post. |
| Data where it's convenient | EU content in a US region breaks residency; archives and backups hold copies nobody tracks for retention. |
| DynamoDB in one region, replicated later | Asynchronous replication loses whatever wasn't copied when a region dies. That breaks "no acknowledged message lost". |
| TTL deletes hot items after 90 days | The table type we'll need for zero-RPO logs doesn't support TTL (step 3.5). |
R3.3 New Requirements and API Additions
Device keys for end-to-end encryption. Each device publishes a public identity key and a batch of one-time key packages (MLS's name for "the public material someone needs to add this device to a group"):
httpPOST /v1/devices/d_phone_1/key-packages HTTP/1.1 Authorization: Bearer <access_token> Content-Type: application/json { "identity_key": "base64…", "key_packages": ["base64…", "base64…"], "count": 100 }
httpGET /v1/users/u_802/key-packages HTTP/1.1 Authorization: Bearer <access_token>
httpHTTP/1.1 200 OK Content-Type: application/json { "devices": [ { "device_id": "d_phone_1", "key_package": "base64…" }, { "device_id": "d_laptop_2", "key_package": "base64…" } ] }
Each fetched key package is used once and removed.
A sealed message. The server sees who, where and when; never what:
json{ "type": "SEND_MESSAGE", "client_msg_id": "4e0c1a7e-51f2-4b7e-9f3d-0d6a1c2b9e77", "conversation_id": "c_9Jq1", "sealed": { "protocol": "MLS", "epoch": 42, "ciphertext": "base64…" }, "notify": { "mentions": ["u_913"] } }
Channels.
httpGET /v1/channels/ch_42/messages?after_seq=551000&limit=50 HTTP/1.1 Authorization: Bearer <access_token>
json{ "type": "CHANNEL_OPEN", "channel_id": "ch_42" }
CHANNEL_OPEN and CHANNEL_CLOSE tell the gateway which channel is on screen, so live posts are sent only while it is.
Retention, holds and residency.
httpPUT /v1/workspaces/w_17/retention HTTP/1.1 Authorization: Bearer <admin_token> Content-Type: application/json { "messages_days": 30 }
httpPOST /v1/legal-holds HTTP/1.1 Authorization: Bearer <legal_token> Content-Type: application/json { "scope": { "workspace_id": "w_17" }, "reason": "case-2031-044" }
Every user and workspace carries a residency attribute: "data_region": "EU" or "data_region": "ANY".
R3.4 Design Evolution: Home Regions, Sealed Envelopes, Channels and Disasters
Step 3.1: Alice in Tokyo Chats with Bob in Paris
The problem: Alice (Tokyo) and Bob (Paris) share a conversation. Both expect instant delivery, and each connects to the region nearest them. But a conversation's messages need one order, decided in one place. What would you do?
Primitive: Cloud Disaster Recovery & Multi-Region Active-Active
Step 3.2: Messages Must Be Unreadable by Us
The problem: product wants direct messages and groups up to 1,000 to be end-to-end encrypted: only the members' devices can read them. Today our servers decrypt TLS and see every body. What would you do?
Step 3.3: A Channel Has 150,000 Members, Mostly Readers
The problem: a community channel has 150K members; 45K are online and 3,000 have it open right now. A moderator posts 2 messages a second during an event. What would you do?
Drill: Caching hot product page
Step 3.4: EU Data Must Stay in the EU; Admins Set Retention
The problem: an EU workspace requires that its messages, archives and backups stay in the EU. Its admin sets 30-day retention. Separately, a legal team puts a hold on another workspace. Our history is spread over DynamoDB, S3 archive segments that mix many conversations, and backups. What would you do?
Step 3.5: A Region Is Gone
The problem: eu-central-1 becomes unreachable at peak. It is home to 25% of conversations and holds 25M sockets. The requirement: no message we acknowledged may be lost, and history and push must keep working. What would you do?
Drill: Replication multi-region consistency · Drill: Raft consensus split brain
Step 3.6: Should We Run Our Own Gateways?
The problem: leadership asks: we run 3,300 gateways across four regions plus warm standby. Should we switch to API Gateway WebSockets, or a managed realtime vendor? What would you do?
Loop: Design a Distributed Rate Limiter
Round 3 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 3.1 | Tokyo–Paris chat | Home region per conversation; replicated directory; local sockets via Global Accelerator; gateways forward to home; delivery relays | One side pays the distance |
| 3.2 | Unreadable by us | MLS (RFC 9420) for DMs and groups, every device a member; key service; commits ordered by our sequencer; channels not E2E | Search, previews, moderation, history on new devices, compression |
| 3.3 | 150K-member channel | Pull-first: live posts only to open screens; regional recent caches; summary pushes; lazy member state | No "read by"; fetch on open |
| 3.4 | Residency and retention | Residency-pinned homes; retention jobs; per-conversation-month data keys for crypto-shredding; legal holds override | Stricter rule for mixed chats; a key table to guard |
| 3.5 | Region loss | MRSC message logs (two replicas + witness, same legal area for the US and EU sets), RPO 0; epoch-stamped ownership; cursor reconciliation; MREC for the rest; monthly tables | Cross-region write latency; no TTL; standby capacity |
| 3.6 | Build vs buy | Cost per million connection-minutes, limits, team | A gateway team |
R3.5 Global Architecture
Synthesizing vector architecture diagram...
Every home region is a Round 2 system plus three things: a forwarder that sends each frame to its conversation's home, message tables that don't acknowledge until a second region has the write, and data keys that let us make whole months unreadable. The global layer is thin: an anycast entry point and a directory of IDs.
Trace 1: a Tokyo–Paris message (conversation homed in eu-central-1)
textAlice (Tokyo app) ─► GA edge ─► ap-northeast-1 gateway ─► directory: c_9Jq1 home = eu-central-1 ─► forwarder ─► (≈ 115 ms one way, AWS backbone) ─► eu-central-1 ingest (key c_9Jq1) ─► sequencer: epoch 7 active here? yes → seq 20419 → PutItem MSG#…20419 into ChatMessages-2031-03 (MRSC) └─ returns after Ireland or the Paris witness has it (≈ 10–25 ms) ─► ACK ─► (≈ 115 ms) ─► Alice: tick after ≈ 250–270 ms ─► sequenced ─► delivery: Bob's devices in Frankfurt (direct), Alice's laptop via the Tokyo relay
Trace 2: an E2E group message
Synthesizing vector architecture diagram...
Trace 3: a region failover (eu-central-1 lost)
textt = 0 eu-central-1 unreachable; its MRSC writes stop (no second region can confirm them, so none succeed) t ≈ 1–5 min alarms: health checks, delivery P99, sockets per region; the on-call confirms (R3.9) ownership: HOMESET#eu epoch 7 → 8, active = eu-west-1 (conditional update, MRSC) t + seconds eu-west-1 sequencers take the EU partitions; last seq = max(META.last_seq, newest MSG#), strong reads → continue at the next seq Global Accelerator already sends new connections to healthy endpoints (Ireland for most EU users) t → +30 min warm gateways scale out; admission paces reconnects; REST history and push already work return failback is planned: drain, epoch 8 → 9 with active = eu-central-1, rebuild caches
R3.6 Numbers and Cost
Traffic per home region (a planning split; each region planned for its own peak at 3× its average; messages counted by the conversation's home)
| Home region | Share | DAU | Sockets (20%) | Messages/day | Average/s | Peak/s | Gateways |
|---|---|---|---|---|---|---|---|
| us-east-1 | 30% | 150M | 30M | 7.5B | 86,806 | ≈ 260K | 990 |
| eu-central-1 | 25% | 125M | 25M | 6.25B | 72,338 | ≈ 217K | 825 |
| ap-south-1 | 25% | 125M | 25M | 6.25B | 72,338 | ≈ 217K | 825 |
| ap-northeast-1 | 20% | 100M | 20M | 5B | 57,870 | ≈ 174K | 660 |
| Total | 500M | 100M | 25B | 289,352 | 3,300 |
Gateways use Round 2's rule: (two AZs hold everything, plus 10%). ap-northeast-1 is exactly Round 2's system. Each standby region keeps a warm quarter of its home's gateways: 825 more.
Cross-region forwarding. Assume 10% of messages are in conversations with members in another region: 2.5B a day ≈ 28,900 a second on average. Each costs one forwarded send (~1 KB) plus remote deliveries (~1.5 KB on average): TB a day ≈ 190 TB a month × $0.02 per GB (the US and EU inter-region rate; Asia is higher) ≈ $3.8K.
Synchronous replication. Every stored message is written to the standby replica too (about 25 TB a day). DynamoDB doesn't charge cross-Region data transfer for global-table replication, in either mode; we pay for the replicated writes and the storage (below), not the bytes. The witness adds no storage or replicated-write charges (as AWS describes it; confirm on the pricing page). The same holds for the asynchronously replicated bookkeeping (inbox items, cursors, directory).
Channel read load. Assume 200M daily users open channels 5 times a day: 1B opens ≈ 11,600 a second on average, ≈ 35,000 at peak, each ≈ 20 KB from a regional recent cache: ≈ 700 MB/s (≈ 5.6 Gbps) at peak, ≈ 609 TB a month out. Live posts to open screens are small by comparison (step 3.3).
Storage per region and tier (hot = current + previous month, ~45 days on average, two MRSC replicas; archive a year in, compressed 3× for channel posts and not at all for E2E messages; DMs and groups are E2E by default (step 3.2), so we assume ~90% of messages are E2E and ~10% are channel posts; two copies, in the same legal area where one exists)
| Home region | Hot (DynamoDB, 2 replicas) | Archive a year in (S3 Glacier IR, 2 copies) |
|---|---|---|
| us-east-1 | 7.5 TB × 45 × 2 = 675 TB | 7.5 × 320 days × 0.93 × 2 ≈ 4,464 TB |
| eu-central-1 | 6.25 × 45 × 2 ≈ 563 TB | 6.25 × 320 × 0.93 × 2 = 3,720 TB |
| ap-south-1 | ≈ 563 TB | 3,720 TB |
| ap-northeast-1 | 5 × 45 × 2 = 450 TB | 5 × 320 × 0.93 × 2 = 2,976 TB |
| Total | 2,250 TB × $0.25/GB ≈ $562.5K/month | ≈ 14,880 TB × $0.004/GB ≈ $59.5K/month |
(The factor 0.93 is 0.1 × ⅓ for channel text + 0.9 × 1 for ciphertext.)
DynamoDB writes. Round 2's $294K a month was for 5B messages a day at 7.5 units each; 25B is 5× that, ≈ $1.47M. Every write is then applied again in a second region (the MRSC replica for messages, the MREC standby for the rest). We assume replicated writes cost about the same as local ones (confirm on the current price list): another ≈ $1.47M, ≈ $2.94M in total.
Monthly cost (list prices, us-east-1 rates where regional prices differ; other regions cost somewhat more; rounded; storage a year in)
| Item | Basis | Monthly |
|---|---|---|
| DynamoDB writes, incl. the second-region copy | above | ≈ $2.94M |
| DynamoDB storage, hot | above | ≈ $562.5K |
| Gateways, 3,300 + 825 warm | 4,125 × $0.145 × 730 | ≈ $436.6K |
| Data transfer out | Round 2's ≈ 814 TB × 5 ≈ 4.07 PB, at ≈ $0.05/GB beyond the first tiers (Asian regions cost more), + ≈ 609 TB of channel reads | ≈ $250K |
| Push (SNS / AWS End User Messaging) | Round 2 × 5 | ≈ $152K |
| ElastiCache (Valkey) | routing clusters (Round 2 × 5 ≈ $92K) + channel recent caches (≈ $20K) | ≈ $112K |
| DynamoDB reads | Round 2 × 5 | ≈ $130K |
| MSK | Round 2 × 5 | ≈ $100K |
| Global Accelerator | fixed fee + a data-transfer premium per GB that depends on the edge and region pair; ≈ 5 PB a month at an assumed $0.02 average | ≈ $100K |
| Workers, key service, directory | Round 2 × 5 + ≈ $10K | ≈ $57.5K |
| S3 archive (two copies) + replication transfer | $59.5K + ≈ 707 TB a month × $0.02 per GB (US/EU rate; Asia higher) ≈ $14.1K | ≈ $73.6K |
| Cross-region transfer (forwarding only; global-table replication has no transfer charge) | above | ≈ $3.8K |
| NLBs | Round 2 × 5 | ≈ $35.5K |
| KMS (data keys per conversation-month) | ≈ 3B key operations × $0.03 per 10,000 | ≈ $10K |
| CloudWatch, SQS, misc. | ≈ $50K | |
| Total | ≈ $5.01M/month |
About 1 cent per daily user a month. DynamoDB is about 72% of the bill (writes, storage and reads), and half of the write line exists only because every write lands in two regions. That's the price of RPO 0 and of standby state. The levers, in order:
- Replicate only what can't be rebuilt. Inbox bumps (3 of the 7.5 units) can be recomputed from memberships and each conversation's latest
seq. Keeping them regional saves 0.4 \times \1.47\text{M} \approx $590\text{K}$ a month; after a failover, inboxes are re-sorted in the background. - Reserved capacity (a one-year commitment on provisioned capacity) cuts the committed part roughly in half at list prices, but it can't be bought for replicated write capacity. It applies only to single-Region tables: for example the inbox tables kept regional after lever 1, not the MRSC message logs or the MREC standby copies.
- Coalesce harder: read cursors once every few seconds per member instead of once a second.
R3.7 Trade-Offs
| Choice | We chose | What we give up |
|---|---|---|
| Home region vs multi-writer | One home per conversation; others forward | One side of a cross-region chat pays the distance. Multi-writer would need cross-region agreement on every seq, or accept last-writer-wins losing messages. |
| MRSC vs MREC for message logs | MRSC: RPO 0, conditions checked against the latest version | 10–60 ms more per write; no TTL, no transactions; three regions per home set. MREC for everything else, with an RPO of about a second. |
| E2E vs server features | MLS for DMs and groups; channels in the clear | Server search, previews, content moderation, notification text, compression; device keys to manage |
| Push vs pull for channels | Pull-first; live posts only to open screens | Members see posts when they open; no "read by" |
| Residency | Pin homes; stricter rule wins; crypto-shredding for archives and backups | Mixed chats always go through the EU; a key table as sensitive as the data |
| Build vs buy | Our gateways | A team on call; an annual review with a vendor quote |
Closing the loop. The opening question was: how do we get a message to every device of the right people, in order, instantly if they're online and reliably if they're not? The answer is now:
- In order: one owner per conversation numbers every message, and a number exists only if its message does. In one region that owner is a log partition; across regions it's the conversation's home, fenced by a conditional write that every region checks against the latest version.
- Every device of the right people: store once; deliver per device through a registry that knows each device's gateway; for big audiences, publish once and let screens that are open pull.
- Instantly if online: a persistent socket, a budget for every stage, and presence and receipts that don't flood the path.
- Reliably if not: the log is the mailbox. A cursor says what's missing, a push says something is, and an acknowledgment is only given once the message would survive losing the region that took it.
R3.8 Failure Modes
| Failure | What you'd see | How the design responds |
|---|---|---|
| A region outage | Health checks fail; sockets drop; sequencers there can't commit | Step 3.5: epoch flip to the standby; RPO 0 for messages, ~1 s for bookkeeping; history and push first, sockets as capacity scales. |
| The standby or the witness is down (home healthy) | MRSC write latency changes | Writes still need 2 of 3: home plus the remaining one. Losing a second of the three stops writes for that home set, by design; we alarm on either. |
| A key-service outage | New devices can't join groups; new E2E conversations can't start | Existing groups keep working (their keys are on the devices). The key service is replicated to every region (public keys only), so this needs more than one region to fail. |
| A channel storm (an announcement to 2M members) | Opens spike in every region | Served from regional recent caches; announcement pushes are sent at a controlled rate over a few minutes; open-screen posts are one publish. |
| A retention job error | Deleting too much, or not deleting | Jobs run with dry-run counts first and a per-run cap; key destruction waits a grace period (e.g. 7 days) after the retention date; holds are checked on every item and key; every action is audited. A job that falls behind is paged before the deadline. |
| Cross-region forwarding degrades | Cross-region chats slow down | Same-region chats are unaffected; forwarders queue with a bounded buffer and shed with NACK (apps retry with the same client_msg_id). |
R3.9 Runbook and Incident Response
Golden signals, per region OPS 8 · REL 6
| Signal | Alarm | Severity | First action |
|---|---|---|---|
| Connected sockets per AZ | drop > 10% in 1 min | P1 | Reconnect storm procedure below |
| Delivery P99 (same region) | > 100 ms (ap-south-1: 150 ms) for 5 min | P2 | Per-stage timings: forward, sequence, MRSC write, deliver |
Fan-out lag (sequenced consumer lag) | > 1 s for 5 min | P2 | Scale delivery workers; check the routing cluster |
| Reconnect rate (new connections/s) | > 3× the same hour last week | P2 | Deploy in progress? AZ problem? A client release? |
| Push failures | > 2% for 10 min | P3 | Provider status; token cleanup; worker backoff |
Gap requests (SYNC_GAP/s) | > 3× baseline | P3 | A delivery path dropping or reordering frames; recent deploy? |
| MRSC write latency | P99 > 2× baseline for 5 min | P2 | Standby or witness region health |
| Retention jobs behind schedule | any job > 24 h late | P3 | Find the stuck policy; check holds and throttling |
Reconnect storm procedure REL 5 · OPS 10
- Confirm the cause. Sockets per AZ (a whole AZ?), a gateway deploy in progress (stop it), or a client release with broken backoff (check the reconnect rate by app version).
- Don't raise admission limits first. Check what's behind the gateways: DynamoDB read throttles, routing-cluster CPU, MSK. The admission cap protects them.
- Add capacity to the surviving AZs if headroom is below 20% (CLI 3). New gateways take reconnects as they come up.
- Watch the plateau. Admissions should run flat at the cap and the backlog should shrink; the last users are usually back within a couple of minutes.
- Afterwards: check presence (a wave of false "offline" means the debounce was too short), and write the post-incident review.
Region failover procedure REL 13 · OPS 7
- Confirm the region is impaired (health checks, MRSC errors, sockets), and decide to fail over. This is a human decision with a written threshold; automation prepares every step.
- Dial the region's Global Accelerator endpoint group to 0% (CLI 5), so no new connections go there even if it half-recovers.
- Flip ownership with the conditional update (CLI 6). Confirm standby sequencers report the new epoch.
- Scale the standby's gateways and workers from warm pools (CLI 3); admission paces reconnects.
- Verify: sends acknowledged, gap requests normal, push flowing, history readable.
- Failback later, planned, in the reverse order.
Go deeper: CLI playbook
Plain commands an on-call engineer runs, one at a time. Replace the IDs and ARNs with real ones.
text# 1. Active connections on a gateway NLB (per minute) aws cloudwatch get-metric-statistics --region eu-central-1 --namespace AWS/NetworkELB --metric-name ActiveFlowCount --dimensions Name=LoadBalancer,Value=net/chat-gw/0123456789abcdef --statistics Average --period 60 --start-time 2031-03-01T18:00:00Z --end-time 2031-03-01T19:00:00Z # 2. Fan-out lag: delivery consumer group on the sequenced topic aws cloudwatch get-metric-statistics --region eu-central-1 --namespace AWS/Kafka --metric-name MaxOffsetLag --dimensions "Name=Cluster Name,Value=chat-msk" "Name=Consumer Group,Value=delivery" "Name=Topic,Value=sequenced" --statistics Maximum --period 60 --start-time 2031-03-01T18:00:00Z --end-time 2031-03-01T19:00:00Z # 3. Scale gateways (here: the standby region) aws ecs update-service --region eu-west-1 --cluster chat --service gateways --desired-count 825 # 4. DynamoDB write throttling on this month's message table aws cloudwatch get-metric-statistics --region eu-central-1 --namespace AWS/DynamoDB --metric-name WriteThrottleEvents --dimensions Name=TableName,Value=ChatMessages-2031-03 --statistics Sum --period 60 --start-time 2031-03-01T18:00:00Z --end-time 2031-03-01T19:00:00Z # 5. Stop sending new connections to the impaired region (Global Accelerator's API is called in us-west-2) aws globalaccelerator update-endpoint-group --region us-west-2 --endpoint-group-arn arn:aws:globalaccelerator::111122223333:accelerator/1234abcd-abcd-1234-abcd-1234abcdefgh/listener/0123abcd/endpoint-group/eu01 --traffic-dial-percentage 0 # 6. Flip conversation ownership for the EU home set (conditional on the current epoch) aws dynamodb update-item --region eu-west-1 --table-name ChatControl --key '{"PK": {"S": "HOMESET#eu"}, "SK": {"S": "OWNER"}}' --update-expression "SET active_region = :r, epoch = :new" --condition-expression "epoch = :old" --expression-attribute-values '{":r": {"S": "eu-west-1"}, ":new": {"N": "8"}, ":old": {"N": "7"}}' # 7. Confirm a message table's replicas and consistency mode aws dynamodb describe-table --region eu-west-1 --table-name ChatMessages-2031-03
R3.10 Pillar Check
| Pillar | What Round 3 adds |
|---|---|
| Reliability | RPO 0 for acknowledged messages through MRSC; no split brain (writes need two of three regions); epoch-stamped ownership; warm standby per home region; region isolation tested with fault injection (AWS FIS can pause replication to a replica) REL 10 · REL 12 · REL 13 |
| Performance Efficiency | Users connect at the nearest edge; forwarding only for cross-region chats; channels pull-first with regional caches; per-region latency targets stated honestly PERF 1 · PERF 4 |
| Security | End-to-end encryption with MLS for DMs and groups; device keys, safety numbers, key transparency; envelope encryption with KMS and crypto-shredding; residency as a data classification that drives placement; audited holds and deletions SEC 2 · SEC 7 · SEC 8 · SEC 9 |
| Cost Optimization | ≈ $5.01M/month, derived; DynamoDB named as 72%; replicate only what can't be rebuilt; reserved capacity for the single-Region tables; build vs buy priced per million connection-minutes COST 7 · COST 8 · COST 11 |
| Operational Excellence | Golden signals per region; reconnect-storm and failover procedures; human-approved failover with prepared automation OPS 7 · OPS 8 · OPS 10 |
| Sustainability | Regions chosen to put sockets near users; channels send live posts only to open screens; monthly tables dropped whole; history compressed and moved to cold storage; retention deletes what nobody needs SUS 1 · SUS 2 · SUS 4 |
R3.11 Round 3 Rubric and Follow-Ups
What an architect (L7) answer adds over L6
- Gives every conversation one home for ordering, and explains what goes wrong with multi-writer (last writer wins losing messages).
- Distinguishes "encrypted" from "end-to-end", picks a group protocol for a reason (MLS: one ciphertext per message and an ordered commit log we already have), and lists what the product loses.
- Changes the delivery model for huge audiences instead of scaling the old one.
- Treats residency and retention as placement and key management, including archives and backups, with holds that override.
- Turns "no acknowledged message lost" into RPO 0 and a concrete mechanism (synchronous replication before the ack), and knows its costs: latency, no TTL, no transactions, three regions.
- Prices the system per region, finds where the money goes, and makes build vs buy a numbers question.
Follow-up questions
-
"A user moves from Tokyo to Paris. Do their conversations move?" Answer: their connections move at once (Global Accelerator picks the nearest region). Their conversations' homes don't: moving a conversation means changing the region that numbers it. For a conversation that should move (say, all active members now live in Europe), a background job can migrate it: pause its sequencer, copy its log to the new home, flip its directory entry with a new epoch, resume. We only do it when it pays off in latency, never automatically on a single trip.
-
"Can we offer search in end-to-end encrypted chats?" Answer: on the device, yes: each device indexes what it can decrypt. Server-side search would need the server to read or index the text, which E2E rules out. The honest product answer is "search what's on this device", plus encrypted backups that restore a device's history and index.
-
"Why not put channels on MRSC too, and push to every member?" Answer: channel posts do go to the MRSC log (an acknowledged post shouldn't be lost either). What we don't do is per-member work: 150K deliveries and bookkeeping writes per post would cost more than the whole channel is worth, for members who mostly won't look today.
Interview gotchas from this round
| Gotcha | Why it's wrong |
|---|---|
| "Global tables give us zero data loss" | Default global tables replicate asynchronously; RPO is the replication delay, and last writer wins decides conflicting writes, including during a failover. RPO 0 needs the strong-consistency mode. |
| "Encryption at rest plus TLS is end-to-end" | We still hold the keys and read every message. |
| "E2E for the 100K-member channel too" | A secret shared with 100K strangers' devices protects little, and channels need moderation and search. |
| "TTL will enforce the retention deadline" | TTL deletes eventually, doesn't touch archives or backups, and isn't available on MRSC tables. |
| "Keep the EU data here, just don't show it" | Residency is about where data is stored, including backups and archives. |
Loop Closer: Interview Strategy for All Three Rounds
How to Run Each 60-Minute Round
| Time | Round 1 | Round 2 | Round 3 |
|---|---|---|---|
| 0–5 min | Scoping: 1-on-1 or groups? offline behaviour? history? devices? | Restate the Round 1 design in 60 seconds | Restate the Round 2 design in 60 seconds |
| 5–15 min | Requirements + API: frames, client message ID, cursors | Scope raise → what breaks | Scope raise → what breaks |
| 15–40 min | Steps 1.0–1.6: polling → WebSocket → registry → store then ack → gap-free seq → log as mailbox → jittered reconnect | Steps 2.1–2.6: store once, push vs nudge; one owner per conversation; presence debounce; cursors; AZ storm; gap quarantine | Steps 3.1–3.6: home regions; MLS; pull-first channels; residency and retention; RPO 0 failover; build vs buy |
| 40–50 min | Numbers: traffic, gateways, 12 write units per message, cost | Numbers: re-check the carried figures (gateway headroom, 7.5 units per message), tiering, cost | Numbers: per region, replication, storage tiers, cost per connection-minute |
| 50–60 min | Failures + pillar check | Failures + pillar check | Failures, runbook, pillar check |
For how to spend a single 45-minute round, see the 45-minute interview blueprint.
The Two Sentences That Matter Most
- Opening a round: "Before I design: what happens when the recipient is offline, how long do we keep history, and how many devices does a user have?"
- When the scope is raised: "Here's what breaks in the current design, and I'll fix it in this order: anything that can lose or misorder an acknowledged message, then anything that can take delivery down, then cost."
Well-Architected Review Sheet
Interviewers rarely ask "which pillar is this?". They ask the pillar's question in plain words. Rehearse one sentence per row.
| Pillar | Question you'll hear | One-sentence answer | Round | Backed by |
|---|---|---|---|---|
| Reliability | "What if the phone retries a send?" (REL 4) | The client message ID makes it idempotent: the server answers with the original sequence number. | 1–2 | Steps 1.3, 2.2 |
| "What happens when a gateway or an AZ dies?" (REL 5) | Apps reconnect with full jitter, gateways admit at a fixed rate, and two AZs already hold every socket. | 1–2 | Steps 1.6, 2.5 | |
| "Can you lose a message in a region failure?" (REL 13) | Not one we acknowledged: the write returns only after a second region has it. | 3 | Step 3.5 | |
| Performance | "How is delivery under 100 ms?" (PERF 1) | A persistent socket, a low-latency log, and a budget for every stage that adds up to about 50 ms. | 2 | Step 2.2 |
| "How do users far away get fast service?" (PERF 4) | They connect at the nearest edge and region; only cross-region chats pay the distance, once. | 3 | Step 3.1 | |
| Security | "Can you read users' messages?" (SEC 9) | Not in DMs and groups: devices encrypt with MLS and we store sealed envelopes; channels are server-readable by design. | 3 | Step 3.2 |
| "How do you delete data in backups?" (SEC 8) | Bodies are encrypted with per-conversation-month keys; destroying the key makes every copy unreadable. | 3 | Step 3.4 | |
| "How do you keep EU data in the EU?" (SEC 7) | Residency is a data class on users and workspaces that pins a conversation's home and every copy to EU regions. | 3 | Step 3.4 | |
| Cost | "What does it cost, and where does the money go?" (COST 5) | About $7.6K, $650K and $5.01M a month; DynamoDB bookkeeping writes dominate, not the gateways. | 1–3 | R1.7, R2.6, R3.6 |
| "Why not the managed WebSocket service?" (COST 11) | At our volume its per-message price alone is about five times our whole gateway cost; we review it yearly with the team's cost included. | 1–3 | R1.8, Step 3.6 | |
| Operations | "How do you know chat is healthy?" (OPS 8) | Sockets per AZ, delivery P99, fan-out lag, reconnect rate, push failures and gap requests, each with a first action. | 2–3 | R2.10, R3.9 |
| "How do you deploy gateways without an outage?" (OPS 6) | Drain over minutes with close code 1001, never all at once, with admission control on the receiving side. | 1–2 | Steps 1.6, 2.5 | |
| Sustainability | "Where is this system wasteful?" (SUS 4) | Old history in hot storage and pushes nobody reads; we tier after 90 days and send channel posts only to open screens. | 2–3 | R2.6, Step 3.3 |
Rubric Across Levels
| Dimension | L5 (Round 1) | L6 (Round 2) | L7 (Round 3) |
|---|---|---|---|
| Connections | WebSockets over polling; a registry with expiry; jittered reconnects. | Per-device registry; sized for an AZ loss and its reconnect storm; admission control. | Nearest-region entry; build vs buy priced per connection-minute. |
| Order and durability | Store then ack; client message ID; gap-free sequence claimed by the message write. | One owner per conversation via log partitioning, fenced by the conditional write; delivery from a durable log. | One home per conversation across regions; RPO 0 via synchronous replication; epoch-stamped ownership. |
| Fan-out | One recipient. | Store once; push small groups, nudge large ones; presence debounced and viewport-only. | Pull-first channels; live posts only to open screens. |
| State that multiplies | Inbox updated with "set if larger". | Receipts as monotonic cursors; group summaries; honest write-unit count. | Lazy per-member state for channels; replicate only what can't be rebuilt. |
| Numbers | Traffic, gateways, write units, a monthly cost. | Re-checks carried figures (headroom, item rounding, index writes) and finds the cost driver. | Prices regions, replication, tiers and build vs buy. |
| Well-Architected trade-offs | Managed vs self-run at small scale, stated as a judgment call. | Latency budget vs log choice; presence accuracy vs cost. | RPO vs latency; E2E vs server features; residency vs convenience. |
| Evolving under new scope | Builds from the baseline one problem at a time. | Opens with what breaks; fixes loss and misordering first. | Changes what the server is allowed to know and where data lives, and says what the business must decide. |