Mesh vs SFU: When to Graduate

A full mesh has no server in the media path: every participant opens an RTCPeerConnection to every other participant and sends them each a private copy of their camera. This guide is part of the SFU vs MCU Topologies guide, and it answers one operational question β€” at what participant count does that arrangement stop working on real hardware and real uplinks, and how do you move a live room onto a forwarding server without dropping the call? The threshold is lower than most teams expect: on typical consumer connections a 720p mesh degrades at five participants and is unusable at seven.

Context & Trade-offs

Three separate budgets are consumed at the same rate, and whichever runs out first sets your ceiling.

Uplink. Each participant transmits N-1 copies of their own video. At 1.5 Mbps for 720p30 that is 3.0 Mbps in a 3-way call, 6.0 Mbps at five participants, and 10.5 Mbps at eight. Residential upstream is commonly 5–10 Mbps, LTE upstream frequently 2–5 Mbps, and neither is dedicated to your tab. The failure is not graceful: once the send rate exceeds capacity the congestion controller starts cutting bitrate on all N-1 connections at once, so one person’s saturated uplink degrades their picture for everybody simultaneously.

Encoders. This is the budget teams forget. The browser does not encode once and fan the result out β€” each RTCPeerConnection owns its own encoder instance, driven by its own bandwidth estimate, so a five-way mesh runs four independent 720p encoders over one camera track. Hardware encoders are the scarce resource: most laptops expose 1–3 concurrent hardware video encode sessions, and beyond that Chrome silently falls back to libvpx or OpenH264 in software, at roughly 0.15–0.3 of a core per 720p30 stream. Four software encodes is a full core of steady load plus a fan, and on a phone it is thermal throttling within minutes. Confirming which path you are actually on is covered in Detecting Hardware vs Software Encoding.

Connection setup. A room of N needs N(N-1)/2 peer connections, each with its own ICE gathering, DTLS handshake, and TURN allocation. At six participants the last joiner negotiates five connections in parallel; if 8–20% of paths need a relay, someone in the room is paying the 20–40 ms one-way relay penalty on several legs at once, and the signalling channel carries five offer/answer exchanges plus 15–50 candidates before that participant sees a single frame.

Mesh edge count and per-participant uplink at N equals 3, 5 and 8 Three full-mesh graphs side by side. Three participants form three connections and need three megabits of uplink each. Five participants form ten connections and need six megabits each. Eight participants form twenty-eight connections and need ten and a half megabits each, exceeding a typical residential uplink. Full mesh: every peer encodes and uploads to every other peer N = 3 β€” comfortable N = 5 β€” the edge N = 8 β€” broken 3 connections 2 encodes, 3.0 Mbps up 10 connections 4 encodes, 6.0 Mbps up 28 connections 7 encodes, 10.5 Mbps up Connections grow as N(N-1)/2; per-participant uplink and encoder count grow as N-1. An SFU replaces every graph above with exactly one connection per participant.
Mesh edges scale quadratically with room size while each participant's own encode and upload load scales linearly β€” both curves bite well before ten people.

Putting the three budgets in one table shows why the practical ceiling lands at four to six rather than at some round number:

N Peer connections Encoders per client Uplink @720p Verdict
2 1 1 1.5 Mbps mesh is strictly correct
3 3 2 3.0 Mbps comfortable everywhere
4 6 3 4.5 Mbps last hardware-encoder-safe size
5 10 4 6.0 Mbps software encode, laptops warm
6 15 5 7.5 Mbps fails on most home uplinks
8 28 7 10.5 Mbps unusable without dropping to 180p

Two things move the ceiling, and both are worth checking before you conclude a mesh is finished. Audio-only rooms barely feel it: Opus at 24–32 kbps means eleven outbound streams still fit in under 400 kbps, and audio encoding is cheap enough that the encoder budget never binds, so a voice mesh comfortably reaches ten to twelve participants. Screen sharing moves the ceiling the other way β€” a 1080p content share runs 2–3 Mbps and is a second track on every one of those N-1 connections, so a four-person call in which one person shares their screen already pushes that sharer to 4.5 Mbps of video plus 7.5 Mbps of content. The person presenting is precisely the person the room can least afford to have degrade.

Downlink is rarely the constraint β€” consumer downstream is typically 5–10Γ— the upstream β€” which is why mesh rooms fail asymmetrically: your own view of everyone else looks fine while your outbound picture collapses. You can buy roughly one extra participant by dropping the shared encode to 360p at 600 kbps, and another by pausing video for non-speakers, but each of those is a quality concession, not a fix; the quadratic connection count is untouched by either.

The other half of the argument is what you gain by moving to a forwarding server, beyond headroom. One upstream connection means one encoder, so simulcast becomes affordable and the server can pick the right layer per subscriber instead of every sender guessing β€” the mechanism described in Simulcast & SVC Implementation. Server-side recording, active-speaker detection, and per-participant subscription control all become possible, and the whole comparison against a mixing topology is quantified in SFU vs MCU Cost & Quality Trade-offs. What you give up is the server bill and one extra network hop of latency.

Minimal Runnable Implementation

The useful implementation is not a hard-coded if (n > 4). It is a gate that measures the three budgets on the actual client and re-evaluates on every join, so a room of six on fibre workstations stays in mesh while a room of four on a phone graduates immediately.

// Decide mesh vs SFU from measured uplink headroom, encoder budget, and room size.
// Called on every participant join/leave, before creating the next peer connection.
const STREAM_KBPS = 1500;        // 720p30 target per outbound copy
const UPLINK_SAFETY = 0.7;       // never plan to use more than 70% of measured capacity

async function shouldGraduate(peerConnections, roomSize) {
  // 1. Measured uplink: take the highest availableOutgoingBitrate across the mesh.
  //    Each connection estimates independently; the best one is closest to true capacity.
  let estimatedUplinkKbps = 0;
  for (const pc of peerConnections) {
    const stats = await pc.getStats();
    for (const r of stats.values()) {
      if (r.type === 'candidate-pair' && r.state === 'succeeded' && r.availableOutgoingBitrate) {
        estimatedUplinkKbps = Math.max(estimatedUplinkKbps, r.availableOutgoingBitrate / 1000);
      }
    }
  }

  // 2. Encoder budget: hardwareConcurrency is a proxy, not a decoder/encoder count.
  //    Treat 4 cores or fewer as "two software encodes maximum".
  const cores = navigator.hardwareConcurrency || 4;
  const encoderBudget = cores <= 4 ? 2 : cores <= 8 ? 4 : 6;

  const outboundStreams = roomSize - 1;                       // mesh fan-out for this client
  const requiredKbps = outboundStreams * STREAM_KBPS;
  const uplinkShort = requiredKbps > estimatedUplinkKbps * UPLINK_SAFETY;
  const encodersShort = outboundStreams > encoderBudget;

  // 3. Quality signal: if frames are already being dropped for CPU, graduate now.
  const cpuLimited = await anySenderCpuLimited(peerConnections);

  return {
    graduate: uplinkShort || encodersShort || cpuLimited,
    reason: cpuLimited ? 'cpu-limited' : encodersShort ? 'encoder-budget' : 'uplink',
    estimatedUplinkKbps: Math.round(estimatedUplinkKbps),
    requiredKbps
  };
}

// qualityLimitationReason is the browser telling you which budget it already blew.
async function anySenderCpuLimited(peerConnections) {
  for (const pc of peerConnections) {
    const stats = await pc.getStats();
    for (const r of stats.values()) {
      if (r.type === 'outbound-rtp' && r.kind === 'video' && r.qualityLimitationReason === 'cpu') {
        return true;                                          // encoder cannot keep up
      }
    }
  }
  return false;
}

Run this on a 1 s poll rather than only at join time; a participant switching from Wi-Fi to cellular collapses the uplink term without changing the room size. The qualityLimitationReason field is the highest-value signal in the whole function because it is the browser reporting a budget that has already been exceeded, and its 'bandwidth' counterpart is one of the congestion indicators explained in Interpreting getStats() for Congestion Signals.

When the gate trips, the cutover has an order that matters. Publish to the server first, confirm media is flowing, and only then tear down the mesh β€” the reverse order gives every participant a visible gap.

Graduation decision tree from measured client budgets A tree starting at a join event. Mobile or low-core devices route straight to the SFU. Otherwise required uplink is compared with seventy percent of measured capacity, then encoder count against the budget, then quality limitation reason. Any failing branch selects the SFU; passing all three keeps the room in mesh. participant joins roomSize = N cores <= 4 ? phone / netbook yes β€” 2 encodes max no (N-1) x 1.5 Mbps under 70% uplink ? no β€” uplink short yes N-1 encoders within budget ? no β€” software fallback yes qualityLimitation Reason == cpu ? yes β€” already failing no β€” all budgets clear graduate to SFU 1 encode, 1 uplink, simulcast available stay in mesh no server hop, no bill
Any one failing budget graduates the room; only a client clearing all four checks keeps its direct peer connections.

Reproduction Steps & Debugging Log Patterns

  1. Build a mesh room and instrument it: for every RTCPeerConnection, poll getStats() at 1 s intervals and log outbound-rtp frameWidth, framesPerSecond, qualityLimitationReason, plus the candidate-pair availableOutgoingBitrate.
  2. Join participants one at a time, waiting 30 s between joins so the bandwidth estimators settle, and record the per-N values. Shape the uplink to 6 Mbps first so the ceiling is reproducible rather than dependent on your office link.
  3. Watch for the encoder handover between N=4 and N=5: frameWidth holds at 1280 while CPU climbs, then resolution starts stepping down on the connections whose estimate dropped first.
  4. Trip the cutover, then verify on the SFU side that each client reports exactly one outbound-rtp video stream and N-1 inbound-rtp streams.
  5. Re-run with a phone in the room to confirm it graduates at N=3 rather than N=5, driven by the core-count branch rather than the uplink branch.

Two measurement details make the difference between a reproducible ceiling and a folklore number. First, hold the camera constraints fixed across the whole run; if the capture resolution is left to negotiate itself, you cannot tell an encoder stepping down from a capturer that never delivered 720p in the first place. Second, read framesEncoded per connection rather than trusting the requested frame rate β€” the software encoder falls behind silently, and the gap between framesSent and the wall-clock expectation is the earliest hard evidence that the machine has run out of encode capacity.

A mesh crossing its ceiling produces a very recognisable log β€” note that the degradation appears on the sending side while every inbound stream stays healthy:

// N=4  up=4500kbps est=8100kbps  1280x720@30  qualityLimitationReason=none
// N=5  up=6000kbps est=8000kbps  1280x720@27  qualityLimitationReason=cpu      // 4th encoder is software
// N=5  up=5400kbps est=6100kbps  960x540@30   qualityLimitationReason=cpu      // encoder scaled down
// N=6  up=4900kbps est=4400kbps  640x360@24   qualityLimitationReason=bandwidth // uplink saturated
// N=6  inbound-rtp x5            1280x720@30  framesDropped=0                   // downlink still fine
// graduate: reason=uplink required=7500kbps estimated=4400kbps

The cutover itself is the part worth rehearsing, because it happens mid-call with people talking. Each client opens its server connection and publishes while the mesh is still carrying the room, waits for the first inbound frame from the server, and only then stops its mesh senders and closes those peer connections.

Live cutover sequence from mesh to a forwarding server Four lanes: client, mesh peers, signalling and SFU. The signalling server broadcasts a switch instruction, each client publishes to the SFU and subscribes, waits for the first inbound frame, then stops mesh senders and closes the mesh peer connections. Media overlap is roughly three hundred to eight hundred milliseconds and no frames are lost. Cutover with overlap: server media proven before mesh media stops client mesh peers signalling SFU mesh media still flowing switch-topology publish 1 stream first inbound frame overlap window stop mesh senders pc.close() x (N-1) SFU carries the room Overlap costs one extra outbound stream for 300–800 ms; ending it early costs a visible freeze.
Publish, verify, then tear down: the brief double-send is the price of a cutover nobody in the call notices.

Common Implementation Mistakes

FAQ

Is there a hard participant limit for a mesh?

No specification-level limit exists β€” the browser will happily open twenty peer connections. The limit is physical: N-1 concurrent encodes and (N-1) Γ— 1.5 Mbps of upload. On typical consumer hardware and connections that puts the comfortable ceiling at four and the hard ceiling at six, and audio-only rooms stretch much further because Opus at 24–32 kbps makes the uplink term negligible up to roughly 12 participants.

Can I stay in a mesh longer by lowering resolution?

Somewhat, and it is worth doing as a stopgap. Dropping to 360p at 600 kbps roughly halves the uplink term and buys one or two participants, but it does not reduce the encoder count or the N(N-1)/2 connection count, and the resulting picture is worse than what a forwarding server would deliver at the same total bitrate. Treat it as a way to survive a spike, not as an architecture.

Should new rooms start on the SFU even when there are only two people?

For most products, yes. Routing every room through the server from the start removes the topology switch entirely, and the extra server hop costs only the SFU’s 1–3 ms of forwarding plus network transit. Keep the mesh path only if peer-to-peer is a product requirement β€” end-to-end privacy, or working without server capacity β€” and accept that you then have to implement and test the cutover.

Related: return to SFU vs MCU Topologies for the server-side comparison, weigh the running cost in SFU vs MCU Cost & Quality Trade-offs, and build the destination described in Selective Forwarding Unit Design.