SFU vs MCU: The Architecture Decision That Determines Everything
Before we compare servers, you need to understand the fundamental architecture choice because it constrains everything else.
MCU (Multipoint Conferencing Unit): The server receives all participant streams, decodes them, composites them into a single mixed stream, re-encodes, and sends one stream to each participant. Each participant receives and decodes one stream regardless of how many people are in the call.
SFU (Selective Forwarding Unit): The server receives each participant's stream and forwards it to other participants without transcoding. Each participant receives N-1 streams (where N is the number of participants) and decodes them locally.
MCU Architecture:
Participant A ──video──▶ ┌─────────┐ ──mixed──▶ Participant A
Participant B ──video──▶ │ MCU │ ──mixed──▶ Participant B
Participant C ──video──▶ │ Server │ ──mixed──▶ Participant C
Participant D ──video──▶ └─────────┘ ──mixed──▶ Participant D
Server CPU: O(n) decode + composite + encode
Server bandwidth: O(n) in + O(n) out
SFU Architecture:
Participant A ──video──▶ ┌─────────┐ ──A,B,C──▶ Participant D
Participant B ──video──▶ │ SFU │ ──A,B,D──▶ Participant C
Participant C ──video──▶ │ Server │ ──A,C,D──▶ Participant B
Participant D ──video──▶ └─────────┘ ──B,C,D──▶ Participant A
Server CPU: O(1) per stream (just forwarding)
Server bandwidth: O(n) in + O(n²) out (without simulcast)
A common starting point is a small-scale MCU approach (Janus with the videoroom plugin in MCU mode) for a 20-person boardroom product. It works fine at that scale. But when requirements change to 500+ participants, MCU becomes impractical immediately, the CPU cost of decoding 500 video streams, compositing them, and re-encoding is astronomical. The switch to SFU becomes necessary.
The bandwidth problem with SFUs (O(n^2) outbound) is solved by simulcast and SVC. More on that below.
mediasoup: The Surgeon's Scalpel
mediasoup is not a server, it's a C++ library with a Node.js wrapper. You build your own server around it. This gives you complete control and complete responsibility.
Here's what a basic mediasoup setup looks like:
const mediasoup = require('mediasoup');
// Create a worker (one per CPU core)
const worker = await mediasoup.createWorker({
logLevel: 'warn',
rtcMinPort: 40000,
rtcMaxPort: 49999,
});
// Create a router (one per room, defines codecs)
const router = await worker.createRouter({
mediaCodecs: [
{
kind: 'audio',
mimeType: 'audio/opus',
clockRate: 48000,
channels: 2,
},
{
kind: 'video',
mimeType: 'video/VP9',
clockRate: 90000,
parameters: {
'profile-id': 2, // Profile 2 = 10-bit
},
},
{
kind: 'video',
mimeType: 'video/H264',
clockRate: 90000,
parameters: {
'packetization-mode': 1,
'profile-level-id': '42e01f', // Baseline
'level-asymmetry-allowed': 1,
},
},
],
});
// Create a WebRTC transport for a producer (publisher)
const transport = await router.createWebRtcTransport({
listenIps: [{ ip: '0.0.0.0', announcedIp: '203.0.113.1' }],
enableUdp: true,
enableTcp: true, // Fallback for restricted networks
preferUdp: true,
initialAvailableOutgoingBitrate: 1000000,
});
The critical configuration for scale is simulcast. Without it, every subscriber gets the full-resolution stream even if they're rendering a 160x90 thumbnail:
// Producer-side: send 3 simulcast layers
const producer = await transport.produce({
kind: 'video',
rtpParameters: {
codecs: [/* ... */],
encodings: [
{ rid: 'r0', maxBitrate: 100000, scaleResolutionDownBy: 4 },
{ rid: 'r1', maxBitrate: 300000, scaleResolutionDownBy: 2 },
{ rid: 'r2', maxBitrate: 900000, scaleResolutionDownBy: 1 },
],
},
});
// Subscriber-side: request specific layer
const consumer = await transport.consume({
producerId: producer.id,
rtpCapabilities: device.rtpCapabilities,
preferredLayers: { spatialLayer: 1, temporalLayer: 2 },
});
// Dynamically switch layers based on UI layout
async function onTileResize(consumerId, width) {
const consumer = consumers.get(consumerId);
if (width < 200) {
await consumer.setPreferredLayers({ spatialLayer: 0 });
} else if (width < 600) {
await consumer.setPreferredLayers({ spatialLayer: 1 });
} else {
await consumer.setPreferredLayers({ spatialLayer: 2 });
}
}
For scaling beyond a single server, mediasoup uses pipe transports to connect routers across workers or machines:
// Cascade rooms across servers
const { pipeProducer, pipeConsumer } = await router1.pipeToRouter({
producerId: someProducer.id,
router: router2, // On another worker or machine
});
Testing shows mediasoup handling ~350 simultaneous video streams (720p, VP9 simulcast, 3 layers) on a c6i.2xlarge (8 vCPU) before CPU hit 80%. That's impressive for forwarding, remember, the SFU isn't transcoding.
LiveKit: The Platform Play
LiveKit is the choice when you don't want to build infrastructure from scratch. It's a Go-based SFU with built-in:
- Multi-node scaling with Redis-backed room routing
- Recording and egress (RTMP, HLS, S3)
- Client SDKs for every platform
- SIP bridging
- AI agent framework
# livekit-server.yaml
port: 7880
rtc:
port_range_start: 50000
port_range_end: 60000
use_external_ip: true
tcp_fallback_port: 7881
redis:
address: redis-cluster.internal:6379
room:
max_participants: 500
empty_timeout: 300
# Enable AV1 SVC
video:
dynacast: true
codecs:
- mime: video/av1
fmtp: ""
- mime: video/vp9
fmtp: profile-id=0
- mime: video/h264
fmtp: level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f
The killer feature is dynacast. When a subscriber isn't watching a particular stream (e.g., it's off-screen or minimized), LiveKit tells the publisher to stop sending that layer. In a 100-person room where each participant sees 9 tiles on screen, this means ~91% of the high-quality layers are paused. The bandwidth savings are enormous.
// LiveKit client SDK, publish with simulcast
const room = new Room({
dynacast: true,
adaptiveStream: true,
videoCaptureDefaults: {
resolution: VideoPresets.h720.resolution,
},
publishDefaults: {
simulcast: true,
videoSimulcastLayers: [
VideoPresets.h90,
VideoPresets.h216,
VideoPresets.h720,
],
videoCodec: 'av1', // Prefer AV1 with SVC fallback to VP9
backupCodec: { codec: 'vp9', encoding: {} },
},
});
await room.connect(LIVEKIT_URL, token);
// Publish camera
const track = await createLocalVideoTrack();
await room.localParticipant.publishTrack(track);
// Subscribe events
room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
if (track.kind === Track.Kind.Video) {
track.attach(document.getElementById(`video-${participant.identity}`));
}
});
For 10K participants, LiveKit's architecture uses multiple SFU nodes with a Redis-backed router:
┌─────────────┐
│ Redis │
│ (routing) │
└──────┬──────┘
│
┌──────────────┼──────────────┐
│ │ │
┌───────┴───────┐ ┌───┴──────┐ ┌────┴───────┐
│ LiveKit │ │ LiveKit │ │ LiveKit │
│ Node 1 │ │ Node 2 │ │ Node 3 │
│ (rooms 1-50) │ │(rooms │ │ (rooms │
│ │ │ 51-100) │ │ 101-150) │
└───────────────┘ └──────────┘ └────────────┘
When a room spans multiple nodes (e.g., 2000 participants), LiveKit automatically creates relay connections between nodes. Testing with 3 nodes handling a single 3000-participant room (10 active speakers, rest viewers), end-to-end latency was 180-220ms, which is acceptable for live events though not for interactive calls.
Janus: The Swiss Army Knife
Janus is a C-based gateway that uses a plugin architecture. Its videoroom plugin is the SFU, but it also has plugins for SIP, streaming, recording, and screen sharing. This matters if you need to bridge WebRTC with legacy telecom infrastructure.
// Janus client, join a video room
const janus = new Janus({
server: 'wss://janus.example.com:8989',
success: function() {
janus.attach({
plugin: 'janus.plugin.videoroom',
success: function(pluginHandle) {
handle = pluginHandle;
handle.send({
message: {
request: 'join',
room: 1234,
ptype: 'publisher',
display: 'Lucio',
}
});
},
onmessage: function(msg, jsep) {
if (jsep) {
handle.createAnswer({
jsep: jsep,
media: { audioSend: true, videoSend: true },
success: function(jsep) {
handle.send({ message: { request: 'start' }, jsep: jsep });
},
});
}
},
});
},
});
Janus's strength is flexibility. Need to record a WebRTC stream to disk? Use the Record plugin. Need to inject an RTSP stream into a WebRTC room? Use the Streaming plugin. Need SIP-to-WebRTC bridging for a call center? SIP plugin.
But Janus scales poorly compared to mediasoup and LiveKit. Its single-process architecture (with a thread pool) hits a ceiling around 200 video streams per instance on equivalent hardware. It doesn't have native cascading, so horizontal scaling requires an external routing layer you build yourself.
VP9 vs AV1: The Codec Negotiation Battle
This is where things get interesting. VP9 with SVC (Scalable Video Coding) has been the workhorse of WebRTC SFUs because it encodes multiple quality layers in a single stream:
VP9 SVC Structure:
┌─────────────────────────────────┐
│ Spatial Layer 2 (720p) │ ← Only sent to subscribers
│ ┌─────────────────────────────┐ │ who need full quality
│ │ Spatial Layer 1 (360p) │ │
│ │ ┌─────────────────────────┐ │ │
│ │ │ Spatial Layer 0 (180p) │ │ │ ← Base layer, always sent
│ │ └─────────────────────────┘ │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────┘
Each spatial layer has 3 temporal layers
(different frame rates: 7.5, 15, 30 fps)
With SVC, the publisher sends one stream and the SFU selectively drops layers. This is more bandwidth-efficient than simulcast (which sends 3 independent streams) because SVC layers reference lower layers, you don't duplicate the base data.
AV1 takes this further with native SVC support and 30-40% better compression:
// AV1 SVC configuration in LiveKit
const publishOptions = {
videoCodec: 'av1',
videoEncoding: {
maxBitrate: 1_500_000,
maxFramerate: 30,
},
scalabilityMode: 'L3T3', // 3 spatial, 3 temporal layers
};
Benchmarks on encoding CPU cost (single 720p30 stream, c6i.xlarge):
| Codec | Mode | CPU Usage | Bitrate | VMAF Score |
|---|---|---|---|---|
| VP8 | Simulcast 3-layer | 18% | 1.3 Mbps | 82.1 |
| VP9 | SVC L3T3 | 24% | 0.9 Mbps | 84.7 |
| H.264 | Simulcast 3-layer | 15% | 1.5 Mbps | 81.3 |
| AV1 | SVC L3T3 | 72% | 0.6 Mbps | 86.2 |
AV1 delivers the best quality at the lowest bitrate but eats 3x the CPU of VP9. For server-side encoding (recording, compositing), this matters. For client-side encoding, it depends on whether the device has hardware AV1 encoding (most 2024+ devices do).
The recommended approach: VP9 SVC as the primary codec with H.264 simulcast fallback for Safari (which only added VP9 WebRTC support in Safari 17.4). Use AV1 only for viewers, not publishers, unless you can confirm hardware encoder support.
Scaling to 10K: The Architecture We Actually Shipped
Here is an architecture deployed for a live event platform handling up to 10,000 viewers with 8 active speakers:
┌──────────────┐
│ Signaling │
│ Server │
│ (WebSocket) │
└──────┬───────┘
│
┌───────────┴───────────┐
│ │
┌───────┴───────┐ ┌───────┴───────┐
│ Speaker SFU │ │ Viewer SFU │
│ (LiveKit) │◄─────►│ Cascade │
│ 8 publishers │ relay │ (LiveKit x4) │
│ + 50 viewers │ │ 2500 viewers │
└───────────────┘ │ each │
└────────────────┘
Speakers connect to a primary LiveKit node. Their streams are relayed to 4 viewer-facing nodes, each handling ~2500 connections. The viewers are receive-only, they don't publish media, which dramatically reduces per-connection cost.
Key config for the viewer nodes:
# viewer-node.yaml
room:
max_participants: 3000
rtc:
congestion_control:
enabled: true
probe_mode: padding
pli_throttle:
low_quality: 500ms
mid_quality: 1000ms
high_quality: 2000ms # Reduce PLI storms from 2500 viewers
The PLI (Picture Loss Indication) throttling is critical. Without it, packet loss from even a few viewers triggers a cascade of keyframe requests that saturates the speaker's uplink. This became apparent during a 5000-person event where a speaker's video froze for everyone because their 50Mbps connection was overwhelmed by PLI-triggered keyframe requests.
Total infrastructure cost for 10K capacity: 5 c6i.2xlarge instances (speakers + 4 viewer clusters) + 2 TURN servers + 1 signaling server. About $3,200/month on AWS, plus bandwidth at ~$0.09/GB out. A 2-hour event with 10K viewers at 1Mbps average = ~9TB outbound = ~$810 in bandwidth.
That's the real cost. Every "scale WebRTC to millions" article conveniently omits bandwidth costs. At scale, you're better off using HLS/DASH for non-interactive viewers and reserving WebRTC for the interactive participants.
The Honest Comparison
| Feature | mediasoup | LiveKit | Janus |
|---|---|---|---|
| Language | C++/Node.js | Go | C |
| Architecture | Library | Server/Platform | Server (plugins) |
| Max streams/node | ~350 video | ~500 video | ~200 video |
| Cascading | Manual (pipe) | Built-in | Manual |
| Recording | Build it | Built-in | Plugin |
| SIP bridge | No | Yes | Plugin |
| AV1 SVC | Yes | Yes | Partial |
| Client SDKs | Community | Official (all) | Community |
| Learning curve | Steep | Moderate | Moderate |
For new projects: LiveKit for most projects, mediasoup only if you need absolute control over the media pipeline or have unusual requirements that LiveKit's abstractions don't accommodate. Janus for legacy telecom integration. All three are production-grade; the difference is how much infrastructure code you want to write versus use.