Skip to content

Group calls (SFU) ​

The default call is 1:1 and peer to peer. Group calls go through sfu-server, a small Selective Forwarding Unit written for this demo with Pion, instead of a ready-made media server. Every client still uses only standard WebRTC APIs, with no SFU SDK, so you can read how a group call works from end to end.

Group calls are opt in. The 1:1 call and its signaling server don't change at all.

Screenshot to add
The web client in a group call with four or five people on different platforms: the grid of tiles with their labels (for example "Android · 3f2a1c"), one tile with the green speaking ring, one with the mic-off icon, one showing the avatar because the camera is off.
File: docs/public/media/group-web.png

Why an SFU ​

With mesh, everyone connects to everyone, so every phone encodes and uploads its video once per other person. Through an SFU each client uploads one stream and the server copies its packets to the others, without decoding them. Drag the slider:

Mesh or SFU?

Mesh: everyone connects to everyone
SFU
SFU: everyone connects to the server
MeshSFU
Connections per client42
Video encodes and uploads per client41
Videos each client downloads44
Upload per client at 1.5 Mbps per 720p video6.0 Mbps1.5 Mbps
Streams the server sendsno server20

An MCU, which decodes, mixes and re-encodes on the server, isn't needed for a demo.

The server ​

sfu-server is one Go process that does both jobs the 1:1 mode splits between the Node server and the peers:

  • signaling: a plain WebSocket at ws://<host>:4001/ws, JSON messages;
  • media: every participant's RTP packets are copied to the other participants.
FileRole
main.goFlags, GET / health check, /ws
signaling.goMessage types. One read loop and one queued write loop per socket, so a room lock never waits on the network. Pings every 20 s.
room.goRooms in memory, join and leave, forwarding a new track to everyone else
participant.goThe two peer connections of a participant, RTP forwarding, key frame requests, renegotiation
webrtc.goPion setup shared by every peer connection: codecs, interceptors, one UDP port
sfu_test.goReal Pion clients over loopback

Every peer connection shares one UDP port (4001, the same number as the TCP port), so a firewall only needs TCP 4001 and UDP 4001. The server doesn't trickle ICE: it waits for its own candidates before sending an SDP, so its offers and answers already contain them.

Two peer connections per client ​

Each client opens two peer connections to the server, whatever the room size:

Publish connectionSubscribe connection
Direction (client side)sendonly: one audio, one video transceiverrecvonly: one transceiver per remote track
Who offersAlways the clientAlways the server
RenegotiatedNeverEach time someone's tracks appear or go away

Fixing who offers on each connection means the two sides never offer at the same time, and a peer never switches from answering to offering. Joins and leaves only renegotiate the subscribe connection, so the outgoing camera is never touched. Switching camera, screen and file works exactly as in a 1:1 call (Switching video sources).

Joining a room ​

All messages are listed in Messages.

Forwarding ​

  • Whose track is this? Each forwarded track's stream ID (msid) is the publisher's participantId, and its track ID is <participantId>-audio or <participantId>-video. Clients read streams[0].id in ontrack to know which tile a track belongs to. Tracks can arrive before or after participant joined.
  • Reused m-lines. When someone leaves, the server reuses their transceivers for the next tracks. libwebrtc doesn't always fire a new track event for a reused m-line, so iOS, macOS and Windows also read a=mid and a=msid from every subscribe offer and map receivers by mid. Android keys tracks by receiver ID, and the web moves a reused track away from its previous owner.
  • Codecs. The server only accepts VP8 and Opus. Every client can encode and decode them, and the server never translates between codecs.
  • Header extensions are stripped from forwarded packets: their IDs were negotiated on the publisher's connection and mean nothing on a subscriber's.
  • Key frames. A subscriber can only start decoding at a key frame. The server sends a PLI to the publisher when a subscribe connection connects and after each subscribe answer, and relays PLI and FIR from subscribers, at most once per 500 ms per track.
  • Loss and congestion. Pion's default interceptors handle NACK retransmissions, receiver reports and transport-wide congestion control feedback.

Active speaker ​

Every 250 to 300 ms each client reads audioLevel from inbound-rtp stats for every remote audio receiver. The loudest participant above a small threshold gets a green ring, held for about a second so it doesn't flicker between words. The level has to be read per receiver, because every forwarded audio track looks the same from the outside.

E2EE in a group ​

E2EE is a frame-level transform, so the server only sees ciphertext in the RTP payloads. It doesn't need to know. The 1:1 key exchange (the offerer sends the material to the one other peer) doesn't fit a room, so:

  1. Every client that joins with E2EE on generates 32 random bytes and sends them in join.
  2. The room keeps the creator's material as the room key and returns it in every joined.
  3. Each client sets it as the shared key (key index 0, the same derivation and options), attaches encryptors to its publish senders and decryptors to every subscribe receiver, including ones added by later renegotiations.

E2EE is a property of the room. A client whose switch differs from the room's gets a fatal E2EE setting does not match the room.

What a production SFU adds ​

Simulcast with per-subscriber layer selection, TURN, several servers, authentication and key rotation. If you need those, look at LiveKit, mediasoup or Janus.