<!-- Canonical: https://lucioduran.com/blog/webrtc-media-servers-mediasoup-livekit
Language: en
Content-SHA256: c8486042872d39b8b0c2d4e9a190e33bb26b8ef2484a63871aa12c67aaf079b1
-->

Real-time

# Scaling WebRTC to 10,000 Participants: mediasoup, LiveKit, and Janus Compared

2026-02-0710 min read

Lucio Durán
Engineering Manager & AI Solutions Architect

Also available in: [Español](<https://lucioduran.com/es/blog/webrtc-media-servers-mediasoup-livekit>)

## 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](<https://mediasoup.org/>) 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:

```javascript
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:

```javascript
// 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:

```javascript
// 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](<https://livekit.io/>) 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

```yaml
# 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.

```typescript
// 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.

```javascript
// 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:

```javascript
// 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:

```yaml
# 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](<https://aws.com/>), 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.

## Frequently asked questions

### Which WebRTC media server should I use?

mediasoup if you need maximum control and are comfortable with Node.js/C++, it's a library, not a server. LiveKit if you want a batteries-included platform with built-in recording, egress, and SDKs. Janus if you need plugin extensibility (SIP gateway, streaming, etc.).

### Can WebRTC really handle 10,000 participants?

Not in a single SFU instance. You need cascaded SFUs with selective forwarding. LiveKit's multi-node routing handles this natively. With mediasoup, you build it yourself using pipe transports between workers. 10K viewers with 5-10 active speakers is achievable.

### Should I use VP9 or AV1 for WebRTC?

VP9 with SVC if you need broad browser support today. AV1 if your audience is Chrome/Edge-heavy and you can tolerate slower encoding. AV1 delivers 30-40% better compression than VP9 at the same quality, but encoding CPU cost is 3-5x higher.

## Tips

1. **Tip:** Never use MCU architecture for calls over 50 participants. The CPU cost of transcoding scales linearly with participants, an SFU forwarding packets uses ` O(n) ` bandwidth but ` O(1) ` CPU per stream, while MCU uses ` O(n) ` CPU.
2. **Tip:** Enable simulcast with 3 spatial layers in mediasoup: ` encodings: [{maxBitrate: 100000}, {maxBitrate: 300000}, {maxBitrate: 900000}] `. The SFU selects the appropriate layer per subscriber based on their available bandwidth.
3. **Tip:** For AV1/VP9 SVC in LiveKit, set ` dynacast: true ` on the room, it pauses layers that no subscriber is consuming, cutting bandwidth by 40-60% in large rooms where most tiles are small.
4. **Tip:** Use TURN/TLS on port 443 as fallback, not primary. Run ` coturn ` with ` --no-multicast-peers ` and rate-limit allocations to prevent TURN servers from becoming DDoS amplifiers.

- webrtc
- mediasoup
- livekit
- janus
- sfu
- mcu
- simulcast
- av1
- vp9
- real-time
- video-streaming

[Back to Blog](<https://lucioduran.com/blog>)
