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.
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.
Reproduction Steps & Debugging Log Patterns
- Build a mesh room and instrument it: for every
RTCPeerConnection, pollgetStats()at 1 s intervals and logoutbound-rtpframeWidth,framesPerSecond,qualityLimitationReason, plus thecandidate-pairavailableOutgoingBitrate. - 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.
- Watch for the encoder handover between N=4 and N=5:
frameWidthholds at 1280 while CPU climbs, then resolution starts stepping down on the connections whose estimate dropped first. - Trip the cutover, then verify on the SFU side that each client reports exactly one
outbound-rtpvideo stream andN-1inbound-rtpstreams. - 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.
Common Implementation Mistakes
- Assuming the browser encodes once and reuses it. Each peer connection encodes the same camera track independently, so a five-way mesh is four full encodes, not one encode fanned out four ways. This single misconception is responsible for most βmesh should scale to tenβ plans.
- Sizing the room on downlink. Consumer downstream dwarfs upstream, so a mesh looks fine in a bandwidth test and dies in a real call. Budget against measured
availableOutgoingBitrate, and leave 30% headroom for retransmissions and audio. - Tearing down the mesh before the server path is proven. Closing the peer connections and then negotiating with the SFU leaves a 1β3 s hole in every stream, and a failed server handshake leaves the room with no media at all. Always overlap.
- Ignoring the participant who joins last. They negotiate
N-1connections simultaneously and are the first to hit ICE and TURN pressure, so a room that is stable for the original members can still be unusable for the newest one β the relay behaviour behind that is covered in Traversing Symmetric NAT with TURN. - Fixing symptoms with per-connection bitrate caps. Clamping
RTCRtpSenderparameters to squeeze one more participant in is legitimate short-term triage, as covered in Reacting to Bandwidth Drops with RTCRtpSender Parameters, but it lowers everyoneβs quality to avoid a server that would have restored it.
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.