TL;DR#
The ask: a host goes live from a browser, any number of viewers watch in real time, and everyone in the room — host included — can post comments that show up live for everyone, with a live comment count.
The answer: don't build one system that does both. Build two systems — video and comments — that never call each other, and connect them with nothing more than a shared room ID. Video speaks WebRTC directly to a media server; comments are plain HTTP plus Server-Sent Events, fanned out by a small in-memory hub.
Here's the full breakdown of how it's built, and why the split is the whole point.
The Challenge#
"Add live streaming" sounds like one feature. It's really two features wearing a trenchcoat, each with its own hard constraints:
- Video is latency-sensitive and bandwidth-heavy. It needs a direct, negotiated connection between browsers (or through a media relay), real microphone/camera capture, and graceful handling of a host who hasn't gone live yet.
- Comments are high-frequency and cheap. Every viewer can post at any moment, everyone else needs to see it within a second, and a late joiner still needs to land on the correct running count — not "0" until the next message happens to arrive.
- Neither should be able to break the other. A camera permission prompt failing shouldn't take down the comment feed. A burst of comments during a hyped moment shouldn't add jitter to the video.
- The room is the same "thing" to the user, even though under the hood it's two independent real-time pipelines that need to agree on exactly one fact: which room they're both talking about.
Most teams' first instinct is to run everything — presence, chat, video signaling — through one connection and one server. That's exactly the coupling that causes a slow chat message to stall a video frame, or a video reconnect to wipe out the comment feed's state.
The Approach#
Split the system down the middle. The room name is the video ID — that one shared string is the only thing connecting two otherwise independent systems:
flowchart LR
subgraph Browser["Host / Viewer — browser"]
UI["React app"]
end
subgraph App["Application server"]
Token["POST /token"]
Comments["POST /comments"]
Stream["GET /stream — SSE"]
Hub[("Hub\nroom → subscribers")]
end
subgraph Media["Media server (SFU)"]
Room[("Room = video ID")]
end
UI -- "1: mint JWT" --> Token
Token -- "signed JWT" --> UI
UI -- "2: connect(token)\npublish / subscribe WebRTC" --> Room
UI -- "3: POST comment" --> Comments
Comments --> Hub
UI -- "4: SSE subscribe" --> Stream
Stream --> Hub
Hub -- "fan out comment + count events" --> Stream
Two things never touch each other:
- Video never touches the application server. The browser calls the server once to get a short-lived access token, then speaks WebRTC directly to the media server. The application process has zero involvement in the actual audio/video after minting that token.
- Comments never touch the media server. They're plain HTTP
POSTplus Server-Sent Events against the application server, fanned out by a small in-memory pub/sub hub. No message broker required for an MVP — a deliberate tradeoff, covered below.
That separation is the entire architectural thesis: two orthogonal real-time systems, joined by one shared key, so each can be reasoned about, scaled, and debugged in isolation.
How It Works: the video plane#
1. Mint a scoped access token. The client asks the server for a token
for a given room and role (publisher for the host, subscriber for a
viewer). Role becomes a capability grant baked into the signed token —
CanPublish is only ever true for the host — not a route the client has
to be trusted to respect.
2. Host goes live.
sequenceDiagram
participant Host as Host browser
participant App as App server
participant Media as Media server
Host->>App: request token (role: publisher)
App-->>Host: signed access token
Host->>Media: connect(mediaUrl, token)
Host->>Host: capture camera + mic
Host->>Media: publish video track, publish audio track
Note over Host: status: idle -> connecting -> live
The host's UI state machine (idle | connecting | live | error) is driven
entirely by the order those promises resolve: connect to the room, grab
local media, publish both tracks, then flip to "live." Cleanup on
unmount stops local tracks and disconnects the room — easy to skip, and
the most common leak in WebRTC demos (a camera indicator that never turns
off).
3. Viewer watches.
sequenceDiagram
participant Viewer as Viewer browser
participant App as App server
participant Media as Media server
Viewer->>App: request token (role: subscriber)
App-->>Viewer: signed access token
Viewer->>Media: connect(mediaUrl, token)
Media-->>Viewer: track subscribed (video)
Viewer->>Viewer: attach track to <video>
Note over Viewer: status: connecting -> waiting -> watching
The viewer registers its track-subscribed listener before connecting to the room, so it can never race the host's track arriving before the listener exists. Status only reaches "watching" once an actual video track is subscribed — not just once the room connects — which is what makes "waiting for the host to go live" a real state instead of a cosmetic one.
How It Works: the comments plane#
Why Server-Sent Events, not WebSockets. Comments are one-directional
broadcast (server to many clients) plus an occasional write (one client
to server). SSE is the right-sized tool: plain HTTP, the browser's native
EventSource reconnects automatically on a dropped connection, and
there's no need for the bidirectional complexity — or the extra
infrastructure — a WebSocket brings for a feed that never needs the
server to ask the client anything.
A hub that never lets a slow client stall the room. Each SSE connection gets a small buffered channel. Publishing a comment fans it out to every subscriber in that room, and critically, never blocks on any one of them:
flowchart TD
Post["POST /comments"] --> Validate["validate + assign next ID"]
Validate --> PubComment["publish: comment event"]
Validate --> PubCount["publish: count event"]
PubComment --> Fanout{{"fan out to room subscribers"}}
PubCount --> Fanout
Fanout -->|"buffer has room"| Deliver["deliver over SSE"]
Fanout -->|"buffer full"| Drop["drop this subscriber"]
Drop --> Reconnect["EventSource auto-reconnects\nand gets a fresh count"]
If a subscriber's buffer fills — a slow connection, a backgrounded tab —
it gets dropped outright rather than backpressuring the publish path.
EventSource reconnects on its own and is immediately primed with the
room's current running count, so the UI barely notices. It's a clean
example of "fail the client, not the room."
Two events per comment, one shared identity model. Every accepted
comment gets a per-room, monotonically increasing ID and triggers two
events: the comment payload itself, then a count event with the
room's new running total. A fresh connection is primed with the current
count immediately on subscribe, so a late joiner never sits at "0" until
the next comment happens to land. And there's no local-echo special case
in the frontend — the person who posted a comment receives it back over
their own SSE connection exactly like everyone else. The host isn't a
privileged commenter, just another participant in the same room.
The Gotcha: two systems that fail differently#
The most instructive bug in building this wasn't in the application code at all — it was in the media server's network configuration, and it's a good illustration of why "it works on my machine" is especially dangerous for real-time systems.
Comments worked fine across devices on the LAN — ordinary HTTP through a mapped port. Video silently failed for anyone not on the same machine as the server. The cause: without an explicit external IP configured, the self-hosted media server was advertising its container's internal Docker IP as the WebRTC ICE candidate address — an address only the host machine itself can reach.
Comments went through a proxied, mapped port, so the container's internal addressing never mattered. WebRTC negotiates its own connection using whatever address the media server hands it — it needs a real, externally-reachable IP baked into the signaling itself, not a mapped port to hide behind. The fix was pinning the media server's advertised IP to the host's actual LAN address. Two systems, one bug class, two completely different symptoms — exactly the kind of thing that's cheap to catch on day one and expensive to debug after a customer reports "video doesn't work for our remote team."
API Surface#
| Method | Endpoint | Purpose |
|---|---|---|
POST |
/token |
Mint a scoped access token for a room (publisher or subscriber) |
POST |
/comments |
Submit a comment for a room |
GET |
/stream |
Subscribe to a room's live comment + count events (SSE) |
What This Validated#
The build was verified against one concrete acceptance test, run with two independent, automated browser sessions: a host opens the host view in one session and grants camera/mic, a viewer opens the watch view in a second session, sees the host's video, posts a comment, and it appears on both sides in real time.
A few properties held up beyond that happy path:
- Independent failure. A viewer's comment connection dropping and reconnecting has zero effect on their video. A host's camera permission being denied has zero effect on the comment feed still working.
- No polling, anywhere. Video tracks arrive via a subscribed-track event; comments arrive via SSE push. Neither side of the app ever asks "anything new?" on a timer.
- Correct on late join. A viewer joining mid-stream lands on the correct comment count immediately, not after the next comment happens to be posted.
- One identity model for the whole room. No separate auth systems for "who can watch" versus "who can comment" — a single room ID and a typed display name is the entire v1 identity model, and it's enough.
Why This Matters Beyond a Demo#
The pattern here — two independent real-time systems joined by a shared key, rather than one monolithic real-time server — is the same shape a production-scale live product needs. Swapping the in-memory comment hub for a pub/sub-backed one so the application server can run multiple replicas doesn't change anything about the video plane. Persisting comments so a reconnecting viewer can replay history doesn't change anything about how tokens are minted. Each half of the system can grow up independently.
That generality is what makes this worth having as a reusable pattern rather than a one-off build: it's the same architecture behind live shopping and product-launch streams, webinars and virtual classes with a live Q&A, fitness and coaching sessions, community AMAs, and any other product surface where "watch together, talk together, live" is the feature being asked for.
Tech Stack#
Go · a self-hosted WebRTC media server · React · Server-Sent Events for the comment transport · JWT-based, capability-scoped access tokens
Key Takeaways#
- Find the one shared key, then split everything else. A room ID doubling as the join point between video and comments is the single decision that makes the rest of the system simple — no cross-service lookups, just a shared string.
- Match the transport to the traffic shape. Comments are broadcast plus occasional writes — SSE fits that shape exactly and gets reconnection for free. Don't reach for a bidirectional protocol a feed doesn't need.
- Design for the slow client, not just the happy path. Dropping a backpressured subscriber instead of blocking the publisher keeps one stalled viewer from ever becoming everyone's problem.
- Capability, not route, is where access control belongs. A signed token that can or can't publish is enforceable no matter what the client does; a client-side route guard is not.