Group calls

A two-peer call is one RTCPeerConnection. One offer, one answer, one media path. Every example you read starts there because the API maps cleanly onto the case: A talks to B.

A group call is not that case scaled up. Adding a third person changes the shape of the problem, not its size. The question stops being "how do I connect two browsers" and becomes "who connects to whom, and who pays for it." That question has three standard answers: full mesh, a star with one hub, and a server-side forwarder (SFU). Each answer fixes a different bottleneck and breaks somewhere else.

This page works through all three. It uses the topology this repo actually ships (a host-and-spokes star built on peer-pool.js) as the worked example, does the bandwidth math at 2, 4, and 8 peers, and is honest about where peer-to-peer stops being viable. The SFU is the correct answer for large group video. It is also deliberately out of scope here, and this page explains why.

Full mesh Star (host) SFU SFU 6 edges, O(N²) 3 edges, O(N) 3 edges, O(N)

Why two-peer code does not scale to N

The two-peer code holds a single connection in a single variable.

// Two peers: one connection, one channel. Simple.
const pc = new RTCPeerConnection({ iceServers: ICE });
const channel = pc.createDataChannel('game');
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// ... exchange SDP, set remote description, done.

That code has no concept of "the other peers." It assumes exactly one remote description, one channel, one lifecycle to track. Every assumption in it is wrong for a group.

Three things break the moment you go past two.

State multiplies. Each remote peer needs its own RTCPeerConnection. WebRTC has no built-in notion of a room or a roster. A connection is a point-to-point tunnel between two endpoints; it does not know other endpoints exist. To talk to three people you hold three connections, each with its own ICE gathering, its own SDP exchange, its own connection state, and its own data channel or media tracks. A single variable becomes a collection you have to index, iterate, and clean up.

The consequence is that there is no single source of truth for "who is in the call." You build that roster yourself, on top of the connections, and you keep it in sync as peers come and go. Every peer maintains its own view of the room, and those views can disagree for a moment during a join or a leave. Reconciling them is application work the API does not do for you.

Signaling multiplies. Every connection needs its own offer/answer round trip. For two peers that is one handshake. For N peers in a mesh it is one handshake per pair, and the handshakes are independent, so each can stall, time out, or fail on its own. Signaling is where group calls get fiddly long before media does. See signaling for the single-pair mechanics this builds on.

Bandwidth multiplies, and asymmetrically. This is the one that actually decides which topology you can use. Sending video to N peers is not the same cost as sending it to one. Depending on the topology, your uplink (the slowest, most contended part of a home connection) carries your stream once, N-1 times, or once-to-a-server. Uplink is the constraint that ends peer-to-peer group video. The rest of this page is mostly about that number.

The three topologies

Pick a topology before you write a line of connection code. It determines how many connections each peer holds, how much each peer uploads, who does the CPU work of encoding, and what happens when someone drops.

Property Full mesh Host star (this repo) SFU
Connections (N peers) N(N-1)/2 total N-1 (all on host) N (one per peer to server)
Connections per peer N-1 host: N-1; guest: 1 1
Your uplink (video) stream x (N-1) host: x(N-1); guest: x1 x1 (everyone)
Encode CPU encode once, send N-1x host encodes + relays load encode once
Single point of failure none the host the server
Who can leave freely anyone any guest; not the host anyone
Needs a media server no no yes
Practical peer limit (video) ~4-5 ~3-4 guests dozens+

Read the table by column, then read the rest of this section for why each number is what it is.

Full mesh: everyone connects to everyone

In a mesh, every peer holds a connection to every other peer. With N peers that is N(N-1)/2 connections in the room, and N-1 connections per peer.

Mesh has one real virtue: no single point of failure. There is no host, no server, no privileged node. If any peer drops, the remaining peers stay connected to each other because they never depended on the one that left. The room degrades gracefully: N-1 people keep talking. For a small, leaderless group this resilience is worth a lot.

The cost is uplink. Mesh has no shared copy of your stream. To show your face to N-1 people, you encode your video and upload it N-1 separate times, once into each connection. Your camera produces, say, 2.5 Mbps of H.264. In a 4-person mesh you upload that 3 times: 7.5 Mbps out. In an 8-person mesh you upload it 7 times: 17.5 Mbps out. Most home connections have far less upload headroom than that. The encoder can often produce one frame and the browser can reuse it across senders, so CPU scales better than bandwidth, but the bytes still leave your machine once per peer.

Mesh dies on uplink, not on connection count. Four to five peers is the usual ceiling for video, and that ceiling is set by the worst uplink in the room.

The other tax mesh charges is signaling. With N(N-1)/2 connections, you run that many independent handshakes to form the room, and the count grows quadratically. Six peers is fifteen handshakes. Each can fail on its own, so the join experience gets flakier as the room grows, well before bandwidth becomes the wall. Mesh also has no natural place to put shared state (there is no privileged node to own the roster, the game seed, or the turn order), so leaderless coordination problems land back on the application.

Host star: one hub, N spokes

This repo uses a star. One peer is the host. Every guest connects only to the host. Guests never connect to each other. The host is the hub; the guests are spokes.

This is exactly what peer-pool.js implements. The host allocates a fixed number of slots and holds one RTCPeerConnection per guest, indexed by slotIdx:

export function peerPool(size, { onSlotState, onSlotMessage } = {}) {
  const pcs = new Array(size).fill(null);       // one RTCPeerConnection per slot
  const channels = new Array(size).fill(null);  // one data channel per slot

  function makeHostSlot(slotIdx, { dataChannel = 'game', channelInit } = {}) {
    const pc = new RTCPeerConnection({ iceServers: ICE });
    pcs[slotIdx] = pc;
    pc.onconnectionstatechange = () => {
      onSlotState?.(slotIdx, pc.connectionState, pc);
    };
    const ch = pc.createDataChannel(dataChannel, channelInit);
    channels[slotIdx] = ch;
    attachChannel(ch, {
      onMessage: (m, c) => onSlotMessage?.(slotIdx, m, c),
    });
    return pc;
  }
  // ... inviteSlot / acceptAnswer / broadcast / send / close
}

The slot index is the whole design. Each guest is a position in two parallel arrays: its connection in pcs[slotIdx], its channel in channels[slotIdx]. The host runs one independent handshake per slot and tracks one connection state per slot. A guest holds exactly one connection: to the host.

The star moves the cost off the guests and onto the host. Compare it to mesh from a guest's seat. A guest uploads its stream once (to the host) regardless of how many other guests are in the room. Guest uplink is flat at x1. That is the entire reason the star exists: it makes joining cheap for the people with weak connections.

The host pays for that. The host holds N-1 connections, receives N-1 incoming streams, and, if it is relaying media between guests in software, must send each guest the other guests' streams. The host's uplink looks like a mesh node's: it uploads on the order of (N-1) streams. The host's CPU does the most work in the room. Whoever has the strongest connection should be the host.

The star also introduces a single point of failure. The host is load-bearing. Any guest can leave and the others are unaffected, because guests only ever depended on the host. But if the host leaves, the room collapses: every guest was connected only to the host, so when it goes, every connection goes with it. There is no fallback path between guests, by design. "Who can leave freely" in the table is the honest summary: any guest, never the host.

This repo's games use the star for game state, not video: the host owns the simulation and broadcasts it to every slot. The same topology carries media if you attach tracks instead of a data channel, but the host's uplink and CPU bill is the same shape either way.

The star buys one more thing the mesh cannot: a natural home for authority. Because every message passes through the host, the host can be the referee. It owns the simulation, picks the random seed, resolves conflicts, and broadcasts the agreed truth. Guests send inputs and render what comes back. That is the authoritative-host pattern this repo's games use, and it is only clean because the topology already funnels everything through one node. A mesh has to elect a coordinator and replicate its decisions; a star already has one by construction.

SFU: a server forwards selectively

A Selective Forwarding Unit is a server that sits in the middle. Every client holds one connection: to the SFU. Each client uploads its stream once, to the server. The server then forwards each client's stream out to the other clients. The clients never connect to each other, and no client relays anyone else's media.

The SFU fixes both problems the peer-to-peer topologies have. Every client's uplink is flat at x1, like a guest in the star, because each client only ever sends to the server. And no single client is load-bearing: when a client drops, the SFU just stops forwarding its stream; everyone else is untouched. The server absorbs the fan-out cost that the mesh spread across peers or the star piled onto the host. This is why every real product for group video (meetings, classrooms, large calls) uses an SFU. It is the correct architecture for the problem.

It is also out of scope here, and not by accident. This repo is STUN-only and peer-to-peer by hard rule. Media must stay on RTCDataChannel and direct peer connections; nothing in the media path may transit a server. An SFU is precisely a server in the media path. Adding one would forward frames through infrastructure, which the project forbids. The signaling layer may use a server to exchange SDP (that is what the ntfy.sh and URL-hash modes do), but SDP is metadata, not media. The line is firm: signaling can touch a server, frames cannot.

So the honest position is: if you need group video for more than a handful of people, you need an SFU, and you build it outside this repo's constraints. Within the constraints, the star is the best available answer, and its ceiling is low.

It is worth being precise about what an SFU does and does not do, because it is easy to confuse with its older sibling, the MCU. An MCU (Multipoint Control Unit) decodes every incoming stream, composites them into a single mixed video, re-encodes that, and sends one stream to each client. That is cheap for clients and very expensive for the server, and the re-encode adds latency and throws away per-receiver flexibility. An SFU does no decoding or mixing. It forwards the original encoded packets, choosing per receiver which simulcast layer to pass through. That keeps server CPU low relative to an MCU and preserves end-to-end quality. The SFU is what modern products use; the MCU survives mostly where clients are too weak to render a grid of streams. Both are servers in the media path, and both are out of scope here for the same reason.

STUN is not TURN is not an SFU.

STUN tells a peer its public address so two peers can find each other. TURN relays packets when a direct path fails: still a server in the media path, also forbidden here. An SFU goes further and actively forwards and routes media. This repo uses public STUN only. No TURN, no SFU, ever. If a direct path can't form, the connection simply fails rather than falling back to a relay. See architecture for where STUN sits.

The bandwidth math

Assume each participant's camera produces a 2.5 Mbps video stream. Ignore audio; it is small and constant. The number that matters is sustained upload per peer, because home uplinks are the scarce resource and they are typically a fraction of the download speed.

Two peers. Trivial in every topology. Each peer uploads x1 = 2.5 Mbps. This is the case the simple two-peer code handles, and the reason it feels like group calls should be easy.

Four peers.

  • Mesh. Each peer uploads to the other 3: x3 = 7.5 Mbps out, each. Total bytes crossing the room: 12 streams = 30 Mbps. Already past many home uplinks.
  • Star. Each guest uploads x1 = 2.5 Mbps, flat. The host uploads to 3 guests, and to relay everyone's video it pushes roughly x3 = 7.5 Mbps out, plus it receives 3 incoming streams. The cost concentrates on one machine.
  • SFU. Every client uploads x1 = 2.5 Mbps. The server eats the fan-out (it forwards 12 stream-flows), but no client's uplink moves.

Eight peers.

  • Mesh. Each peer uploads x7 = 17.5 Mbps out. This is over a typical residential uplink before you count anything else. Mesh is not viable at eight.
  • Star. Guests still upload x1 = 2.5 Mbps each: the guest experience does not degrade with room size. But the host now uploads on the order of x7 = 17.5 Mbps and receives 7 streams while encoding/relaying for all of them. One machine carries the whole room. In practice the host saturates first; 3-4 guests is the realistic limit before the host's uplink or CPU gives out.
  • SFU. Every client still uploads x1 = 2.5 Mbps. The server scales; the clients do not feel the room growing. This is why SFUs reach dozens of participants and the others do not.

The pattern: mesh spreads the fan-out cost across every peer's uplink and so dies first; the star moves it all onto one peer and so dies on that one peer; the SFU moves it to a server built to absorb it. None of this changes the encode cost much (a peer usually encodes once and reuses the frame), but the upload cost is what ends a session, and only the SFU keeps it flat for everyone.

Managing N connections

A group call is a set of connections you create, track, renegotiate, and tear down independently. The two-peer code's single-variable model has to become a keyed collection. The repo's pool gives the working pattern: two parallel arrays indexed by slot.

The pool, conceptually

Hold connections and their channels in structures you can index by a stable key. In the repo that key is slotIdx. The point of a stable key is that everything about one peer (its connection, its channel, its UI tile, its last-seen state) hangs off the same index, so join and leave touch exactly one slot and leave the rest alone.

const pcs = new Array(size).fill(null);      // pcs[i]      -> RTCPeerConnection for slot i
const channels = new Array(size).fill(null); // channels[i] -> data channel for slot i

Broadcast and targeted send both fall out of this. Broadcast filters to the open channels and writes each; a send addresses one slot. Both guard on readyState === 'open' so a half-open or closing connection never throws mid-write:

function broadcast(msg) {
  return wireBroadcast(channels.filter(c => c?.readyState === 'open'), msg);
}

function send(slotIdx, msg) {
  return wireSend(channels[slotIdx], msg);  // wireSend guards readyState internally
}

Adding a peer (join)

Joining is a per-peer handshake against one free slot. Nothing about the existing peers changes: adding a connection does not renegotiate the ones already up. In the pool, the host invites a slot, hands the resulting offer code to the joining guest through whatever signaling channel is in use, and later accepts the guest's answer into the same slot:

// Host side, joining slot `i`:
const offerCode = await pool.inviteSlot(i);     // creates pc[i], gathers ICE, returns SDP offer
// ... deliver offerCode to the guest via signaling, receive answerCode back ...
await pool.acceptAnswer(i, answerCode);          // sets the remote description on pc[i]
// pc[i] connects; channels[i] opens; the guest is live in slot i.

The handshake itself is the same single-pair offer/answer flow as a two-peer call (see signaling). The only new thing is that you run one per slot and route each guest's SDP to the right index.

Removing a peer (leave)

A leave is the inverse: close the one connection, free the one slot, and tell everyone else. Close the RTCPeerConnection, null out both array entries, and broadcast the departure so the other peers can drop that slot from their UI and game state:

function dropSlot(slotIdx) {
  try { pcs[slotIdx]?.close(); } catch {}   // close releases ICE/DTLS for that peer only
  pcs[slotIdx] = null;
  channels[slotIdx] = null;
  broadcast({ t: 'left', slotIdx });         // survivors prune this slot
}

Watch connectionState to detect involuntary leaves (a crash, a closed tab, a dead network) that never send a polite departure. The pool already wires onconnectionstatechange per slot. When a slot transitions to failed, disconnected, or closed, run the same drop path you'd run for a clean leave:

const pool = peerPool(3, {
  onSlotState(slotIdx, state) {
    if (state === 'failed' || state === 'closed') dropSlot(slotIdx);
  },
});

In a star this is the whole leave story for guests: a guest leaving touches one slot on the host and zero connections elsewhere, because no guest was connected to that guest. In a mesh, every remaining peer must independently notice and close its own connection to the one that left: N-1 cleanups instead of one. The star's clean leave is the flip side of its single point of failure: cheap when a spoke goes, fatal when the hub goes.

Renegotiation is per connection

Mid-call changes (adding a screen-share track, switching cameras, dropping video to audio-only) renegotiate the one connection they affect. There is no room-wide renegotiation, because there is no room object; there are only point-to-point connections. Changing what you send to one peer fires negotiationneeded on that peer's connection and nobody else's. In a star, a guest changing its stream renegotiates only its connection to the host; the other guests' connections are untouched. Track-level changes are covered in tracks.

Adapting under load

The fixed-bandwidth math above assumes a clean stream. Real links congest. When the path to a peer can't carry full-quality video, you must shed bits or the stream stalls and rebuffers. WebRTC gives you several controls, and getStats to know when to use them.

Detect congestion with getStats

getStats() is the only honest signal that a peer's link is in trouble. Poll the outbound and remote-inbound reports and watch packet loss, round-trip time, and how the encoder is reacting:

async function checkCongestion(pc) {
  const stats = await pc.getStats();
  let loss = 0, rtt = 0;
  stats.forEach(r => {
    if (r.type === 'remote-inbound-rtp') {        // the receiver's view of your stream
      loss = r.fractionLost;                       // 0..1; sustained > ~0.05 is trouble
      rtt = r.roundTripTime;                       // seconds
    }
  });
  return { loss, rtt };
}

Rising fractionLost, climbing roundTripTime, or an encoder that has dropped its resolution on its own (visible in the outbound-rtp report's frame dimensions and qualityLimitationReason) all say the link is saturated. In a star, the host should poll every slot, because the host's uplink is the first thing to saturate as the room grows.

Shed bits with degradationPreference and maxBitrate

When a link congests, choose what to sacrifice. degradationPreference on a sender tells the encoder whether to drop resolution or frame rate first. Prefer maintain-framerate for screen content where sharp text matters more than motion; prefer maintain-resolution for talking heads where smooth motion reads better than extra pixels.

Cap a sender's bitrate directly with setParameters. This is the lever the host pulls per slot to keep its total uplink under budget:

const sender = pc.getSenders().find(s => s.track?.kind === 'video');
const params = sender.getParameters();
params.degradationPreference = 'maintain-framerate';
params.encodings[0].maxBitrate = 600_000;   // cap this stream at 600 kbps
await sender.setParameters(params);

In a star, lowering each guest's maxBitrate is how you fit more guests under the host's fixed uplink: eight guests at 600 kbps cost the host less than three guests at full quality. It is a direct trade of per-peer quality for peer count.

A useful refinement is to cap the streams nobody is looking at. In a typical call only one or two faces are large on screen at any moment; the rest are thumbnails. There is no reason to encode and send a thumbnail at full quality. Drop the off-screen senders to a low maxBitrate and a low degradationPreference and reserve the budget for the active speaker. The receiver decides which is which and signals the sender over a data channel, since WebRTC will not infer it for you.

Simulcast: encode once, let the receiver pick

Simulcast has a sender encode the same video at several resolutions at once and send all of them. The receiver, or an SFU, picks the layer it can afford. This is the right tool when different peers have very different links: a peer on fibre takes the high layer, a peer on a phone takes the low one, from the same sender. Configuring the encodings, RIDs, and scale factors is its own topic; see tracks for the encoding setup. Note that simulcast pays off most with an SFU choosing layers per receiver. In a pure peer-to-peer star its benefit is narrower, because the host would have to do the layer selection the SFU normally does.

Practical peer-count limits

For peer-to-peer, the honest numbers:

  • Data only (no media). The star scales further. Game state and chat are kilobytes per second, not megabits. This repo's games run 2-3 guests comfortably on the data-channel star, and the limit there is gameplay design, not bandwidth.
  • Audio only. A few peers in a mesh, more in a star. Audio is ~40 kbps; uplink is rarely the wall. The handshake count and connection management become the friction first.
  • Video, mesh. 4-5 peers, set by the worst uplink in the room. Past that, someone's upload saturates and the call degrades for everyone they send to.
  • Video, star. 3-4 guests, set by the host's uplink and CPU. Guests stay cheap; the host is the ceiling. Pick the best-connected peer as host and cap per-guest bitrate.
  • Video, SFU. Dozens and up, and the only way to get there. Out of scope for this repo by rule.

The takeaway is blunt: peer-to-peer group video does not get past a handful of people, and no amount of bitrate-capping changes the order of magnitude. If you need a large group video call, you need an SFU, and you build it outside the STUN-only peer-to-peer constraints this site is about. Within those constraints, the host star is the right tool, peer-pool.js is the working implementation, and 3-4 video guests is the realistic ceiling.

Recap

  • Two-peer code does not scale because state, signaling, and bandwidth all multiply with N, and bandwidth multiplies asymmetrically, on the uplink.
  • Mesh connects every pair: N(N-1)/2 connections, each peer uploads x(N-1), no single point of failure. Dies on uplink at ~4-5 video peers.
  • Star (this repo's peer-pool.js) routes every guest through one host indexed by slotIdx: guests upload x1, the host carries x(N-1) and is a single point of failure. ~3-4 video guests.
  • SFU puts a forwarding server in the middle: every client uploads x1, no client is load-bearing, scales to dozens. It is a server in the media path, so it is forbidden here.
  • Manage N connections as a keyed pool: join is a per-slot handshake, leave closes one slot and broadcasts, renegotiation is per connection.
  • Adapt under load with getStats to detect congestion, degradationPreference and maxBitrate to shed bits, and simulcast (most useful with an SFU) to serve mixed links.

Going further

  • Tracks: adding, replacing, and encoding the media tracks that ride these connections.
  • Streaming topologies: the same mesh/star/SFU trade-offs applied to one-to-many broadcast.
  • Connecting architecture: where STUN sits and why no relay or media server is in the path.
  • Multiplayer: peer-pool.js in its native habitat, carrying game state over the data-channel star.

Troubleshooting

  • Adding the third peer renegotiates the first two. It should not. Each connection is independent; a new connection fires negotiationneeded only on itself. If existing connections renegotiate, you are sharing one RTCPeerConnection across peers instead of holding one per slot.
  • One peer's video is fine for everyone but one viewer. That viewer's downlink is the limit, not your uplink. Check that viewer's remote-inbound-rtp loss. Simulcast or a lower layer for that receiver is the fix.
  • The host's quality collapses as guests join. The host's uplink is saturating. Cap each guest's maxBitrate with setParameters, or accept fewer guests. Confirm the best-connected peer is the host.
  • A guest crashed but its tile is still showing. You only handled clean leaves. Watch onconnectionstatechange per slot and run the drop path on failed/disconnected/closed.
  • Connections fail to form for some peers but not others. Likely a NAT that STUN can't traverse. With no TURN fallback by design, that path simply fails. See ICE and NAT.
  • Everything drops at once. In a star, that is the host leaving: every guest depended on it. There is no peer-to-peer fallback between guests by design. If you need survivability when any single node leaves, you need a mesh (no hub) or an SFU (server-side fan-out), not a star.