πŸ“š Study Notes / Home / HLD / Session 14
Session 14 Β· Case Study β€” Messaging & Netflix

How chat apps and Netflix actually work

Today we stop learning building blocks one at a time and instead build two real systems: a messaging app like WhatsApp, and a video platform like Netflix. We assume you've studied none of this before β€” every topic opens with a tiny "explain like I'm 5" story, then we slowly go deeper with real data models, real diagrams, and the trade-offs an engineer actually wrestles with. Take it slow; by the end you'll be able to sketch both systems on a whiteboard.

⏱ 40 min readπŸ“– 12 topics

1 The 5-step design approach


Explain like I'm 5

Before you build a sandcastle, you don't just start grabbing sand. You decide what you're building (a castle? a tunnel?), how big it should be, how strong it has to be, and how much sand you'll need. System design is the same: we ask a few simple questions in a fixed order, every single time, so we never forget a step.

Whenever you're handed a "design X" question, follow the same recipe. The first four steps are about understanding the problem (spend ~20–25 minutes here), and only then do you start the actual system design (~20–25 minutes). Today we'll use the recipe to design a messaging application.

❓
Problem statement
What are we even building? Reason by analogy.
β†’
🧩
Functional requirements
Who does what, and what happens. The MVP features.
β†’
🎚️
Non-functional reqs
Constraints: ordering, consistency, latency…
β†’
πŸ“
Scale estimation
How big? The numbers dictate the design.
β†’
πŸ—οΈ
System design
Now, and only now, draw the boxes.
The one big idea

The first four steps are pure thinking β€” no boxes, no arrows. Most interview failures come from jumping straight to "I'll use Kafka and Cassandra" before anyone has agreed on what the system must do or how big it is. Resist the urge. The scale and the requirements dictate the design.

Recap Five steps, always in order: Problem statement β†’ Functional requirements β†’ Non-functional requirements β†’ Scale estimation β†’ System design. The first four are about understanding the problem; only the last is drawing the architecture.

2 Problem statement & reasoning by analogy


Explain like I'm 5

If your friend says "build me a toy," you'd ask: a toy car? a doll? a kite? They're all "toys" but wildly different to build. "Design a messaging app" is just as vague β€” so first we list real apps that already exist and figure out which kind of messaging we mean.

Our prompt is "Design a messaging application." That's huge and vague. The trick is to reason by analogy: find existing companies/systems that offer a similar product, which gives you an overview of the umbrella of different scopes you could pick from.

Anchor appSimilar to…What it really is
WhatsAppTelegram, Signal, Hike, iMessage, ArattaiMobile-first realtime chat, < 3 sec for the other person to receive a message. Built as an SMS replacement.
SlackMS Teams, FlockB2B enterprise chat. Group/channel-centric β€” a channel can have 100k employees.
Facebook MessengerInstagram Messages, Orkut messagesMessaging for social media. Primarily 1-1 messages.

What not to build

Just as important: scope out the things that look similar but are a different problem entirely. Naming these shows you understand the boundary.

AppSimilar to…Why it's a different problem
Discordβ€”Voice chat β€” needs ultra-low latency.
Orkutβ€”Social media, not messaging.
TwitterX, Threads (owned by Meta)Social media.
SkypeFaceTime, ZoomVideo chat & screen sharing.
HangoutsGoogle MeetVideo calling.
Snapchatβ€”Privacy-focused β€” disappearing messages.
Recap "Design a messaging app" is ambiguous. Reason by analogy to map out the scope: WhatsApp (mobile-first 1-1 realtime chat), Slack (enterprise, huge groups), Messenger (social 1-1). Explicitly exclude voice/video/social products like Discord, Zoom, Twitter, and Snapchat. We'll design a WhatsApp-style app.

3 Functional requirements (the MVP)


Explain like I'm 5

A functional requirement is a thing the app can do for someone. Think about the actors (the people and systems using the app) and ask "what can each of them do?" A user can send a message, read a message, start a chat… each of those is a feature you write code for.

A functional requirement is some feature/functionality you offer to the client (a user, or another internal system). You explicitly expose an API / write code for each one. The guiding sentence is: "Who is doing what, and based on that, what happens?"

Actors

  • Users β€” the sender and the receiver.
  • Organisation (only for enterprise software like Slack) β€” relationships like org ⇔ admin ⇔ HR.

Whenever you think of features, think of what things the actors can do in the app.

Keep the "minimal" in MVP

MVP features are not a feature-suggestion competition. Follow KISS: keep it simple, silly. And remember: practice makes perfect β€” but only correct practice. Incorrect practice actively harms you.

TierMeaning
MVP features (v0)Perfect for discussion β€” what you build now.
Future scope (v1+)Good to have, but not absolutely necessary.
Bad (v-never)Get yourself rejected in an interview.

MVP feature 1 β€” A user can send messages to other users

sendMessage(
  sender_id:    uuid,
  recipient_id: uuid,
  message:      json
): ack | failure          # user-facing
Why is message just json?

At this moment it would be premature to nail down the exact contents of a message. Keep it as JSON for now β€” the contents depend on how "fancy" we want the app to be, and they'll frequently change in the future. So we make it schemaless.

Message status

Users should be able to see the status of the messages they've sent:

StatusMeaningUI
SentReceived by the backend server.Single tick βœ“
DeliveredDelivered to the recipient's app, but not read yet.Double tick βœ“βœ“
ReadThe recipient has actually read the message.Double tick βœ“βœ“ (blue)

Rich messages

  • React to messages with Emojis
  • Videos / Images
  • Gifs

MVP feature 2 β€” Receive & view past messages (most recent first)

Users should be able to receive messages and view the past messages in a conversation, most recent first. Users should only be able to view messages of a conversation they're a part of.

getMessages(
  user_id:          uuid,
  conversation_id:  uuid,        # identifies the conversation (Tarun+Sanjana, Harini+Pallavi, …)
  pagination_offset: int,
  pagination_limit:  int
): list of messages       # user-facing

Updating/deleting already-sent messages is extremely hard to do correctly, as is full profile management β€” so they're future scope, not MVP.

MVP feature 3 β€” View your conversations (most recent first)

getConversations(
  user_id:          uuid,
  pagination_offset: int,
  pagination_limit:  int
): list of conversation   # each: {conversation_id, name, unread count,
                          #        quick summary of the latest message}  -- user-facing

Future scope (v1+) β€” good to have, but not the MVP

  • Notifications β€” in-app and push.
  • Broadcast the same message to multiple people (batch processing).
  • Groups β€” users can participate & send messages inside groups. A single group can potentially have 100,000+ users:
    sendMessageGroup(sender_id: uuid, group_id: uuid, message: json): ack | failure
    getMessagesGroup(user_id: uuid, group_id: uuid,
                     pagination_offset: int, pagination_limit: int): list of messages
  • Multi-device support β€” extremely hard to do correctly (needs CRDTs).
  • Group management β€” creating, adding people, permissions, group description.
  • Contacts β€” suggest / load contacts from the user's phone.
  • Message forwarding.
  • Start / delete a conversation, and mark as spam β€” start a new conversation by selecting a recipient from contacts/friends.
  • Syncing messages between the app cache & the cloud.
  • End-to-end encryption.
Recap Identify the actors (sender, receiver), then list features as APIs you'd write code for: sendMessage, getMessages, getConversations. Keep message schemaless JSON. Message status is Sent (βœ“) / Delivered (βœ“βœ“) / Read (blue βœ“βœ“). Keep MVP minimal (KISS); push groups, notifications, multi-device, E2E encryption, etc. to future scope.

4 Non-functional requirements (design goals)


Explain like I'm 5

If functional requirements are "the app can send a message," non-functional requirements are "the message must arrive in the right order, quickly, and must never get lost." Same feature, but rules about how well it behaves.

A non-functional requirement is an additional constraint/behavior you want the system to adhere to β€” not a feature you offer the client. For a messaging app, three matter most: message order, idempotency, and the consistency / availability / latency trade-off.

4.1 Message order

Message ordering must be maintained: the recipient must see messages in the same order the sender sent them.

Why is message order important?

The meaning of a conversation can change based on message order. "I'm going to kill it at the interview" then "wish me luck" reads very differently if reversed. Communication is the backbone of human society, and it needs order.

4.2 Idempotency

Messaging apps are typically mobile-first, and mobile users don't always have perfect internet. So these apps frequently deal with network failures. In a lot of scenarios, the frontend is coded to automatically retry sending if it fails β€” we don't want the user to re-type their whole message.

<input id="message" type="text" placeholder="send a message" onkeydown="sendMessage" />
<script>
    function sendMessage() {
        message = document.getElementById("message").text;
        sendWithRetries(message);
    }

    function sendWithRetries(message) {
        fetch("/backend-api-url/sendMessage", "POST", {
            message: message
        }).then(() => console.log("success"))
          .error(() => {
              // automatic retry after 1 second
              setTimeout(() => sendWithRetries(message), 1)
          })
    }
</script>
Automatic retries can cause duplicates!

Consider this sequence: the request does reach the server, the server acts on it (stores the message), and responds with an ACK β€” but the ACK gets lost and never reaches the user. The frontend wrongly assumes the server never received the request and retries. Now the server stores the message again β†’ duplicates in the database.

The fix is to make the operation idempotent.

What is idempotency?

A function f(x) is idempotent if and only if f(x) = f(f(x)) β€” repeated application does nothing more than a single application.

  • f(x) = xΒ² is NOT idempotent: f(4) = 16 but f(f(4)) = 256.
  • f(x) = |x| IS idempotent: f(-5) = 5 and f(f(-5)) = 5.

Any WRITE (post/update/delete) API endpoint should be idempotent if your frontend can retry.

Q: Does the backend engineer care whether the frontend used auto-retries?

No β€” they don't care, and they shouldn't have to. Maybe the frontend doesn't retry today, but someone adds auto-retries later. So irrespective of what the frontend does, always try to make backend POST APIs idempotent whenever possible.

Don't confuse duplicates with intentional repeats

If a user purposely resends the same message again and again, that must be allowed β€” when you want to annoy your friends, sending "Hi" 20 times in a row should work! We only want to prevent duplicates caused by automatic retries, not legitimate repeats.

4.3 Consistency vs Availability vs Latency (PACELC)

Don't jump to an answer. Reason it out by asking the right questions first.

Q: What is "the data"? The messages that are being sent.

Q: What does eventual consistency (stale reads) mean here? Messages out of order on the recipient's side, or not being able to see the latest message. Two concrete cases:

SideScenario at t=0 / t=1Effect
Sender sidePujan sends to Minesh at t=0, server ACKs. Pujan reloads at t=1 and can't see his own message (stale read).Pujan thinks the app deleted his message. (It's not lost β€” he'll see it eventually.) Solved by read-your-write consistency.
Receiver sidePujan sends to Minesh at t=0, server ACKs. At t=1 Minesh tries to load and can't see it.Pujan says "I sent it," Minesh says "I didn't get it." (Not lost β€” arrives eventually.)

Q: Can we afford stale reads here? No, we cannot. Q: What does data loss mean? Sender sent a message, the server ACK'd, but the message actually got lost by the backend. Q: Can we afford data loss? Very, very bad. No.

Communication needs strong consistency

Communication is the backbone of human society β€” it needs strong consistency. But availability and low latency also matter: we need realtime chat, so if PersonA sends to PersonB and PersonB is online, PersonB should receive it within a few seconds.

Our latency requirement: Low latency < 5 seconds.

But PACELC says we can't have all three!

The PACELC theorem says we can't have high consistency together with high availability and low latency. So is it possible to achieve all three? No β€” PACELC says it's impossible. However, we can create the illusion of consistency with availability & low latency: in reality we give up on consistency just a little bit, but to the end user things still appear consistent 99.xxx% of the time. (We build this illusion in Topic 8.)

Recap Three NFRs dominate: (1) Message order must be preserved (meaning depends on it); (2) Idempotency β€” make WRITE APIs idempotent so frontend auto-retries don't create duplicates (but allow intentional repeats); (3) We need strong consistency, availability, and <5s latency β€” which PACELC says is impossible together, so we'll build the illusion of consistency instead.

5 Scale estimation


Explain like I'm 5

Before building a bridge you guess how many cars will cross it each day β€” a footbridge and a highway are totally different. Here we guess how many messages flow per second and how much storage we'll need over 20 years. Those numbers decide whether one computer is enough (it won't be).

The scale will dictate our design choices. Let's estimate.

Users & messages per second

Assume total users = 2 billion (web/planet scale).

Daily Active Users (DAU)

Normally we apply the Pareto Principle (only ~20% of users active). Is that true for WhatsApp? No! For a messaging app the number is much higher β€” say 80% = 1.6 billion β‰ˆ 2 billion DAU. (For FB Messenger it might still be as low as 20%.)

Assume average 10–20 messages / active-user / day. Taking 20:

Total messages/day  = 20 messages/user/day Γ— 2 billion users
                    = 40 billion messages / day

Avg messages/second = 40 billion / day
                    = 4 Γ— 10¹⁰ messages / (10⁡ seconds)
                    = 4 Γ— 10⁡ messages / second
                    = 400,000 messages / second

Per-API load

APIAvg loadReasoning
sendMessage400,000 / secThe base rate computed above.
getMessages800,000 / sec (β‰ˆ 2Γ—)A message is sent once but read a handful of times β€” assume each message read twice on average.
getConversations200,000 / secOnly when the user re-opens the app: ~10 reopens/user/day Γ— 2B = 20B reopens/day.
Why reads aren't astronomically higher (and why groups differ)

On Stack Overflow / Twitter a post is made once and read thousands or millions of times. On WhatsApp a message is sent once and read only a handful of times β€” so reads are only ~2Γ— writes. Groups change this: a message sent once in a large group is read by thousands. If 400,000 msg/sec were each read 100,000 times, that's 40 billion requests/sec β€” insane; no system (not even Google with ~10 million servers) can handle that as of 2025. Luckily group participation is far smaller β€” Slack has only 42 million DAU vs WhatsApp's 2 billion.

Peak load

Assume peak = 5Γ— average during global events (Covid, New Year): β‰ˆ 2 million messages/second.

Storage over 20 years

What's the data in one message?

message: {
    sender_id:                            uuid (16b)
    receiver_id / group_id / conversation_id: uuid (16b)
    message_id:                           uuid (16b)
    text:                                 string (200b avg)
    when:                                 timestamp (8b)
    where:                                geolocation (16b)
    delivery_status:                      enum (1b)
    attachment_url:                       string (200b avg)
}
Why one merged "conversation_id", not many tables

1:1 messages could have their own table (sender_id, receiver_id, message); group messages another (sender_id, group_id, message). But what about 3-person chats? Ad-hoc 5-person chats? Another table each? No. Instead, merge everything under a conversation_id: for 1:1 it's a tuple of (sender_id, receiver_id); for groups it's the group id; ad-hoc conversations get a conversation_participants(conversation_id, user_id) table that covers all scenarios (1:1, group, 1:any).

Average size β‰ˆ 500 bytes/message:

Data/day  = 500 bytes/message Γ— 40 billion messages/day
          = 20 trillion bytes/day
          = 20 TB / day

Data/20yr = 20 TB/day Γ— (20 Γ— 365 days)
          = 20 TB/day Γ— ~10,000 days
          = 200 Petabytes
Can this fit on one server?

No. As of 2026 we can store up to ~3 PB on a single server, not 200 PB. Even if we could, a single server can't handle 2 million messages/sec. We definitely need sharding!

Read-heavy or write-heavy?

AveragePeak
Writes (sendMessage)400,000 / sec2 million / sec
Reads (getMessages)800,000 / sec4 million / sec
The one big idea

Neither reads nor writes are 10Γ— the other β€” this is both read-heavy AND write-heavy, with neither dominating. That's very challenging to design for, because no single database is optimized for both. So we must turn it into one or the other. Can we reduce writes (batching/sampling)? No. So we'll reduce the reads hitting the database by absorbing them in a cache β€” and since messages are mostly immutable (edits/deletes are rare), caching works well. The result: lots of cache, and a database optimized for writes.

Recap ~2 billion DAU Γ— ~20 msgs/day β‰ˆ 40 billion messages/day β‰ˆ 400,000 writes/sec (peak ~2M), with reads β‰ˆ 2Γ— (~800,000/sec). At ~500 bytes/message that's ~20 TB/day β†’ ~200 PB over 20 years β€” far too much for one server, so we need sharding. The system is both read- and write-heavy, so we'll absorb reads in a cache and optimize the DB for writes.

6 System design β€” idempotency & message order


Explain like I'm 5

Give every note a special sticker number so the post office can tell "I already have this exact note" and not file it twice. And on each note, write "this comes right after note #7" β€” so your friend lays the notes out in the right order, and waits if note #7 hasn't arrived yet.

Now we start the actual system design. Two NFRs get solved together on the client side: idempotency and ordering.

Client-generated message ids: UUID v7

Each message gets a unique message_id generated on the client. We use UUID v7:

  • universally unique, decentralized, sparse, and roughly sortable (time-ordered)
  • a widely accepted spec with popular implementations in every major language
  • 128-bit, high performance

Idempotency via client-side id

Whenever the backend receives a sendMessage call, it stores the message only if the message_id isn't already in the DB. If it is already there, it ignores the request but still returns a success response (so the frontend stops retrying).

<input id="message" type="text" placeholder="send a message" onkeydown="sendMessage" />
<script>
    function sendMessage() {
        var content = document.getElementById("message").text;
        var message = {
            id: uuid_v7(),     // this id stays UNCHANGED across retries
            content: content,
            timestamp: new Date(),
        }
        sendWithRetries(message);
    }
    function sendWithRetries(message) {
        fetch("/backend-api-url/messages", "POST", {
            message: message
        }).then(() => console.log("success"))
          .error(() => {
              // automatic retry after 1 second
              setTimeout(() => sendWithRetries(message), 1)
          })
    }
</script>

Because the id is generated once (before any retry) and reused, a retried request carries the same id β€” so the backend recognizes the duplicate and refuses to store it twice.

Message order β€” why timestamps and backend queues fail

Sorting by created_time will NOT work

The recipient doesn't even know a message is pending until it arrives. By the time the receiver's app realizes an intermediate message arrived late, it's already too late β€” it had already shown the later messages to the user. And no backend-based solution (SQS / buffer / …) works either, for the same reason: the backend doesn't know a message is pending until it arrives, so by the time it realizes one came late, it has already put later messages in the queue.

Solution: a message chain

Every new message contains the previous_message_id inside it. On the recipient's side, the app will not show a message until its previous message has also been received & shown. If a message appears out of order, the app shows waiting-for-messages until all previous messages arrive.

<input id="message" type="text" placeholder="send a message" onkeydown="sendMessage" />
<script>
    var previous_id = null;
    function sendMessage() {
        var content = document.getElementById("message");
        var id = uuid_v4()
        var message = {
            previous_id,         // id of the last message that I sent
            id: id,
            content: content,
            timestamp: new Date(),
        }
        previous_id = id;
        sendWithRetries(message);
    }
</script>
Q: What about multiple devices / multiple senders in a group chat?

Don't worry β€” we're not guaranteeing any ordering across senders. The intent is only that all messages sent by "Subhadeep" appear in the correct order. Messages from different senders can be interleaved in any way; we don't care, as long as each individual sender's messages stay in order.

Recap Generate a client-side UUID v7 per message (unique, sortable). For idempotency, the backend stores a message only if its id is new, but always returns success so retries stop. For ordering, timestamps and backend queues fail (you can't detect a late message in time), so each message carries its previous_message_id β€” a message chain β€” and the recipient shows "waiting" until predecessors arrive. Order is guaranteed per-sender, not across senders.

7 Sharding the data


Explain like I'm 5

200 petabytes won't fit in one box, so we split the data across thousands of boxes. The rule for deciding which box a message goes into is the sharding key. Pick the rule well and finding your messages means opening one box; pick badly and you open every box.

We need sharding (Topic 5). The big question is the sharding key. Two candidates: user_id and conversation_id.

Option A β€” shard by user_id

All data (messages sent & received) of a particular user lives within the same shard.

Q: 2 billion users β†’ 2 billion servers?

No! A single server holds the data for hundreds of thousands of users.

How do you shard a table that has no user_id column?

The raw messages table is just (id, sender_id, recipient_id, message) β€” e.g. (1, Jinesh, Anika, "Hi"). To shard by user, we duplicate the row under each participant with a user_id column and store a copy on each user's shard:

Jinesh's shard:
  row_id  user_id  id  sender_id  recipient_id  message
  123     Jinesh   1   Jinesh     Anika         Hi

Anika's shard:
  row_id  user_id  id  sender_id  recipient_id  message
  321     Anika    1   Jinesh     Anika         Hi
Operation1:1 chatGroup chat
getMessagesGo only to the user's shard (all their messages are there).Store-in-sender: reads fan out (different senders' messages live in different shards). Store-in-all-participants: fast reads from any shard.
sendMessageGo to both sender & receiver's shard (data replication).Store-in-sender: store only in sender's shard. Store-in-all: fan-out writes.
recentConversationsGo only to the user's shard.β€”
Sharding by user_id does NOT work for groups

For group conversations, either reads fan out or writes fan out β€” there's no good middle ground.

Option B β€” shard by conversation_id

For groups, conversation_id = group_id. For 1-1 chats, conversation_id = a unique id assigned to a pair of users (just the pair (sender_id, receiver_id)).

Operation1:1 chatGroup chat
getMessagesGo only to the conversation's shard.Just go to the group's shard.
sendMessageGo to the conversation's shard.Just store in the group's shard.
recentConversationsFan-out β†’ bad. Fix below.Always a fan-out (a single group message updates the recent-conversation list for up to 100,000 users).
The recentConversations DB

Because recentConversations(user_id) would fan out, we keep a separate DB just to answer it. Now sendMessage must also update this DB. Note: it actually holds info (last-message timestamp) about all conversations β€” it's only called the recentConversations DB because we use it exclusively to answer that query.

Groups: don't even provision recentConversations on the backend

For groups, a single message updates the recent conversation for potentially 100,000 users β€” always a fan-out, no matter what. So you don't provision this API for groups on the backend. Practically it happens only at the frontend: the client app maintains a cache of recent conversations, updated via notifications.

The schema

users                    (id, name, age, avatar_url)
conversations            (id, title, description)
conversation_participants (conversation_id, user_id)
messages                 (id, sender_id, conversation_id, message)
What to choose?

Facebook Messenger / WhatsApp: group size is limited β†’ sharding by user_id is ideal. Slack / MS Teams: groups are a core feature and can get very large (100,000+ users) β†’ sharding by conversation_id is ideal.

Recap Two sharding keys. By user_id: a user's data is co-located (rows duplicated per participant), great for 1:1 and "all my messages," but groups force either fan-out reads or fan-out writes. By conversation_id: a conversation is co-located, great for groups, but recentConversations fans out (use a separate DB / frontend cache). WhatsApp/Messenger β†’ shard by user_id; Slack/Teams β†’ shard by conversation_id.

8 The illusion of consistency


Explain like I'm 5

You can't reliably hand a copy of a note to two friends at the exact same instant. But if you always give it to the receiver first, the receiver is happy immediately; the sender already remembers what they wrote (their app keeps a copy), so they're happy too. To both of them it looks instant β€” even though the bookkeeping finishes a moment later.

PACELC says: if we want consistency, we give up availability & latency. We do want consistency. Assume we're sharding by user_id β€” then every write must go to 2 shards (sender & receiver), and we must keep them consistent.

Option 1 β€” Two-Phase Commit (2PC)

Write to both shards atomically with 2PC. But it's extremely slow (low throughput, high latency) and gives low availability. Rejected.

Can we instead create an illusion of consistency?

Instead of writing atomically, write the two shards one by one. Running example:

Riya  – "bro can I copy your project submission?" β†’ Aditya
(sender)                                            (recipient)

Attempt: write to Sender shard first

  • Failure (low latency): return failure immediately; client can retry. Fine.
  • Success: Can we return "success" to the sender now? No! The data isn't on the receiver shard yet β€” if the receiver does getMessages, they won't see it, even though the sender thinks it's sent. Inconsistency: "bro, I sent it" / "bro, you didn't!"
    • Then write to the Receiver shard. Success (low latency) β†’ return success, done.
    • Failure (high latency): now we're in an inconsistent state (on sender shard, not receiver shard). Retry β†’ sender waits until retry succeeds β†’ very high latency. Rollback β†’ return an error, but the system stays inconsistent until the rollback completes, so we must lock and block the sender's reads β†’ high read latency for the sender.
Writing to the sender first is bad

Either we lie about success, or we suffer high latency from retries/rollbacks and locks. Let's flip the order.

The fix: write to Receiver shard first

  • Failure (low latency): return failure immediately; client (sender) can retry.
  • Success: the message has actually been sent β€” if the recipient fetches now, they see it. Return success immediately (without even writing the sender shard yet) β†’ very low latency.
    • (Async) write to the Sender's shard. Success β†’ done. Fails β†’ just keep retrying until it succeeds. The sender isn't waiting (already ACK'd), so no added latency.
The frontend cache closes the gap

If the receiver-shard write succeeds but the async sender-shard write hasn't landed, reloading the sender's app via getMessages wouldn't show their own message β†’ they'd think it was deleted. Fix: a frontend cache inside the client app. The app already knows what it sent and that the server ACK'd it, so it shows the message regardless. The only failure case is if the sender's shard misbehaves and the user reinstalls / clears the app cache before the async write lands β€” extremely rare, so not an issue. The message is never lost; it eventually replicates to the sender shard from the receiver shard.

The one big idea

By cleverly writing to the recipient's shard first and leaning on the sender-side frontend cache, we create an illusion of immediate consistency. It solves the practical problem without 2PC's cost.

How this beats PACELC on availability & latency

Consistency vs Availability β€” a network partition:

Harini ----- "Hi" -----> Srinidhi
App Server 1 receives the request.
It CAN reach Srinidhi's shard, but a network partition blocks Harini's shard.

The app server just writes to Srinidhi's shard (recipient) and returns success to Harini. It also drops a message in a task queue to add this to Harini's shard asynchronously. Even during the partition, sendMessage & getMessages stay available β†’ high availability. Srinidhi sees the message (written to her shard); Harini sees it via the frontend cache β†’ (illusion of) immediate consistency. (If the write to Srinidhi's shard fails, return an error and let Harini retry.)

Consistency vs Latency: because we're not trying to write both shards atomically, we never wait for retry/rollback β€” we return success the moment the recipient shard is written β†’ low latency.

Recap 2PC across sender + receiver shards is too slow / low-availability. Instead, write to the receiver's shard first, return success immediately, then async-write the sender's shard (retry until it lands). A sender-side frontend cache hides the gap, and a task queue keeps things available through partitions. Net effect: an illusion of immediate consistency with high availability and low latency.

9 Choosing the right database


Explain like I'm 5

Different toy boxes are good at different things β€” one is great for stacking, another for sorting by color. We list exactly what we need our "box" (database) to be good at, then pick the box that matches.

Recall the load: writes 400,000/sec avg (2M peak), reads 800,000/sec avg (4M peak) β€” both read & write heavy, and no database is optimized for both.

Optimize reads or writes?

Can we optimize writes? Only by batching (causes stale reads β€” but we want low latency) or sampling (causes data loss β€” and we can't afford data loss). So no. Can we optimize reads? Yes β€” absorb as many as possible in a cache. With most reads absorbed, we can now optimize the database for writes.

What the ideal database needs

  • High write throughput (400,000 writes/sec avg)
  • No joins needed β€” we only "find all messages of Akshay (within a conversation)," never "all messages of all of Akshay's friends"
  • No search needed (if needed later, provision another DB in a separate microservice)
  • Paginated reads (by timestamp)
  • No transactions needed
  • Disk persistence
DB typeStrengthsWeaknesses / verdict
SQLACID transactions, normalization, joins, schemaLow throughput, can't scale horizontally β†’ βœ—
Key-ValueHigh throughput, simplicityNo search, no joins, no pagination β€” but we need pagination β†’ βœ—
DocumentSchemaless, search, local index on any attributeNo joins, no pagination β€” we need pagination, don't need search β†’ βœ—
Column-FamilyFast writes, time-based pagination, fast aggregate queriesNo joins, no search β€” but that's exactly what we need β†’ βœ“
The pick

The ideal database is a Wide-Column Database β€” HBase / Cassandra / ScyllaDB. Fast writes, time-based pagination, no need for joins/search/transactions.

Recap Both read- and write-heavy, but no DB does both. We can't batch/sample writes (stale reads / data loss), so we cache reads and optimize the DB for writes. The need for high write throughput + time-based pagination + no joins/search/transactions points to a wide-column store: HBase / Cassandra / ScyllaDB.

10 The cache & routing


Explain like I'm 5

Instead of running to the big warehouse for every note, each clerk keeps the notes they handle on their own desk. Reading is instant. And we always send the same person to the same clerk, so the right notes are always on that clerk's desk.

We decided to absorb reads in a cache. Let's design it with the 5-step cache design process:

1️⃣
Need
Establish the need for caching
β†’
2️⃣
Type
Local vs Global; Single vs Distributed (if global)
β†’
3️⃣
Invalidation
Based on consistency + data/query complexity
β†’
4️⃣
Eviction
Just use LRU
β†’
5️⃣
Load balancer
Consistent hashing vs round robin

Type β€” why a local cache wins

  • A single global cache? No β€” we need lots of cache, so it must be distributed (local or global).
  • Network overhead per read? getMessages only reads a few messages β†’ low network overhead, so a global cache could work.
  • One cache server can't hold all the data β†’ we'd need a distributed global cache.
  • But: with a global cache, the app server for getMessages does nothing but forward the request β†’ we'd have 2Γ— the servers (app + cache) with half doing nothing. And read-your-write consistency becomes hard (it's trivial with a local cache, since local writes succeed).
Use a local cache β€” app servers double as the cache

Instead of a global cache plus useless app servers, use a local cache: the app servers are the cache.

Invalidation β€” write-through, cheaply

We want immediate consistency, but not the latency it usually costs. With a separate app server and cache server, immediate consistency needs 2PC (keep cache & DB in sync β€” two places). With a local cache, writing to the cache always succeeds, so no 2PC is needed. Hence we get write-through invalidation (immediate consistency) without high latency.

Eviction

LRU eviction.

Routing β€” consistent hashing for stateful servers

Our app servers are now stateful: if a server holds Harini's messages in cache, Harini's requests must always go to that server. So we route with consistent hashing.

  • getMessages(user_id, conversation_id) β†’ goes to my shard and my app-server+cache, because all my messages are there.
  • sendMessage(sender_id, recipient_id) β†’ routed to the recipient shard & app-server+cache first (data written there), then (async, via a message queue) replicated to the sender shard & app-server+cache. (This is exactly the "recipient-first" trick from Topic 8.)
Worked example β€” what happens when a cache server crashes?

The users that were routed to the crashed server get redirected to another server β€” which doesn't have their messages cached. That's the cold cache problem: the data isn't in cache, so we fetch it from the DB. So some of the time (when a cache server crashes), some users (only those originally on the crashed server) see larger latency for their first few reads. That's perfectly okay.

Recap 5-step cache design β†’ a local cache where app servers double as the cache. This gives cheap write-through (immediate-consistency) invalidation with no 2PC, LRU eviction, and consistent-hashing routing so each stateful server keeps owning its users. sendMessage writes the recipient first, then async- replicates to the sender. A crashed cache server just causes a brief cold-cache latency bump for its users β€” acceptable.

3 Video streaming, Netflix-style


Explain like I'm 5

Imagine a library that keeps the same story printed in many sizes β€” a big picture book for home, a pocket version for the bus. It also cuts each book into small chapters. When you read on a weak signal, the library quietly hands you the pocket version; on strong wifi, the big beautiful one β€” and it switches between them chapter by chapter so you never notice. Netflix is that library, but for video, and it keeps copies in a branch near your house so you get them fast.

Netflix has to play video smoothly on a phone in a tunnel, a TV on gigabit fiber, and everything in between β€” for hundreds of millions of people worldwide. The magic comes from four ideas working together: many encodings, adaptive bitrate streaming, chunking, and CDNs.

Storing one video as many resolutions & bitrates

When a studio uploads a movie, Netflix doesn't keep one file. It runs the video through a transcoding pipeline that produces many versions of the same content at different combinations of resolution (240p, 480p, 720p, 1080p, 4K…) and bitrate (how much data per second β€” higher bitrate = better quality, bigger file). A single title might have a dozen or more such renditions.

Resolution vs bitrate β€” not the same thing

Resolution = how many pixels (the picture's dimensions). Bitrate = how much data per second is spent describing those pixels. You can have a 1080p stream at a low bitrate (looks blocky in fast scenes) or a high bitrate (crisp). Netflix even tunes bitrate per title β€” a cartoon compresses better than a fast action film, so it gets a lower bitrate for the same quality.

Chunking β€” cut every rendition into tiny segments

Each rendition is sliced into short segments (a.k.a. chunks), typically 2–10 seconds long. So for one movie you end up with: (many renditions) Γ— (many little segments), plus a small text file called a manifest (the index that lists every segment of every rendition and where to fetch it).

What a manifest actually looks like (HLS)

HLS uses .m3u8 playlist files. The top-level "master" manifest just lists the available quality levels:

#EXTM3U
# 480p stream, ~800 kbps
#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=854x480
480p/index.m3u8
# 720p stream, ~2.8 Mbps
#EXT-X-STREAM-INF:BANDWIDTH=2800000,RESOLUTION=1280x720
720p/index.m3u8
# 1080p stream, ~5 Mbps
#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=1920x1080
1080p/index.m3u8

And each quality has its own playlist listing the actual 6-second segment files:

# 720p/index.m3u8
#EXT-X-TARGETDURATION:6
#EXTINF:6.0,
seg_00001.ts
#EXTINF:6.0,
seg_00002.ts
#EXTINF:6.0,
seg_00003.ts

The player downloads the master, picks a quality, then fetches segments one after another.

Adaptive Bitrate Streaming (HLS / DASH)

Adaptive Bitrate Streaming (ABR) is the trick that makes video "just work." The player constantly measures your current network speed and, for each upcoming segment, chooses the highest-quality rendition your connection can handle. Signal drops? Next segment comes from a lower rendition β€” no spinning wheel. Signal recovers? It steps back up. Because all renditions are chopped at the same segment boundaries, switching is seamless.

The two dominant protocols are HLS (HTTP Live Streaming, from Apple) and MPEG-DASH (Dynamic Adaptive Streaming over HTTP). Both do the same job: a manifest plus HTTP-fetched segments, with the client deciding which quality to grab.

The one big idea

Video streaming is "just" downloading lots of small files over ordinary HTTP. By pre-cutting every quality into aligned chunks and letting the player choose the quality per chunk, we get smooth playback over any network β€” and because it's plain HTTP, we can cache those chunks anywhere, which sets up the CDN below.

Thumbnails & preview images

Those little frames that appear when you scrub the timeline are thumbnail sprites β€” a grid of tiny still images generated during transcoding (e.g. one frame every 10 seconds), packed into a single image plus a tiny map of "time β†’ which tile." Scrubbing just shows the right tile; no video decoding needed. Box-art and hero images are likewise pre-generated stills served from the CDN.

The role of CDNs for global delivery

A CDN (Content Delivery Network) is a fleet of caching servers spread across the globe, close to users. Instead of every viewer pulling video from one faraway data center, the segments are copied to an edge server near them. Netflix runs its own CDN called Open Connect, placing storage boxes inside internet providers' networks β€” sometimes literally in your ISP's building. Result: your movie travels meters, not oceans.

🎬
Upload
Studio uploads master file
β†’
βš™οΈ
Transcode
Make many renditions, cut into segments
β†’
πŸ—„οΈ
Store
Segments + manifest in object storage (S3)
β†’
🌍
Distribute
Push popular titles to edge CDN servers
β†’
πŸ“Ί
Play
Player fetches manifest, then nearest segments via ABR
Worked example β€” a phone losing signal mid-scene

You're streaming 1080p on the train. The ABR logic, per segment, looks roughly like:

# Client picks quality for the NEXT segment based on measured speed + buffer
def pick_rendition(measured_kbps, buffer_seconds):
    if buffer_seconds < 5:             # running low β€” play it safe
        return lower_rendition()
    if measured_kbps > 5000:           # plenty of room
        return rendition("1080p")
    elif measured_kbps > 2800:
        return rendition("720p")
    else:
        return rendition("480p")

Train enters a tunnel β†’ measured speed drops β†’ next segment comes back as 480p from the local edge cache β†’ picture softens for 6 seconds but never stalls β†’ tunnel ends β†’ it climbs back to 1080p. You barely noticed.

Recap Netflix transcodes each title into many resolution/bitrate renditions, chunks them all into aligned 2–10s segments listed in a manifest, and serves them over plain HTTP. Adaptive bitrate streaming (HLS/DASH) lets the player pick quality per chunk for smooth playback, scrub-thumbnails are pre-baked sprites, and a global CDN (Netflix's Open Connect) keeps the bytes physically close to you.

4 Live broadcast to millions


Explain like I'm 5

On-demand video is like a library: each person checks out the book whenever they want, at their own pace. A live broadcast is like a stadium concert: everyone watches the same moment together, right now. You can't hand out a personal copy to each of ten million fans from one stage β€” so you put up giant screens (speakers) around the city, and feed them all from the stage. The "giant screens near the crowd" are the same CDN edges from Topic 3, but now everyone is looking at the live feed at once.

A live event β€” a cricket final, a product launch, a streamer with millions watching β€” is the hardest scaling problem in this session. The content is the same for everyone and it's being created this second. The core challenge is fan-out: one source, millions of simultaneous viewers.

How live differs from on-demand

AspectOn-demand (Netflix)Live broadcast
When is it watched?Anytime, spread outAll at once (huge concurrency spike)
Is content ready?Fully transcoded ahead of timeCreated in real time β€” encode on the fly
Can you pre-cache it?Yes, push popular titles to edges in advanceNo β€” segments appear seconds before they're watched
What matters most?Smoothness & qualitySmoothness and low glass-to-glass latency

The live pipeline

It reuses the streaming machinery (chunks, manifests, ABR, CDN) but adds a real-time front end:

πŸ“Ή
Capture
Camera β†’ encoder sends a feed (e.g. via RTMP/SRT)
β†’
βš™οΈ
Encode
Transcode live into renditions + tiny segments
β†’
πŸͺœ
Origin
A few origin servers hold the newest segments
β†’
🌍
Edge fan-out
Thousands of CDN edges each serve their local crowd
β†’
πŸ‘₯
Millions
Viewers pull from the nearest edge

Edge caching is what makes the fan-out survivable

Here's the saving grace: even though there are millions of viewers, they all want the exact same segment right now. So an edge server fetches segment #4021 from the origin once, then serves that one copy to the 200,000 viewers attached to it. This is the cache hit ratio doing heavy lifting β€” the origin sees thousands of requests, the edges absorb millions. Without edge caching, the origin would melt instantly.

The one big idea

You don't scale a live stream by making one server faster β€” you scale it by making sure each unique segment is fetched from the origin as few times as possible and then copied massively at the edges. The "shape" is a tree: one origin β†’ many regional caches β†’ many edges β†’ millions of viewers. Each level multiplies reach without multiplying load on the level above.

The latency trade-off

Chunking creates a tension. Bigger segments (say 6s) are efficient and smooth but mean viewers are always ~10–30 seconds behind real life (each player buffers a few segments for safety). For a movie that's fine. For a live sports match where your neighbor cheers before your screen shows the goal, it's painful.

GoalSegment sizeApprox latencyCost
Cheap & smoothLarge (6–10s)~20–45s behind liveLowest, very cacheable
Low-latency HLS/DASHSmall (1–2s) + partial chunks~3–8sMore requests, harder to cache
Ultra-low (interactive)WebRTC (no chunking)<1sHighest β€” hard to scale to millions
The fundamental tension

Lower latency fights against easy caching. To get closer to "live" you use tinier segments and less buffering β€” but smaller, fresher segments are requested more often and harder for edges to cache, raising cost and load. Engineering a live system is largely about choosing where to sit on this latency-vs-cost-vs-scale curve. A massive sports broadcast happily accepts a few seconds of delay to reach 50 million people; a two-person video call accepts huge cost for sub-second latency.

Worked example β€” the fan-out math

Say 10,000,000 people watch a final, served by 1,000 edge servers (10,000 viewers each), with a new 6-second segment every 6 seconds.

  • Viewer requests per segment: 10,000,000 β€” impossible for one origin.
  • But each edge fetches the segment from origin just once β†’ origin sees only 1,000 requests per segment. Totally manageable.
  • Cache hit ratio at the edge β‰ˆ (10,000 βˆ’ 1) / 10,000 β‰ˆ 99.99%.

That 99.99% is the whole game: edges turn 10 million requests into 1,000 at the source. (Compare to Topic 3, where on-demand can pre-push content to edges before the rush; live can't, which is why fresh-segment caching efficiency matters even more here.)

Recap Live broadcast is on-demand streaming plus a real-time encoder and a brutal fan-out problem: one source, millions of simultaneous viewers. Edge caching saves it β€” every viewer wants the same fresh segment, so edges fetch each one once and copy it to thousands, giving a ~99.99% hit ratio. The defining trade-off is latency vs caching/cost: smaller, fresher chunks get you closer to live but are harder and pricier to cache.

β˜… Putting it all together


Two very different products, but look how many ideas they share β€” sharding for scale, persistent connections and fan-out for delivery, and pushing data physically close to the user.

A messaging app is a write-heavy post office: model it as users/conversations/messages, shard 1:1 chats by conversation but large groups by per-user inbox to dodge hot shards, store it in wide-column NoSQL, and deliver instantly over WebSockets with a presence registry, backward-flowing ack receipts, and per-conversation ordering that gets cheap immediate consistency because one chat lives on one shard. Video streaming transcodes each title into many renditions, chunks them into aligned segments, lets the player choose quality per chunk via adaptive bitrate (HLS/DASH), and serves everything from a global CDN. Live broadcast reuses all of that but adds real-time encoding and solves a massive fan-out via edge caching, forever trading latency against caching cost. The recurring lesson: scale comes from choosing the right key to split on and copying data close to whoever needs it.

Quick self-check

Why do 1:1 chats and large group chats often use different shard keys?

1:1 chats shard cleanly by conversation_id (two people, modest traffic, single-shard reads). A huge popular group sharded by conversation creates a hot shard, so large groups lean on per-user_id inboxes (or fan-out on read) to spread the load.

Why use WebSockets instead of polling for chat?

A WebSocket is one persistent, two-way connection, so the server can push a message the instant it arrives β€” no wasteful repeated "anything new?" requests and far lower latency. Long polling is the fallback when WebSockets aren't available.

How do we get correct message order and immediate consistency cheaply?

Each message gets a per-conversation sequence number (or a sortable Snowflake/ULID), and because we shard by conversation, a whole chat lives on one shard β€” so that single shard can enforce order and immediate consistency without slowing the global system.

What is adaptive bitrate streaming, and why is chunking required for it?

ABR lets the player pick the highest-quality rendition the current network can handle, per segment. It needs chunking because all renditions are cut at the same segment boundaries, so the player can switch quality seamlessly from one chunk to the next.

Why can a live stream reach 10 million viewers without melting the origin?

Everyone wants the same fresh segment at once, so each edge server fetches it from the origin once and serves its local crowd β€” a ~99.99% cache hit ratio. The origin sees only a few thousand requests while edges absorb the millions.

What's the core trade-off in low-latency live streaming?

Latency vs caching/cost. Smaller, fresher chunks (and less buffering) get viewers closer to real time, but those segments are requested more often and are harder/pricier for edges to cache.

πŸ“š References & Further Reading


Class material

Papers, docs & deep dives