1 The 5-step design approach
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.
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.
2 Problem statement & reasoning by analogy
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 app | Similar to⦠| What it really is |
|---|---|---|
| Telegram, Signal, Hike, iMessage, Arattai | Mobile-first realtime chat, < 3 sec for the other person to receive a message. Built as an SMS replacement. | |
| Slack | MS Teams, Flock | B2B enterprise chat. Group/channel-centric β a channel can have 100k employees. |
| Facebook Messenger | Instagram Messages, Orkut messages | Messaging 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.
| App | Similar to⦠| Why it's a different problem |
|---|---|---|
| Discord | β | Voice chat β needs ultra-low latency. |
| Orkut | β | Social media, not messaging. |
| X, Threads (owned by Meta) | Social media. | |
| Skype | FaceTime, Zoom | Video chat & screen sharing. |
| Hangouts | Google Meet | Video calling. |
| Snapchat | β | Privacy-focused β disappearing messages. |
3 Functional requirements (the MVP)
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.
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.
| Tier | Meaning |
|---|---|
| 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
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:
| Status | Meaning | UI |
|---|---|---|
| Sent | Received by the backend server. | Single tick β |
| Delivered | Delivered to the recipient's app, but not read yet. | Double tick ββ |
| Read | The 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.
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)
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.
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>
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.
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) = 16butf(f(4)) = 256.f(x) = |x|IS idempotent:f(-5) = 5andf(f(-5)) = 5.
Any WRITE (post/update/delete) API endpoint should be idempotent if your frontend can retry.
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.
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:
| Side | Scenario at t=0 / t=1 | Effect |
|---|---|---|
| Sender side | Pujan 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 side | Pujan 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 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.
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.)
5 Scale estimation
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).
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
| API | Avg load | Reasoning |
|---|---|---|
sendMessage | 400,000 / sec | The base rate computed above. |
getMessages | 800,000 / sec (β 2Γ) | A message is sent once but read a handful of times β assume each message read twice on average. |
getConversations | 200,000 / sec | Only when the user re-opens the app: ~10 reopens/user/day Γ 2B = 20B reopens/day. |
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)
}
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
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?
| Average | Peak | |
|---|---|---|
Writes (sendMessage) | 400,000 / sec | 2 million / sec |
Reads (getMessages) | 800,000 / sec | 4 million / sec |
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.
6 System design β idempotency & message order
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
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>
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.
previous_message_id
β a message chain β and the recipient shows "waiting" until predecessors arrive. Order is
guaranteed per-sender, not across senders.
8 The illusion of consistency
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.
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.
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.
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.
9 Choosing the right database
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.
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 type | Strengths | Weaknesses / verdict |
|---|---|---|
| SQL | ACID transactions, normalization, joins, schema | Low throughput, can't scale horizontally β β |
| Key-Value | High throughput, simplicity | No search, no joins, no pagination β but we need pagination β β |
| Document | Schemaless, search, local index on any attribute | No joins, no pagination β we need pagination, don't need search β β |
| Column-Family | Fast writes, time-based pagination, fast aggregate queries | No joins, no search β but that's exactly what we need β β |
The ideal database is a Wide-Column Database β HBase / Cassandra / ScyllaDB. Fast writes, time-based pagination, no need for joins/search/transactions.
10 The cache & routing
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:
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?
getMessagesonly 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
getMessagesdoes 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).
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.)
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.
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
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 = 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).
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.
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.
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.
4 Live broadcast to millions
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
| Aspect | On-demand (Netflix) | Live broadcast |
|---|---|---|
| When is it watched? | Anytime, spread out | All at once (huge concurrency spike) |
| Is content ready? | Fully transcoded ahead of time | Created in real time β encode on the fly |
| Can you pre-cache it? | Yes, push popular titles to edges in advance | No β segments appear seconds before they're watched |
| What matters most? | Smoothness & quality | Smoothness 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:
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.
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.
| Goal | Segment size | Approx latency | Cost |
|---|---|---|---|
| Cheap & smooth | Large (6β10s) | ~20β45s behind live | Lowest, very cacheable |
| Low-latency HLS/DASH | Small (1β2s) + partial chunks | ~3β8s | More requests, harder to cache |
| Ultra-low (interactive) | WebRTC (no chunking) | <1s | Highest β hard to scale to millions |
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.
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.)
β 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
- π Original class notes / handout (Google Doc) β open the shared class material for this session.
- Class handout: "[SST-2028] Case Study: Messaging Apps (FB Messenger, Whatsapp, Slack)".
Papers, docs & deep dives
- π MDN β The WebSocket API β persistent two-way connections for real-time message push.
- π MDN β Live streaming web audio and video (HLS / DASH) β adaptive bitrate streaming fundamentals.
- π The Netflix Tech Blog β deep dives on Open Connect, transcoding, and global video delivery.
- π Apache Cassandra β Data Modeling β partition/clustering keys behind wide-column message storage.