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.