Part V β The Craft Β· Lesson 10.14
YouTube Live Stream Chat UI
In one lineHundreds of messages a second into a fixed-size window β buffer, cap, virtualize, and pin to bottom carefully.
Where you've seen itLive chat during a big match, where the feed is a blur and the browser still stays smooth.
Diagram
BUFFER AND FLUSH β the core trick
WebSocket (2.4)
βΌ βΌ βΌ βΌ βΌ βΌ βΌ βΌ βΌ βΌ βΌ βΌ ~300 messages/sec
ββββββββββββββββββββββββββββ
β in-memory buffer array β push only β no React state
ββββββββββββββ¬ββββββββββββββ
β requestAnimationFrame / 100ms tick
βΌ
ββββββββββββββββββββββββββββ
β ONE setState per flush β ~10 renders/sec instead of 300
ββββββββββββββ¬ββββββββββββββ
βΌ
bounded window
300 renders/sec = dropped frames. 10 renders/sec = smooth.BOUNDED WINDOW β memory must not grow with stream length
βββββββββββββββββββββββββββββββ
β msg 4,981 β keep the last N (200β500)
β msg 4,982 β drop from the head on append
β β¦ β
β msg 5,000 β newest β a 6-hour stream must not
βββββββββββββββββββββββββββββββ hold 2M messagesPIN TO BOTTOM β only when the user is already there
atBottom = scrollHeight - scrollTop - clientHeight < 40px
atBottom β auto-scroll on each flush
scrolled β STOP auto-scrolling, show "β Jump to latest (128)"
up
user is reading history; moving them is hostile
Also: pause auto-scroll on hover, so a mouse-over freezes the feed.How it works
- Never
setStateper message. Push into a plain array and flush on a timer or animation frame. - Cap the rendered window to a few hundred messages, dropping the oldest on append.
- Virtualize if rows vary in height or the window is large (10.7).
- Track
atBottomand only auto-scroll when true; otherwise show a jump-to-latest button with an unread count. - Handle the extras that make it real: dedupe by message id, moderation deletions, rate-limit spam per user, and a "chat paused" state under extreme load β which is exactly what YouTube does.
const buffer = useRef([]);
useEffect(() => {
socket.onmessage = (e) => buffer.current.push(JSON.parse(e.data));
const id = setInterval(() => {
if (!buffer.current.length) return;
setMessages((prev) => [...prev, ...buffer.current].slice(-MAX)); // cap
buffer.current = [];
}, 100);
return () => clearInterval(id);
}, []);Trade-offs
| Decision | Consequence |
|---|---|
| Flush every 100ms | 10 renders/sec, imperceptible delay |
| Flush per message | Smooth data, unusable UI |
| Cap at 300 messages | Bounded memory, history lost |
| Virtualize | Handles any size, more complexity |
| Auto-scroll always | Destroys the reading experience |
Accessibility (8.3): a live region announcing every message is unusable. Announce nothing by default, offer a "pause and read" mode, and keep the scroll container keyboard-focusable so it can be scrolled without a mouse.
Interview angle
"Design live chat for a stream with 500k viewers."
- Client side: buffer and flush on a frame, cap the window, virtualize, and pin to bottom only when the user is already there.
- Transport: SSE or WebSocket per viewer, fanned out from a pub/sub layer β and at extreme volume the server samples messages rather than sending all of them.
- Say the quiet part: nobody can read 300 messages a second, so sampling and "chat paused" are features, not degradations.
Recap
- Buffer in a ref, flush on a tick β one render per frame, not per message.
- Bound the window; memory must not scale with stream duration.
- Auto-scroll is conditional on the user already being at the bottom.