Designing a WebRTC Backend: Signaling Servers, TURN Relays, and Scalable Peer Connection Management
WebRTC moves media between browsers without a server in the middle, but the backend infrastructure that makes it work reliably at scale is non-trivial. This covers signaling architecture, STUN/TURN deployment, SFU vs MCU topologies, room state machines, and fallback strategies.
WebRTC is one of those technologies where the browser-side API looks deceptively simple, but the backend required to make it production-ready is genuinely complex. You can get two peers connected in a weekend demo. Scaling that to thousands of concurrent sessions with acceptable failure rates takes months of production experience.
This article covers the full backend surface: signaling architecture, NAT traversal infrastructure, media server topologies, room management state machines, and what to do when peer connections fail.
The Signaling Layer
WebRTC does not define a signaling protocol. The spec deliberately leaves it open. In practice, almost every production system uses WebSocket because you need bidirectional, low-latency message delivery to exchange SDP offers/answers and ICE candidates.
The signaling server does three things: it routes SDP between peers, it routes ICE candidates between peers, and it maintains enough room state to know which peers belong together.
import { WebSocketServer, WebSocket } from "ws";
interface SignalingMessage {
type: "offer" | "answer" | "candidate" | "join" | "leave";
roomId: string;
peerId: string;
targetPeerId?: string;
payload: unknown;
}
interface Room {
id: string;
peers: Map<string, WebSocket>;
createdAt: number;
}
const rooms = new Map<string, Room>();
const peerToRoom = new Map<string, string>();
function getOrCreateRoom(roomId: string): Room {
if (!rooms.has(roomId)) {
rooms.set(roomId, { id: roomId, peers: new Map(), createdAt: Date.now() });
}
return rooms.get(roomId)!;
}
function broadcast(room: Room, message: SignalingMessage, excludePeerId: string): void {
const serialized = JSON.stringify(message);
for (const [peerId, socket] of room.peers) {
if (peerId !== excludePeerId && socket.readyState === WebSocket.OPEN) {
socket.send(serialized);
}
}
}
const wss = new WebSocketServer({ port: 8080 });
wss.on("connection", (socket) => {
let currentPeerId: string | null = null;
socket.on("message", (data) => {
const msg: SignalingMessage = JSON.parse(data.toString());
if (msg.type === "join") {
currentPeerId = msg.peerId;
const room = getOrCreateRoom(msg.roomId);
room.peers.set(msg.peerId, socket);
peerToRoom.set(msg.peerId, msg.roomId);
// Notify existing peers so they can initiate offers
broadcast(room, msg, msg.peerId);
return;
}
if (msg.type === "offer" || msg.type === "answer" || msg.type === "candidate") {
if (!msg.targetPeerId) return;
const roomId = peerToRoom.get(msg.peerId);
if (!roomId) return;
const room = rooms.get(roomId);
if (!room) return;
const target = room.peers.get(msg.targetPeerId);
if (target && target.readyState === WebSocket.OPEN) {
target.send(JSON.stringify(msg));
}
}
});
socket.on("close", () => {
if (!currentPeerId) return;
const roomId = peerToRoom.get(currentPeerId);
if (roomId) {
const room = rooms.get(roomId);
if (room) {
room.peers.delete(currentPeerId);
broadcast(room, { type: "leave", roomId, peerId: currentPeerId, payload: null }, currentPeerId);
if (room.peers.size === 0) rooms.delete(roomId);
}
peerToRoom.delete(currentPeerId);
}
});
});
This is the skeleton. In production, you add authentication (JWT validation on upgrade), rate limiting per peer, and persistence for room state across signaling server restarts.
One architectural decision that matters early: do you store room state in memory or in a shared store like Redis? In-memory is fine for a single signaling instance, but once you need horizontal scaling, peers in the same room may land on different instances. You need sticky routing (by roomId) or a pub/sub layer that propagates messages across instances.
// Redis-backed signaling relay
import { createClient } from "redis";
const publisher = createClient({ url: process.env.REDIS_URL });
const subscriber = createClient({ url: process.env.REDIS_URL });
await Promise.all([publisher.connect(), subscriber.connect()]);
// Subscribe to this instance's channel
await subscriber.subscribe(`signaling:${instanceId}`, (rawMessage) => {
const { targetPeerId, message } = JSON.parse(rawMessage);
const socket = localPeers.get(targetPeerId);
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(message));
}
});
// Routing: look up which instance owns the target peer, then publish to that channel
async function routeMessage(targetPeerId: string, message: SignalingMessage): Promise<void> {
const ownerInstance = await redisClient.get(`peer:instance:${targetPeerId}`);
if (!ownerInstance) return;
await publisher.publish(`signaling:${ownerInstance}`, JSON.stringify({ targetPeerId, message }));
}
NAT Traversal: STUN and TURN
STUN (Session Traversal Utilities for NAT) lets a peer discover its public IP and port. The browser sends a binding request to the STUN server, and the server reflects back the source address as seen from outside the NAT. This works for most residential connections with full-cone or address-restricted NAT.
Symmetric NAT, which is common in corporate networks and many mobile carriers, blocks STUN-only connections. The peer’s external address changes per destination. Here you need TURN (Traversal Using Relays around NAT), which proxies the entire media stream through a relay server.
TURN relay traffic means your relay server handles every byte of audio and video for affected sessions. At 500 kbps per video participant and 30% of sessions hitting TURN (a realistic enterprise figure), a room of four people using TURN generates roughly 6 Mbps of relay traffic per room. Plan capacity accordingly.
Coturn is the standard open-source TURN server. A typical production configuration:
listening-port=3478
tls-listening-port=5349
fingerprint
lt-cred-mech
realm=yourdomain.com
# Time-limited credential generation (prevents credential abuse)
use-auth-secret
static-auth-secret=<your-secret>
total-quota=100
bps-capacity=5000000
Generate short-lived TURN credentials per session on your backend:
import * as crypto from "crypto";
interface TurnCredential {
urls: string[];
username: string;
credential: string;
}
function generateTurnCredential(secret: string, ttlSeconds = 86400): TurnCredential {
const timestamp = Math.floor(Date.now() / 1000) + ttlSeconds;
const username = `${timestamp}:${crypto.randomUUID()}`;
const credential = crypto
.createHmac("sha1", secret)
.update(username)
.digest("base64");
return {
urls: [
"stun:turn.yourdomain.com:3478",
"turn:turn.yourdomain.com:3478",
"turns:turn.yourdomain.com:5349",
],
username,
credential,
};
}
Return these credentials from your session API and pass them as iceServers to RTCPeerConnection. Rotate the static secret periodically and never expose it client-side.
For geographic scale, deploy TURN in multiple regions and route peers to the nearest relay based on IP geolocation or latency probing. A mismatch between TURN region and peer location adds 100-200 ms of unnecessary relay latency, which destroys voice quality.
SFU vs MCU: Choosing a Media Server Topology
For calls beyond two participants, you need a media server. Two topologies dominate:
SFU (Selective Forwarding Unit): Each participant sends one stream to the SFU. The SFU forwards individual streams to each subscriber without decoding or re-encoding. CPU cost is low; bandwidth from the SFU to each subscriber scales linearly with participant count (each subscriber receives N-1 streams).
MCU (Multipoint Control Unit): Each participant sends one stream. The MCU decodes all streams, composites them into a single mixed stream, and sends one stream to each participant. CPU cost is high and scales with room size; bandwidth per participant is fixed regardless of room size.
| Factor | SFU | MCU |
|---|---|---|
| CPU per room | Low (no decode/encode) | High (decode + mix + encode per participant) |
| Bandwidth to client | O(N) streams | O(1) stream |
| Simulcast / quality adaptation | Native | Complex |
| Layout control | Client-side | Server-side |
| Latency | ~50 ms | ~150-300 ms (encode pipeline) |
| Good for | Video conferencing, webinars | Low-bandwidth clients, legacy SIP bridging |
SFU is the right choice for most applications. MCU makes sense only when downstream bandwidth is severely constrained or when you need to bridge to legacy telephony systems.
Popular open-source SFU options: mediasoup (Node.js, production-grade), Pion (Go), Livekit (Go, more opinionated). If you need to start fast and your scale is modest, Livekit’s hosted offering removes the operational overhead. If you need control over the media pipeline, mediasoup gives you that without taking a black-box approach.
Room State and Peer Connection State Machines
A WebRTC session has two layers of state: the signaling state (SDP negotiation) and the ICE/DTLS connection state. Both need to be modeled and tracked.
type PeerConnectionState =
| "new"
| "connecting"
| "connected"
| "disconnected"
| "failed"
| "closed";
type SignalingState =
| "stable"
| "have-local-offer"
| "have-remote-offer"
| "have-local-pranswer"
| "have-remote-pranswer"
| "closed";
interface PeerSession {
peerId: string;
roomId: string;
connectionState: PeerConnectionState;
signalingState: SignalingState;
iceGatheringComplete: boolean;
connectedAt: number | null;
lastActivity: number;
failureReason: string | null;
}
// Track state transitions server-side via signaling messages
// and heartbeat pings from the client
function handlePeerStateUpdate(
session: PeerSession,
event: { connectionState?: PeerConnectionState; signalingState?: SignalingState }
): PeerSession {
const updated = { ...session, lastActivity: Date.now() };
if (event.connectionState) {
updated.connectionState = event.connectionState;
if (event.connectionState === "connected") {
updated.connectedAt = Date.now();
}
if (event.connectionState === "failed") {
updated.failureReason = "ice-failure";
}
}
if (event.signalingState) {
updated.signalingState = event.signalingState;
}
return updated;
}
The failed state is where most production bugs live. When ICE fails, the browser fires connectionstatechange with "failed", but it does not automatically retry. You need a client-side recovery strategy: restart ICE negotiation if the session is still valuable, or surface the failure to the user.
// Client-side ICE restart on failure
peerConnection.onconnectionstatechange = () => {
if (peerConnection.connectionState === "failed") {
attemptIceRestart();
}
};
async function attemptIceRestart(): Promise<void> {
const offer = await peerConnection.createOffer({ iceRestart: true });
await peerConnection.setLocalDescription(offer);
// Send the new offer through the signaling channel
signalingSocket.send(JSON.stringify({
type: "offer",
roomId,
peerId: localPeerId,
targetPeerId: remotePeerId,
payload: offer,
}));
}
Track these transitions in your session store. A peer that cycles connected -> disconnected -> connected repeatedly is usually behind a flaky network or a firewall doing aggressive NAT mapping refresh. Knowing the failure pattern helps you decide whether to recommend TURN for that peer.
Scaling Strategies
Horizontal scaling of signaling servers is straightforward once you introduce the Redis pub/sub relay described above. Route WebSocket connections by roomId using consistent hashing at the load balancer layer, and you eliminate cross-instance message routing for most rooms.
TURN relay scaling is a different problem. Each coturn instance is a UDP relay at the kernel level. You scale by adding instances and routing peers by geographic proximity. Monitor bandwidth per instance, not just connection count. A room of five participants with 720p video and audio can saturate 30-40 Mbps of relay bandwidth on a single instance if all five hit TURN.
For SFU scaling, mediasoup and Livekit both support horizontal scaling via a routing layer. Each SFU worker handles a fixed number of rooms. A routing service maps roomId to the SFU worker and pins all participants in a room to the same worker instance.
// Simplified SFU routing
interface SfuWorker {
id: string;
endpoint: string;
currentRooms: number;
maxRooms: number;
}
function selectWorkerForRoom(roomId: string, workers: SfuWorker[]): SfuWorker | null {
// Check if room already has an assigned worker
const assigned = roomToWorker.get(roomId);
if (assigned) {
const worker = workers.find((w) => w.id === assigned);
if (worker) return worker;
}
// Assign to least-loaded worker with capacity
const available = workers
.filter((w) => w.currentRooms < w.maxRooms)
.sort((a, b) => a.currentRooms - b.currentRooms);
if (available.length === 0) return null;
roomToWorker.set(roomId, available[0].id);
return available[0];
}
At the database level, room metadata (participant list, creation time, settings) should live in Redis with a TTL. Active sessions are ephemeral. Do not persist signaling state to PostgreSQL in the hot path.
Bandwidth Estimation and Congestion Control
WebRTC includes REMB (Receiver Estimated Maximum Bitrate) and Transport-wide Congestion Control (TWCC) for bandwidth adaptation. The SFU reads these feedback messages and adjusts the bitrate of forwarded streams accordingly.
With simulcast, the publisher sends multiple quality layers (360p, 720p, 1080p) and the SFU selects which layer to forward per subscriber based on their REMB feedback. This is the right architecture for rooms where participants have heterogeneous connections.
Monitor these metrics per session in production:
- Round-trip time (RTT) via RTCP SR/RR
- Packet loss percentage (over 3-5% consistently is a user-visible problem)
- REMB reported bandwidth (watch for oscillation, which indicates a congested path)
- Jitter buffer delay
Log these as time series. Alert when packet loss exceeds 5% for more than 30 seconds across more than 1% of active sessions. That pattern usually correlates with a regional network issue or a misbehaving TURN instance.
Fallback to HLS/DASH
Peer connections fail. Some corporate firewalls block all UDP and restrict outbound TCP to port 443. Even with TURN over TLS on port 443, some networks deep-packet-inspect and block DTLS handshakes.
For broadcast use cases (webinars, one-to-many streams), having an HLS/DASH fallback from the SFU is standard practice. The SFU ingests the publisher’s stream and, in parallel, transcodes it and outputs HLS segments to an origin server and CDN.
The fallback path adds 8-30 seconds of latency (HLS segment duration plus CDN propagation), but it works on every network. For live video content where low latency is secondary to reliability, this is acceptable.
Flag users who fail ICE establishment after 15 seconds and redirect them to the HLS player URL for that room. Track the fallback rate per room per day: a spike in fallback rate is usually the first signal that TURN deployment is degraded.
async function establishConnectionWithFallback(
roomId: string,
peerId: string
): Promise<"webrtc" | "hls"> {
const iceTimeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("ice-timeout")), 15_000)
);
try {
await Promise.race([connectWebRTC(roomId, peerId), iceTimeout]);
return "webrtc";
} catch {
reportMetric("webrtc.fallback", { roomId, reason: "ice-timeout" });
activateHlsPlayer(roomId);
return "hls";
}
}
Production Topology Summary
| Component | Technology | Scaling unit |
|---|---|---|
| Signaling server | Node.js + WebSocket + Redis pub/sub | Horizontal, route by roomId |
| STUN | Coturn (STUN mode) | Stateless, scale freely |
| TURN relay | Coturn (relay mode) | Scale by bandwidth, region-local |
| SFU | mediasoup or Livekit | Scale by room count, pin room to worker |
| Room state | Redis with TTL | Key-value, low operational overhead |
| HLS fallback | FFmpeg transcoder + S3 + CDN | Scale by concurrent publisher count |
The backend surface for WebRTC is broader than most teams expect going in. The browser API abstracts the media pipeline; what it does not abstract is the infrastructure required to punch through NATs, route signals across server instances, forward media at scale, and recover from the failure modes that appear only in production. Building each layer with explicit state transitions and observable metrics is what separates a demo from a system.
More in System Design
How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.
How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.
How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.
How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.