🤖 AI 资讯

· ·
← 返回列表

How WebRTC Scales: Signaling, NAT Traversal, and the Mesh/SFU/MCU Tradeoff

FreeCodeCamp2026-09-15 15:57:39开源,融资,快手,文生视频,扩散模型,强化学习,模型评测,招聘HR,网络安全,榜单评测,开发者生态原文 ↗

Web Real-Time Communication (or WebRTC) is the open standard browsers use to send audio, video, and data straight to each other. There's no plugin or native app, nothing beyond an API that every browser already ships.

WebRTC covers the media path: once two peers have found each other, everything from codec negotiation to encoding to transport is handled. What it never covers is the finding part.

This article discusses the three APIs that make up the spec, why signaling and NAT traversal live outside it, an implementation of a signaling server at scale, and the mesh/SFU/MCU tradeoff that decides how the media itself scales.

Table of Contents

The Building Blocks: Three APIs, One Gap

WebRTC exposes three JavaScript APIs to do this:

  • RTCPeerConnection negotiates codecs between the two peers and handles encoding, decoding, and transmitting the media stream once a connection exists.

  • MediaStream gets it something to send, wrapping access to a webcam or microphone.

  • RTCDataChannel runs alongside the media connection for anything that isn't audio or video, chat messages, file chunks, game state, or any application data that doesn't need a codec.

None of them know how to find a remote peer on their own. That's the part WebRTC leaves out entirely.

Signaling and the Offer/Answer Exchange

Before two peers can exchange media, they have to exchange a description of what they're capable of: codecs, network info, media types, and encoded as SDP (Session Description Protocol).

WebRTC ships no mechanism for actually delivering that description between peers. That's signaling, and the spec deliberately leaves it up to whoever's building on top, typically over WebSockets or HTTP long polling.

Sequence diagram of Peer A and Peer B exchanging an SDP offer and answer through a signaling server

The exchange itself follows a fixed shape, an offer from the peer initiating the call and an answer from the peer receiving it:

// Peer A: create and send the offer
const pc = new RTCPeerConnection({ iceServers });
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
signalingChannel.send({ type: 'offer', sdp: pc.localDescription });

// Peer B: accept the offer, respond with an answer
await pc.setRemoteDescription(offerFromA);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
signalingChannel.send({ type: 'answer', sdp: pc.localDescription });

// Peer A: complete the handshake
await pc.setRemoteDescription(answerFromB);

setLocalDescription and setRemoteDescription are the only two calls doing any real work here. Everything else is just getting the SDP blob from one peer's signaling connection to the other's.

NAT Traversal: ICE, STUN, and TURN

An SDP exchange tells each peer what the other supports. It doesn't tell them how to reach each other, since most devices sit behind NAT or a firewall with no directly routable address.

ICE (Interactive Connectivity Establishment) is the piece that solves that, gathering every address a peer might be reachable at and testing them until one works. Those addresses, called candidates, come from two kinds of servers: STUN (Session Traversal Utilities for NAT) and TURN (Traversal Using Relays around NAT).

Diagram of a peer gathering host, STUN, and TURN ICE candidates

The diagram shows a single peer gathering three candidate types in parallel: a host candidate from its own network interface, a server-reflexive candidate returned by a STUN server (the public IP and port its NAT mapped it to), and a relay candidate allocated on a TURN server. All three are sent to the remote peer as ICE candidates, and whichever pairing connects successfully is the one used for the call.

STUN handles the common case: a peer asks a STUN server what public IP and port the NAT mapped it to, and uses that as a candidate address.

TURN is the fallback for when STUN isn't enough. Symmetric NAT and some firewall configurations block direct connectivity outright, so a TURN server relays traffic between the two peers instead. It works in every network configuration STUN can't, at the cost of routing every packet through a third server instead of directly between peers, adding latency and consuming server bandwidth for the duration of the call.

Candidates can be exchanged two ways. First, regular ICE can wait until every candidate is gathered before sending any of them. This is simple but adds latency up front: the connection can't start negotiating until the slowest candidate finishes gathering.

Second, trickling ICE sends each candidate the moment it's found, so negotiation starts on whichever candidate arrives first instead of waiting on all of them. But this comes at the cost of needing both peers' signaling and ICE implementations to handle candidates arriving incrementally rather than all at once.

For time-sensitive applications, trickling is worth the added implementation complexity. Most modern WebRTC stacks support it by default.

// Sending side: forward each candidate the moment ICE finds it
pc.onicecandidate = (event) => {
  if (event.candidate) {
    signalingChannel.send({ type: 'ice-candidate', candidate: event.candidate });
  }
};

// Receiving side: add each candidate as it arrives, don't wait for the rest
signalingChannel.on('ice-candidate', ({ candidate }) => {
  pc.addIceCandidate(candidate);
});

An Example Implementation

To see where these pieces actually cost something at scale, I built a signaling server MVP:

  • Node.js for the server, since its single-threaded event loop handles many concurrent WebSocket connections without the overhead of a thread per connection, and

  • Express for routing and Socket.IO

Web Real-Time Communication (or WebRTC) is the open standard browsers use to send audio, video, and data straight to each other. There's no plugin or native app, nothing beyond an API that every browser already ships.

WebRTC covers the media path: once two peers have found each other, everything from codec negotiation to encoding to transport is handled. What it never covers is the finding part.

This article discusses the three APIs that make up the spec, why signaling and NAT traversal live outside it, an implementation of a signaling server at scale, and the mesh/SFU/MCU tradeoff that decides how the media itself scales.

Table of Contents

The Building Blocks: Three APIs, One Gap

WebRTC exposes three JavaScript APIs to do this:

  • RTCPeerConnection negotiates codecs between the two peers and handles encoding, decoding, and transmitting the media stream once a connection exists.

  • MediaStream gets it something to send, wrapping access to a webcam or microphone.

  • RTCDataChannel runs alongside the media connection for anything that isn't audio or video, chat messages, file chunks, game state, or any application data that doesn't need a codec.

None of them know how to find a remote peer on their own. That's the part WebRTC leaves out entirely.

Signaling and the Offer/Answer Exchange

Before two peers can exchange media, they have to exchange a description of what they're capable of: codecs, network info, media types, and encoded as SDP (Session Description Protocol).

WebRTC ships no mechanism for actually delivering that description between peers. That's signaling, and the spec deliberately leaves it up to whoever's building on top, typically over WebSockets or HTTP long polling.

The exchange itself follows a fixed shape, an offer from the peer initiating the call and an answer from the peer receiving it:

// Peer A: create and send the offer
const pc = new RTCPeerConnection({ iceServers });
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
signalingChannel.send({ type: 'offer', sdp: pc.localDescription });

// Peer B: accept the offer, respond with an answer
await pc.setRemoteDescription(offerFromA);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
signalingChannel.send({ type: 'answer', sdp: pc.localDescription });

// Peer A: complete the handshake
await pc.setRemoteDescription(answerFromB);

setLocalDescription and setRemoteDescription are the only two calls doing any real work here. Everything else is just getting the SDP blob from one peer's signaling connection to the other's.

NAT Traversal: ICE, STUN, and TURN

An SDP exchange tells each peer what the other supports. It doesn't tell them how to reach each other, since most devices sit behind NAT or a firewall with no directly routable address.

ICE (Interactive Connectivity Establishment) is the piece that solves that, gathering every address a peer might be reachable at and testing them until one works. Those addresses, called candidates, come from two kinds of servers: STUN (Session Traversal Utilities for NAT) and TURN (Traversal Using Relays around NAT).

The diagram shows a single peer gathering three candidate types in parallel: a host candidate from its own network interface, a server-reflexive candidate returned by a STUN server (the public IP and port its NAT mapped it to), and a relay candidate allocated on a TURN server. All three are sent to the remote peer as ICE candidates, and whichever pairing connects successfully is the one used for the call.

STUN handles the common case: a peer asks a STUN server what public IP and port the NAT mapped it to, and uses that as a candidate address.

TURN is the fallback for when STUN isn't enough. Symmetric NAT and some firewall configurations block direct connectivity outright, so a TURN server relays traffic between the two peers instead. It works in every network configuration STUN can't, at the cost of routing every packet through a third server instead of directly between peers, adding latency and consuming server bandwidth for the duration of the call.

Candidates can be exchanged two ways. First, regular ICE can wait until every candidate is gathered before sending any of them. This is simple but adds latency up front: the connection can't start negotiating until the slowest candidate finishes gathering.

Second, trickling ICE sends each candidate the moment it's found, so negotiation starts on whichever candidate arrives first instead of waiting on all of them. But this comes at the cost of needing both peers' signaling and ICE implementations to handle candidates arriving incrementally rather than all at once.

For time-sensitive applications, trickling is worth the added implementation complexity. Most modern WebRTC stacks support it by default.

// Sending side: forward each candidate the moment ICE finds it
pc.onicecandidate = (event) => {
  if (event.candidate) {
    signalingChannel.send({ type: 'ice-candidate', candidate: event.candidate });
  }
};

// Receiving side: add each candidate as it arrives, don't wait for the rest
signalingChannel.on('ice-candidate', ({ candidate }) => {
  pc.addIceCandidate(candidate);
});

An Example Implementation

To see where these pieces actually cost something at scale, I built a signaling server MVP:

  • Node.js for the server, since its single-threaded event loop handles many concurrent WebSocket connections without the overhead of a thread per connection, and

  • Express for routing and Socket.IO