Real-Time Media & WebRTC Audio/Video Mesh
Peer-to-peer audio/video streaming, low-latency room signaling over WebSockets, and state broadcasting under concurrent user loads.
Overview
WebRTC enables browser-to-browser audio/video and data transfer without a central media server. The server only orchestrates signaling (SDP offer/answer + ICE candidates) — media flows directly peer-to-peer.
Signaling Server (Socket.IO)
// server/signaling.ts
import { Server } from "socket.io";
export function attachSignaling(io: Server) {
io.on("connection", (socket) => {
socket.on("join-room", (roomId: string) => {
socket.join(roomId);
socket.to(roomId).emit("peer-joined", socket.id);
});
socket.on("offer", ({ to, sdp }: { to: string; sdp: RTCSessionDescriptionInit }) => {
io.to(to).emit("offer", { from: socket.id, sdp });
});
socket.on("answer", ({ to, sdp }: { to: string; sdp: RTCSessionDescriptionInit }) => {
io.to(to).emit("answer", { from: socket.id, sdp });
});
socket.on("ice-candidate", ({ to, candidate }: { to: string; candidate: RTCIceCandidateInit }) => {
io.to(to).emit("ice-candidate", { from: socket.id, candidate });
});
socket.on("disconnect", () => {
socket.broadcast.emit("peer-left", socket.id);
});
});
}
Peer Connection Hook (React)
// hooks/useWebRTC.ts
import { useEffect, useRef, useCallback } from "react";
import { socket } from "@/lib/socket";
const ICE_SERVERS = [{ urls: "stun:stun.l.google.com:19302" }];
export function useWebRTC(roomId: string) {
const localStream = useRef<MediaStream | null>(null);
const peers = useRef<Map<string, RTCPeerConnection>>(new Map());
const createPeer = useCallback((peerId: string) => {
const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });
localStream.current?.getTracks().forEach((t) => pc.addTrack(t, localStream.current!));
pc.onicecandidate = (e) => {
if (e.candidate) socket.emit("ice-candidate", { to: peerId, candidate: e.candidate });
};
pc.ontrack = (e) => {
// attach e.streams[0] to a <video> element
document.querySelector<HTMLVideoElement>(`#video-${peerId}`)!.srcObject = e.streams[0];
};
peers.current.set(peerId, pc);
return pc;
}, []);
useEffect(() => {
async function init() {
localStream.current = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
socket.emit("join-room", roomId);
}
socket.on("peer-joined", async (peerId: string) => {
const pc = createPeer(peerId);
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
socket.emit("offer", { to: peerId, sdp: offer });
});
socket.on("offer", async ({ from, sdp }: { from: string; sdp: RTCSessionDescriptionInit }) => {
const pc = createPeer(from);
await pc.setRemoteDescription(sdp);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
socket.emit("answer", { to: from, sdp: answer });
});
socket.on("answer", async ({ from, sdp }: { from: string; sdp: RTCSessionDescriptionInit }) => {
await peers.current.get(from)?.setRemoteDescription(sdp);
});
socket.on("ice-candidate", async ({ from, candidate }: { from: string; candidate: RTCIceCandidateInit }) => {
await peers.current.get(from)?.addIceCandidate(candidate);
});
init();
return () => { peers.current.forEach((pc) => pc.close()); };
}, [roomId, createPeer]);
return { localStream };
}
Handling Scale: SFU vs Mesh
| Topology | Participants | Server Load | Latency |
|---|---|---|---|
| Mesh (P2P) | 2–4 | None | Ultra-low |
| SFU (mediasoup) | 4–100 | Medium | Low |
| MCU | 100+ | High | Medium |
For >4 participants, switch to a Selective Forwarding Unit like mediasoup or LiveKit.
Key Takeaways
- The server only handles signaling; media never touches it in a pure WebRTC mesh
- Always add TURN servers for production — STUN alone fails behind symmetric NAT
- Use SFU topology once you exceed 4 participants to avoid O(n²) upload bandwidth