live-streaming

Live Video, Live Comments: A Real-Time Streaming Case Study

Treat live video and live comments as two completely separate systems joined by one shared key — the room ID. Video goes straight from browser to browser over WebRTC through a media server; comments are plain HTTP + Server-Sent Events, fanned out by a small in-memory pub/sub hub. Neither system depends on the other, so either one can fail, scale, or get replaced without touching the other.

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 POST plus 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#

  1. 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.
  2. 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.
  3. 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.
  4. 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.
Related reading

Building a Real-Time Leaderboard That Doesn't Fall Over

A real-time gaming leaderboard architecture case study — how Vaniworks builds live-ranking systems for games, contests, and apps that stay fast, accurate, and durable under concurrent score updates.

Does your business actually need a custom AI agent?

A practical way to tell whether a custom AI agent is worth building for your business, or whether a simpler automation gets you the same result faster.

Not sure where to start?
Start with a conversation.

Tell us what's on your mind. We'll tell you honestly whether we can help — and if not, who can.