Design live comments for streams
Open in the interactive app: practice, quiz, mock interview →In one minute
- Fan out to gateways, not to viewers. A comment on a stream with 5 million viewers should become a few hundred messages, one per gateway host that holds some of those viewers' sockets. The gateway does the last hop to each connection.
- Nobody can read 10,000 comments a second. Beyond about 10 to 20 per second the chat is a blur, so the system selects a ranked sample per viewer per tick and drops the rest from the live view. That cap, not the comment rate, sets delivery cost.
- Batch by tick. One frame per viewer per second, carrying up to a dozen comments, costs far less than a dozen frames. Serialize once per video per tick and reuse the bytes for every viewer.
- Live delivery is best-effort; the store is the truth. A viewer who drops a frame does not get it back over the socket. Every published comment is durable, keyed by video and video offset, so replay and moderation work from the store.
- The write path must survive the spike. A goal in a cup final produces a burst of 50x in two seconds. Accept, persist and publish asynchronously, and rate-limit per user so one script cannot flood a stream.
Requirements
Functional
- Viewers of a live video post short text comments and see other viewers' comments appear within a couple of seconds.
- A viewer joining mid-stream sees the most recent comments immediately.
- After the stream ends, the recording replays comments in sync with the playhead.
- The author and moderators can delete comments; deleted comments disappear from live views and replays.
- A viewer always sees their own comment, and comments from friends or the streamer get priority in the live view.
Non-functional
- Post-to-screen latency under 2 seconds p95 for viewers in the same region.
- Scale from a stream with 3 viewers to one with 5 million, with no per-stream provisioning.
- No comment lost once the poster gets a success response, even if it never reached a live viewer.
- Order is roughly by server time; strict global order is not required.
- Availability over consistency: a gateway or region failure degrades the live feed, never the video.
Out of scope: the video pipeline itself (see Design YouTube and Media Streaming), reactions and like counters (see Distributed Counting), threaded replies, and the content classifiers behind moderation.
Back-of-envelope
Round assumed numbers for a large social platform.
- Concurrent viewers at peak: 50M across about 1M live videos. The distribution is a power law: most streams have under 50 viewers, and the top stream has 5M.
- Comment writes: if a viewer posts once every 10 minutes on average, 50M / 600 ≈ 83k comments/s at peak. Daily average is about a third of peak, so roughly 28k/s, or 28k × 86,400 ≈ 2.4B comments a day.
- Storage: about 500 bytes per comment with metadata, so 2.4B × 500 B ≈ 1.2 TB a day, 1.2 TB × 365 × 5 ≈ 2.2 PB over five years before replication. Comments older than 30 days are rarely read, so they move to compressed cold storage.
- Naive fan-out: the top stream at 10k comments/s times 5M viewers is 5 × 10^10 deliveries per second. Not possible, and not useful.
- Capped fan-out: with one frame per viewer per second, 50M viewers is 50M frames/s. A frame of 10 comments is about 2 KB after JSON and compression, so egress is about 100 GB/s at peak, spread over every gateway.
- Gateways: at 250k sockets per host, 50M viewers need 200 hosts; plan 400 so a host can fail or drain without overloading its neighbours.
- Dispatcher traffic: the top stream reaches all 400 gateways, so one tick is 400 internal messages. Small streams live on one or two gateways, so internal fan-out stays in the low millions per second.
API
POST /v1/videos/{video_id}/comments
{ client_comment_id, text, video_offset_ms }
-> 201 { comment_id, status: "published" | "held" }
GET /v1/videos/{video_id}/comments/recent?limit=50
-> { comments: [ { comment_id, author, text, video_offset_ms, server_ts } ], cursor }
GET /v1/videos/{video_id}/comments/replay?from_ms=600000&to_ms=660000
-> { comments: [...], next_from_ms }
DELETE /v1/videos/{video_id}/comments/{comment_id} -> 204
GET /v1/videos/{video_id}/live (Server-Sent Events)
event: batch
id: 1832
data: { tick: 1832, comments: [...], dropped: 412 }
event: delete
data: { comment_id }client_comment_id makes a retried post idempotent. video_offset_ms is where the poster's playhead was, which is what replay keys on. The live stream uses SSE because traffic is almost all server to client; posting goes over normal HTTP, which reuses the platform's auth, rate limiting and abuse checks. Serve it over HTTP/2: over HTTP/1.1, browsers allow only 6 connections per domain, so a viewer with several tabs open starves their own page loads, while HTTP/2 multiplexes the streams on one connection (MDN). The dropped field lets the client show "and 400 more" without receiving them.
Data model
comments lives in a wide-column store (Cassandra or similar). Partition key (video_id, minute_bucket), clustering key (video_offset_ms, comment_id). Columns: author_id, text, server_ts, status (published, held, deleted). Partitioning by video alone would put a hot stream's 10k writes/s on one replica set and make the partition unbounded; bucketing by minute of video time caps each partition and spreads a long stream over many. Replay reads one or two buckets at a time in offset order, which is a sequential scan.
comment_dedupe holds (author_id, client_comment_id) -> comment_id with a 24-hour TTL. A retry after a timeout returns the original id.
recent_comments is an in-memory list per video (Redis LPUSH plus LTRIM to 200, the capped-list idiom the Redis docs recommend (Redis docs)). Joining viewers read it instead of hitting the comment store, which turns a "join storm" at stream start into cache reads.
subscriptions exist only in memory. Each gateway keeps video_id -> set of connections. Each dispatcher keeps video_id -> {gateway_id: refcount} for the videos it owns. They are rebuilt from gateways' re-subscribe calls after a restart, so they never need a database.
comment_id is a time-ordered 64-bit id (Snowflake-style, see Generating Unique IDs), so ids sort by creation time within a video.
High-level design
flowchart LR
V["Viewer app"] -->|"POST comment"| CS["Comment service"]
CS --> DB[("Comment store by video + minute")]
CS --> RC[("Recent list cache")]
CS --> BUS["Pub/sub partitioned by video_id"]
BUS --> D["Dispatcher (owns video_id)"]
D -->|"one batch per tick"| G1["Gateway 1"]
D --> G2["Gateway 2"]
G1 -->|"SSE frame"| W1["Viewers"]
G2 --> W2["Viewers"]
W1 -.->|"join: subscribe"| G1
G1 -.->|"first viewer of video"| DWrite path.
- The app shows the comment locally at once (optimistic echo), then posts it.
- The comment service authenticates, checks the per-user rate limit, deduplicates on
client_comment_id, and runs a fast synchronous filter (block lists, link spam). Suspicious comments are stored asheldand never published. - It writes the comment to the store with quorum, pushes it onto the recent list, and publishes
{video_id, comment}to the pub/sub topic. Then it returns 201. - The dispatcher that owns this
video_id(by consistent hashing) adds the comment to the video's pending buffer.
Read path.
- A viewer opens the live page: the app fetches the recent list, then opens the SSE stream to a gateway chosen by the load balancer.
- The gateway adds the connection to its local set for that video. If this is the gateway's first viewer of the video, it sends
subscribe(video_id, gateway_id)to the owning dispatcher. - Every tick (1 second), the dispatcher picks up to
capcomments from the pending buffer, ranks them, serializes one batch, and sends it to every subscribed gateway. - Each gateway writes the same frame bytes to every local connection watching that video, adding the viewer's own and friends' comments when it has them (see deep dive 2).
- When the last local viewer leaves, the gateway unsubscribes, and the dispatcher stops sending.
Deep dives
1. Routing comments to the right gateways
Option one is broadcast: every gateway receives every comment, 33M internal messages per second at 83k comments/s and 400 gateways, almost all thrown away. Option two is a pub/sub channel per video that gateways subscribe to directly (Redis pub/sub, for example). It works up to a point, but a broker with a million churning channels becomes the bottleneck, and gateways still receive every raw comment before any sampling.
I choose a dispatcher tier between the bus and the gateways. Slack's real-time stack has the same split: channel servers own channels by consistent hashing on the channel ID and forward each message to the gateway servers subscribed to that channel, which then deliver it to their own WebSocket clients (Slack Engineering). Videos are assigned to dispatchers by consistent hashing on video_id; the bus is partitioned the same way, so a dispatcher consumes only the partitions for the videos it owns. The dispatcher keeps a refcounted subscription table: gateway G is subscribed to video V while at least one viewer on G watches V. Refcounting matters because viewers join and leave constantly; the dispatcher should see one subscribe when G gets its first viewer of V and one unsubscribe when G loses its last, not a message per viewer. Discord applies the same idea inside its Erlang cluster: its Manifold library groups recipient processes by node and sends once per node, where local workers finish the delivery, after a single guild publish to 30,000 sessions had taken up to 2.1 seconds (Discord).
Where the subscription table lives is a real choice. I keep it in dispatcher memory and rebuild it from gateways after a failure. LinkedIn's live-video likes system keeps it in a shared key-value store instead, so any dispatcher can serve any video and a dispatcher crash loses nothing; it reports about 100,000 connections per frontend host, about 5,000 events per second per dispatcher, and about 75 ms p90 from dispatcher to client (LinkedIn, QCon London 2020). The shared store costs a lookup per publish; the in-memory table costs a rebuild storm after a crash.
For the top streams, sending 400 messages a tick is nothing. What can break is the inbound side, where 10k comments/s for one video land on one dispatcher; the fix is in Failure modes below.
2. Too many comments: sampling and ranking
At low volume, send everything. Above the cap, choose. Options: random sampling (fair, cheap, but shows spam and noise as often as good comments), first-come (biased to whoever has the lowest latency), or ranking. I rank by a score computed at write time: author reputation, whether the author is the streamer or a moderator, comment length and language match, and a spam probability. Within a tick, the top cap by score go out; ties go to the older comment so the feed reads roughly in time order. Comments older than a few seconds when the tick fires are dropped from the live view, since a comment about a goal arriving 15 seconds late is noise.
Personalization happens at the edge, because the dispatcher's batch is shared by millions. A viewer's own comment is echoed locally by the app. A friend's comment also travels over the platform's per-user notification channel to the friend's gateway, which merges it into that viewer's next frame; friend graphs are small next to stream audiences, so this is cheap.
Dropped comments are not lost. They are in the store and show up in replay and in the "all comments" view, which pages through the store instead of the live socket.
3. Replay in sync with the recording
During the live stream each comment is stamped with video_offset_ms from the poster's playhead. After the stream ends, the player asks for comments in windows of 60 seconds ahead of the playhead, using the (video_id, minute_bucket) partitions, and renders each comment when the playhead crosses its offset. Seeking discards the buffer and fetches the new window. Replay traffic is ordinary cacheable reads, so a CDN or read cache in front of the replay endpoint absorbs a recording that goes viral.
The subtle part is skew: viewers watch live with different delays, sometimes 20 seconds apart, so stamping the poster's playhead rather than server time makes replay line up with what the poster was reacting to. Deletes write a tombstone (status = deleted) instead of removing the row, which replay filters, and the live path broadcasts a delete event so clients remove the comment from screen.
Down the stack
Layer 1The contract
- Post. A 201 means the comment is durable on a quorum of replicas and will appear in replay unless deleted. A retry with the same
client_comment_idreturns the samecomment_id. - Live view. At most one batch per tick per viewer, at most
capcomments per batch, and no comment older thanmax_ageat selection time. Delivery is best-effort: a dropped frame is not resent. - Subscriptions. A gateway receives batches for video V exactly while it has at least one local viewer of V, give or take one tick.
- Deletes. A deleted comment is never selected for a later batch, never returned by replay, and is removed from screens that already show it within one tick.
Layer 2The mechanism
SSE is plain HTTP: the response has Content-Type: text/event-stream and stays open, and each event is lines of event:, id: and data: ended by a blank line. The browser's EventSource reconnects automatically and sends the last id in a Last-Event-ID header; here the server uses it only to avoid re-sending the same tick, not to replay missed ticks. Two more parts of the spec matter at this scale. A retry: field sets the client's reconnection delay, so the server can hand each client a randomized delay before a deploy and spread the reconnects. And because some proxies drop idle HTTP connections, the spec suggests sending a comment line (starting with :) about every 15 seconds as a keepalive (WHATWG HTML Standard).
The bus can be Kafka with video_id as the partition key, so all comments for a video land on one partition in order and the owning dispatcher is a consumer of that partition (Kafka docs). The dispatcher's per-video state is a pending buffer, a subscription map with refcounts, and a min-heap of size cap for selection, which is O(n log cap) per tick. Gateways keep a hash map from video_id to a list of connection handles.
Layer 3The machine
- Serialize once. For a video with 25,000 local viewers on one gateway, the gateway builds the frame bytes once, then calls
write(orwritev) on 25,000 sockets. Each call is roughly 1 to 3 microseconds of kernel time, so 25k writes cost 25 to 75 ms of CPU per tick, spread over cores. - TLS cannot be shared. Each connection has its own session keys, so the 2 KB frame is encrypted 25,000 times. AES-GCM with AES-NI runs at several GB/s per core, so 50 MB per tick is tens of milliseconds of one core. This, not JSON encoding, is the dominant per-viewer cost.
- Bytes on the wire. A 2 KB frame is two TCP segments at a 1,448-byte MSS. On an idle connection the congestion window is large enough to send both at once, so a frame costs one round trip at most.
- Idle cost. Each SSE connection holds kernel socket buffers (tunable down to a few KB when idle), TLS state, and the gateway's per-connection object: about 10 to 30 KB, so 250k connections fit in a few GB of RAM.
epoll_waitlets a handful of threads wait on all of them. - Write path. The comment store is an LSM tree: a write is a commit-log append and a memtable insert, flushed later to immutable SSTables (Cassandra docs), so 83k writes/s across a cluster is routine.
Why it's built this way
- Humans read at most a few comments per second, so the live view is capped and ranked, and delivery cost follows viewers, not comments.
- Sockets are spread across hundreds of gateways, so routing goes to gateways with viewers, refcounted, instead of to individual viewers.
- TLS and syscalls cost per frame and per connection, so comments are batched into one frame per tick and serialized once.
- Sampling and lossy delivery mean the live feed cannot be the record, so every comment is stored durably, keyed for replay.
- Viewers sit at different points in the stream, so comments are keyed by video offset, not wall clock.
Failure modes
- 10x burst (a goal, a reveal). Writes spike in seconds. The comment service accepts and persists, and the dispatcher's cap keeps outbound cost flat. If the store lags, publish to the bus first and write to the store from a consumer, so live delivery does not wait on disk; the 201 then waits only on the bus's replicated append.
- Hot video on one dispatcher. Inbound comments for the top stream overwhelm its owner. Split ingest for that video across N dispatchers that each keep a local top-K and merge candidates once per tick (the two-stage shape from Design Top K), triggered automatically above a rate threshold.
- Gateway restart or deploy. Its viewers reconnect elsewhere and the new gateways re-subscribe. Drain gradually, have clients reconnect with jittered backoff, and serve joins from the recent-list cache so a reconnect storm does not hit the comment store. Never let the orchestrator remove more gateways at once than the rest can absorb: block scale-down until a node has drained (see In the wild).
- Dispatcher failure. Its videos move to the next node on the ring, gateways re-send their subscriptions, and viewers lose a few seconds of live comments, which the contract allows.
- Region outage. Viewers fail over with the video to another region. Comments replicate asynchronously across regions, so a few seconds of comments posted in the lost region may be missing from live views elsewhere, but they reappear in replay once the region recovers.
- Spam raid. A coordinated group floods a stream. Per-user and per-video rate limits, a slow mode the streamer can switch on (one comment per user per N seconds), and ranking that pushes new accounts down keep the visible feed usable.
In the wild
- Discord, 2017: one publish, 30,000 receivers. Each Discord guild is an Erlang process that sends every event to every connected session. Busy guilds reached about 30,000 concurrent users, each send cost 30 to 70 microseconds, and one publish could take 900 ms to 2.1 s, so guilds fell behind at peak. Manifold groups recipients by node and sends one message per node, where partitioned workers deliver locally, spreading the CPU and cutting traffic between nodes. It is the gateway-level fan-out of this design (Discord).
- Discord, 2026: a scale-down sets off a reconnect storm. On 25 March a change that made session pods larger and fewer led Kubernetes to terminate half of them in one zone before they could hand off, ungracefully ending 17% of sessions. The reconnects hit a start-session rate limit that had not been retuned, gateway nodes ran out of memory, and voice syncer mailboxes backed up until instances dropped out of the hash ring. Voice and video were degraded for 3 hours 17 minutes. Discord added an admission webhook that blocks scale-down until pods drain, and retuned its rate limits (Discord).
- Shopify, 2022: a live map moves to SSE. Shopify's Black Friday live map had pushed data over WebSocket into a store that browsers polled, so trending products could lag by minutes. The 2022 rebuild streamed from Kafka through a Go SSE server behind nginx, data reached clients within milliseconds of becoming available and appeared on the map within 21 seconds of being created, and the map had 100% uptime during the Black Friday to Cyber Monday weekend (Shopify Engineering).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| SSE for live delivery | Plain HTTP, auto-reconnect, works through proxies | One direction only; needs HTTP/2 to avoid the 6-connection limit | Comments and other server push |
| WebSocket | Two-way, binary frames | Own reconnect logic, harder through some proxies | Interactive features on the same socket |
| Long polling | Works everywhere | A request per update, high overhead at 1/s | Fallback only |
| Send every comment | Complete, simple | Cost scales with comments × viewers | Streams under the cap |
| Ranked sample per tick | Bounded cost, better feed | Most comments never seen live | Large streams |
| Pub/sub channel per video | Simple, few moving parts | Broker strain with millions of channels | Small or medium scale |
| Dispatcher tier with refcounts | Gateway-level fan-out, scales to hot streams | Another tier to run and rebalance | Millions of concurrent streams |
Leadership lens
- Build first: the durable write path with idempotency, the gateway with SSE, and the dispatcher with refcounted subscriptions, sending every comment with no cap. Add the per-tick cap and ranking when the first large stream needs it, then replay, then friend priority.
- Team ownership: a real-time infrastructure team owns gateways and dispatchers and offers them as a platform (the same tier serves typing indicators, live reactions and score updates). A comments team owns the write path, ranking and replay. Trust and safety owns the filters and moderation tools.
- Cost drivers: egress and TLS CPU on the gateways scale with viewers × ticks, so the tick interval and cap are the main cost levers. Storage is modest; cold-tier it after 30 days.
- Rollout: launch behind a flag on small streams, raise the cap and audience limits stepwise, and load-test with a synthetic 5M-viewer stream before a big event, not after.
- Review questions: what is the cap and who chose it; what happens to a held comment that a moderator approves late; how does a viewer find their friend's comment that was sampled out.
Check yourself
Why is naive fan-out impossible for the top stream, and what bounds delivery cost instead?
Show answer
10k comments/s times 5M viewers is 5 x 10^10 deliveries per second, which is neither possible nor useful because nobody can read that fast. Capping each viewer at one frame per tick with a ranked sample of up to
capcomments makes cost follow viewers, about 50M frames/s and roughly 100 GB/s of egress at peak across all gateways.Why put a dispatcher tier with refcounted subscriptions between the bus and the gateways instead of a pub/sub channel per video that gateways subscribe to directly?
Show answer
A broker with a million churning channels becomes the bottleneck, and gateways would still receive every raw comment before sampling. The dispatcher owns videos by consistent hashing, samples once per tick, and sends one batch per subscribed gateway, seeing only a subscribe on a gateway's first viewer and an unsubscribe on its last, at the cost of another tier to run and rebalance.
The top stream's inbound comments overwhelm the one dispatcher that owns it. What breaks and how is it fixed?
Show answer
All of that video's comments, 10k per second, land on its owning dispatcher. Above a rate threshold, ingest for that video is split across N dispatchers that each keep a local top-K and merge candidates once per tick, the two-stage shape from Design Top K.
Why is the comments table partitioned by
(video_id, minute_bucket)and clustered byvideo_offset_msrather than partitioned by video and ordered by server time?Show answer
Partitioning by video alone would put a hot stream's 10k writes/s on one replica set and grow an unbounded partition; minute buckets cap each partition and spread a long stream. Keying by the poster's playhead offset lets replay line up with what the poster was reacting to, since live viewers can be 20 seconds apart, and replay reads one or two buckets sequentially.
Sources and further study
Primary
- Server-sent events, WHATWG HTML Standard: the normative event-stream format,
retry,Last-Event-IDand keepalive advice. - RFC 9113: HTTP/2: stream multiplexing, which is what lifts the per-domain connection limit for SSE.
Systems docs
- Using server-sent events (MDN): the browser API and the 6-connection limit over HTTP/1.1.
- Apache Kafka introduction: keyed partitions and the per-partition ordering the dispatcher relies on.
- Cassandra storage engine: the commit log, memtable and SSTable write path behind the comment store.
Books
- High Performance Browser Networking, Ilya Grigorik, chapter 16 "Server-Sent Events (SSE)": the protocol, its overhead and when to choose it over WebSocket (read free).
- Site Reliability Engineering, Beyer et al. (Google), chapter 22 "Addressing Cascading Failures": slow startup, cold caches and retry storms, the failure mode behind reconnect storms.
Other curricula
- Azure Architecture Center: Publisher-Subscriber pattern: frames live fan-out as topics and subscriptions decoupling one sender from many receivers.
- donnemartin/system-design-primer: covers the building blocks (asynchronism, caching, consistent hashing) but not live comments, so pair it with the LinkedIn and Slack write-ups.
Watch
- System Design Interview: Design Live Comments w/ a Ex-Meta Staff Engineer (Hello Interview): a full mock interview covering SSE versus WebSockets and how to route comments to the right servers.
- Design Live Comments Feature: System Design Interview with a Meta Engineer (interviewing.io): a recorded mock with interviewer feedback on scaling the fan-out.
Free resources
- Streaming a Million Likes/Second: Real-Time Interactions on Live Video (InfoQ): LinkedIn's talk on routing live likes and comments to connected viewers through a subscription-aware dispatcher.
- Instant Messaging at LinkedIn: Scaling to Hundreds of Thousands of Persistent Connections on One Machine (LinkedIn Engineering): how they hold SSE connections on Play and Akka, with the kernel tuning involved.
- Server-sent events (WHATWG HTML Standard): the framing rules, reconnection behaviour and
Last-Event-ID. - Real-time Messaging (Engineering at Slack): Slack's channel servers and gateway servers, a close cousin of the dispatcher and gateway split here.
- FB Live Comments breakdown (Hello Interview): a written walkthrough of the same interview question to compare against.