Broadcast. Watch. Connect β live.
Self-hosted, real-time live streaming infrastructure built for scale. CrowdStream enables low-latency broadcaster-to-audience sessions with live chat, reactions, co-broadcasting, and viewer presence β with no third-party streaming dependency, and no cloud vendor lock-in.
Live session, verified across devices and networks β desktop broadcaster (left) streaming to a mobile viewer over 4G (right), same session ID on both ends.
- Demo
- Overview
- Key Features
- Architecture
- Multi-Pod Signaling
- Deployment Topology β Local β Cloud
- NAT Traversal: HAProxy + Envoy in Front of Coturn
- Key Flows
- Tech Stack
- Status
- Contact
CrowdStream is a one-to-many live video streaming system. A broadcaster captures camera and mic and publishes media; many viewers subscribe and consume it. Media never flows peer-to-peer β it routes through a mediasoup SFU on the backend. Socket.IO carries all signaling; audio/video itself travels over WebRTC (UDP/DTLS/SRTP), traversing STUN/TURN when a direct path isn't available.
The full stack β signaling, SFU, NAT traversal, and multi-pod room routing β has been built and validated end to end on a local, self-hosted topology. The design is intentionally cloud-portable: every local component (ngrok, Nginx, HAProxy/Envoy, Coturn) maps one-to-one onto a standard cloud equivalent (public ingress, load balancer/API gateway, TURN relay), so moving to a cloud environment is a configuration swap, not a redesign.
| Area | Capability |
|---|---|
| π₯ Broadcasting | Live video and audio over WebRTC, captured directly from browser camera/mic |
| π‘ SFU Delivery | Multi-viewer fan-out via mediasoup SFU β no peer-to-peer mesh, no transcoding |
| π¬ Live Chat | Real-time chat alongside the stream |
| β€οΈ Reactions | Real-time emoji reactions |
| ποΈ Co-broadcasting | Multiple active broadcasters in a single room |
| π Viewer Presence | Live viewer count |
| π§© Multi-Pod Signaling | Viewers and broadcasters land on different backend pods but still join the same room transparently |
| π NAT Traversal | Self-hosted Coturn (TURN/STUN), fronted by HAProxy and Envoy |
CrowdStream splits cleanly into a React client (broadcaster + viewer), a Node.js signaling layer, and a mediasoup SFU that owns the actual media plane. A Redis-backed layer sits between backend pods so signaling isn't bound to a single process.
flowchart TB
subgraph Clients["π₯οΈ Clients β React + Vite"]
BC[Broadcaster<br/>broadcaster.ts Β· room.ts<br/>Device Β· sendTransport Β· producers]
VW[Viewer<br/>viewer.ts Β· room.ts<br/>Device Β· recvTransport Β· consumers]
end
subgraph Backend["βοΈ Backend Pods β Node.js + TypeScript"]
SOCK[Socket.IO Gateway<br/>registerBroadcaster Β· registerViewer Β· disconnect]
SIG[Signaling / Domain Logic<br/>viewer.handler Β· broadcaster.util Β· canConsume]
MEDIA[Media Handlers<br/>consumer.handler β pause/resume/close]
end
subgraph Coord["π Cross-Pod Coordination"]
REDIS[(Redis<br/>Socket.IO adapter + room registry)]
end
subgraph SFU["π‘ MediaSoup SFU (^3.18)"]
WORKER[Worker<br/>1 per room Β· rtcPorts 40000β49999]
ROUTER[Router<br/>per room Β· Opus / VP8 / H264]
TRANSPORT[WebRtcTransport<br/>send for broadcaster Β· recv per viewer]
end
subgraph State["ποΈ Runtime State"]
MEM[(memoryRoom<br/>Map<roomId, Room>)]
MONGO[(MongoDB<br/>defined, not yet wired in)]
end
BC -->|Socket.IO signaling| SOCK
VW -->|Socket.IO signaling| SOCK
BC -->|WebRTC UDP/DTLS/SRTP| TRANSPORT
VW -->|WebRTC UDP/DTLS/SRTP| TRANSPORT
SOCK <-->|pub/sub across pods| REDIS
SOCK --> SIG --> MEDIA
SIG --> ROUTER
ROUTER --> WORKER
ROUTER --> TRANSPORT
SIG --> MEM
SIG -->|roomId β owning pod| REDIS
MONGO -.not invoked at runtime.-> MEM
Notes
- SFU model: the broadcaster uploads one stream; the SFU fans it out to N viewers β no transcoding, no P2P mesh.
- Runtime state is in-memory (
memoryRoom), scoped per pod. The MongoDB layer is defined but not yet wired into the handler flow, so room/session state doesn't survive a restart. - One mediasoup Worker per room today β no worker pool yet; horizontal scaling across CPU cores within a pod is a known next step.
- Redis does double duty: it backs the Socket.IO adapter (cross-pod event fan-out) and holds the
roomId β podownership registry described below.
A broadcaster and a viewer can land on different backend pods β normal behavior behind any load balancer with more than one replica β and still end up in the same room without either client being redirected or reconnected.
How it works:
Viewers can join a room whose mediasoup Router lives on a different pod than the one they're connected to, and the system transparently forwards the relevant calls to the owning pod and relays the response back β this path is implemented and working for joinRoom, createViewerTransport, connectConsumerTransport, consume, pauseConsumer, resumeConsumer, and viewer heartbeats.
How it works:
- Room ownership (
roomId β nodeId) is stored in Redis at room creation, keyed off each pod'sconfig.instanceId. - When a viewer's request lands on a non-owning pod, that pod doesn't reject it β it packages the call into a
PodCommandPayload(a typed request with arequestId, the original args, and areplyTochannel scoped to itself), publishes it to the owning pod over Redis, and registers a pending entry (with a 5-second timeout) in an in-memorypodRequestHandleMapkeyed byrequestId. - The owning pod executes the actual mediasoup call locally and publishes the result back on the
replyTochannel; the originating pod's pending entry resolves, and the viewer'sack()fires with the real result β the viewer's browser never needs to know a different pod handled it. - If the owning pod doesn't respond within 5 seconds, or the cross-pod publish reaches zero receivers, the request fails explicitly (
CROSS_POD_TIMEOUT/CROSS_POD_UNREACHABLE) rather than hanging.
Host vs. co-broadcaster: the room's primary host always creates the room on their own pod via createRoom, so the host is the owning pod by construction β cross-pod forwarding for createBroadcasterTransport/connectBroadcasterTransport/produce is never needed on the host path. It is needed for a co-broadcaster joining a room already owned by a different pod β that path currently checks ownership and explicitly rejects with 409 Room belongs to another node rather than forwarding, marked as a TODO in the handler.
MongoDB is wired into the live request path, write-through on every state change β not read from yet to gate behavior (Redis is still the source of truth for live routing decisions; nothing currently queries Mongo before letting a join or produce proceed).
LiveRoomβ one document per room, created oncreateRoomwithsfuNodeIdset to the owning pod'sconfig.instanceId(the same value used for the Redis ownership registry),totalViewersJoinedincremented on every successful join.Broadcasterβ one document per broadcaster session, with hashed IP/user-agent (ipHash,userAgentHashβ not raw values), andtransportIds/producerIdsappended as transports and producers are created.Viewerβ one document per viewer session, with the same hashed identifiers,transportIdsandconsumerIdsappended as they're created.
All writes are fire-and-forget (void Model.create(...).catch(...)) off the critical path β persistence failures are logged but never block or fail the underlying signaling response.
sequenceDiagram
participant V as Viewer (connected to Pod B)
participant PB as Backend Pod B
participant R as Redis (adapter + room registry)
participant PA as Backend Pod A (owns the room)
participant SFU as mediasoup SFU (on Pod A)
V->>PB: joinRoom(roomId)
PB->>R: lookup roomId β owning pod
R-->>PB: roomId owned by Pod A
PB->>R: publish joinRoom event (Socket.IO adapter)
R-->>PA: deliver joinRoom event
PA->>SFU: getRouterRtpCapabilities / createViewerTransport
SFU-->>PA: transport + ICE/DTLS params
PA->>R: publish response event
R-->>PB: deliver response event
PB-->>V: transport params (over its own Socket.IO connection)
V->>SFU: WebRTC media (ICE/DTLS/SRTP) β direct to SFU, not via Pod B
Why this matters: clients never need to know or care which pod they're attached to, and a room isn't tied to "whichever pod the client happens to hit." This is the same pattern used to horizontally scale any Socket.IO deployment (@socket.io/redis-adapter), extended here with an explicit room-ownership registry so mediasoup-specific calls are routed to the one pod that actually holds the Router for that room.
The current deployment is fully self-hosted and has been validated locally. Every layer maps directly onto a cloud equivalent β nothing in the design is local-only, so moving environments is a matter of pointing the same configuration at managed infrastructure rather than rebuilding it.
| Local component | Cloud equivalent | Role |
|---|---|---|
| ngrok | Public ingress / DNS + managed TLS | Exposes the app publicly |
| Nginx | Nginx (unchanged) or a managed reverse proxy | Routes HTTP/Socket.IO traffic to backend pods |
| HAProxy (TCP) | Cloud NLB / TCP listener | TURN-over-TCP path for clients where UDP is blocked |
| Envoy (UDP) | Cloud NLB / UDP listener | STUN/TURN-over-UDP path β the primary ICE path |
| Coturn | Coturn (unchanged) | TURN/STUN relay for NAT traversal |
| Single-pod backend | Multiple backend pods behind a load balancer | Same Socket.IO + mediasoup code, now horizontally scaled via the Redis adapter above |
sequenceDiagram
participant U as Broadcaster / Viewer
participant Ingress as Ingress<br/>(ngrok locally / DNS+LB in cloud)
participant NG as Nginx
participant BE as Node.js Backend Pod
participant SFU as mediasoup SFU
participant TURN as Coturn
U->>Ingress: HTTPS / Socket.IO
Ingress->>NG: forward request
NG->>BE: route to backend pod
BE->>BE: createRoom / exchange RTP capabilities
BE->>SFU: create WebRTC transport
SFU-->>BE: ICE + DTLS parameters
SFU->>SFU: generate ICE candidates
SFU->>TURN: STUN binding / TURN allocation
TURN-->>SFU: relay candidate
SFU-->>U: candidates returned via signaling
U->>U: ICE connectivity checks
U->>TURN: selected candidate pair
TURN->>SFU: DTLS handshake β SRTP media flow
With no managed load balancer in the self-hosted path, TURN/STUN traffic is split across two proxies before it reaches Coturn β HAProxy handles the TCP path, Envoy handles the UDP path. This pair stands in for a cloud ALB/NLB and works identically against a cloud-hosted deployment.
β οΈ Local setup requirement: Coturn needs to advertise the machine's actual public-facing IPv4 (itsexternal-ipconfig) for ICE candidates to resolve correctly. If the machine running the server is on a mobile hotspot, the visible public IP can be behind carrier-grade NAT or change between sessions β in that case, TURN relay candidates get advertised with the wrong address, and clients on a different network than the host will fail to connect even though everything looks configured correctly. Bind Coturn'sexternal-ipto a stable, reachable public IPv4 (a static IP where available, or the actual current public IP if not) before testing across networks. This is a local/self-hosted-topology concern only β a cloud deployment behind a proper NLB/TURN relay (see Deployment Topology) doesn't have this problem, since the relay's public address is stable by design.
flowchart TB
CLIENT[WebRTC Client] -->|ICE: STUN / TURN| SPLIT{Transport type}
SPLIT -->|TURN over TCP| HAP[HAProxy<br/>TCP Proxy]
SPLIT -->|STUN/TURN over UDP| ENVOY[Envoy<br/>UDP Proxy]
HAP --> TURN[(Coturn)]
ENVOY --> TURN
TURN --> RELAY[Relay Media]
RELAY --> SFU[mediasoup SFU]
- HAProxy proxies TURN-over-TCP connections β useful for clients on networks that block or throttle UDP.
- Envoy proxies the UDP path for STUN and TURN β the primary route for ICE connectivity checks and media relay.
- Both sit in front of a single Coturn instance, giving it one consistent entry point regardless of which transport a client's ICE agent picks.
- This layer is a like-for-like substitute for a cloud ALB/NLB; the same HAProxy/Envoy configuration runs unchanged in front of a cloud-hosted Coturn instance.
Broadcast (publish):
createRoom β owning pod spins up Worker + Router, registers roomId β podId in Redis β getRouterRtpCapabilities β client loads Device β createBroadcasterTransport β connectBroadcasterTransport (DTLS) β produce (video + audio) β Producers stored in memoryRoom on the owning pod.
View (subscribe), same pod or different pod:
joinRoom β signaling layer resolves the room's owning pod via Redis (transparent if it's the same pod) β load Device β createViewerTransport β connectConsumerTransport (DTLS) β consume (creates paused Consumers per producer, gated by canConsume) β resumeConsumer β client builds a MediaStream and renders. Signaling events are relayed pod-to-pod over the Socket.IO Redis adapter; the media path (WebRTC) always goes directly from the client to the SFU that owns the room.
Teardown:
disconnect β handleDisconnect closes transports/producers/consumers, removes the entry from memoryRoom, and clears the room's ownership entry from Redis.
| Layer | Technology |
|---|---|
| Backend | Node.js, TypeScript, Socket.IO |
| Cross-Pod Coordination | Redis β Socket.IO adapter (pub/sub) + room-ownership registry |
| Media | MediaSoup (WebRTC SFU) β one worker process per room, media plane in C++ off the event loop |
| Frontend | React, Vite, mediasoup-client |
| Database | MongoDB (Mongoose) β write-through persistence for rooms, broadcasters, and viewers; not yet read from on the live request path |
| NAT Traversal | Coturn (TURN/STUN) |
| Proxy / Ingress | ngrok (local) / DNS + managed ingress (cloud), Nginx, HAProxy (TCP), Envoy (UDP) |
| Media Processing | FFmpeg (thumbnails now, recording planned) |
Built by Harshit Singh Parihar.
