{"schemaVersion":1,"id":"https://lucioduran.com/blog/webrtc-media-servers-mediasoup-livekit","url":"https://lucioduran.com/blog/webrtc-media-servers-mediasoup-livekit","title":"Scaling WebRTC to 10,000 Participants: mediasoup, LiveKit, and Janus Compared","description":"A battle-tested comparison of WebRTC media servers. SFU vs MCU architecture, simulcast configuration, VP9/AV1 codec negotiation, and the scaling patterns that actually work when you need 10,000 concurrent participants.","language":"en","datePublished":"2026-02-07","dateModified":null,"contentFormat":"text/markdown","content":"Real-time\n\n# Scaling WebRTC to 10,000 Participants: mediasoup, LiveKit, and Janus Compared\n\n2026-02-0710 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/webrtc-media-servers-mediasoup-livekit>)\n\n## SFU vs MCU: The Architecture Decision That Determines Everything\n\nBefore we compare servers, you need to understand the fundamental architecture choice because it constrains everything else.\n\n**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.\n\n**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.\n\n```\nMCU Architecture:\n Participant A ──video──▶ ┌─────────┐ ──mixed──▶ Participant A\n Participant B ──video──▶ │ MCU │ ──mixed──▶ Participant B\n Participant C ──video──▶ │ Server │ ──mixed──▶ Participant C\n Participant D ──video──▶ └─────────┘ ──mixed──▶ Participant D\n Server CPU: O(n) decode + composite + encode\n Server bandwidth: O(n) in + O(n) out\n\nSFU Architecture:\n Participant A ──video──▶ ┌─────────┐ ──A,B,C──▶ Participant D\n Participant B ──video──▶ │ SFU │ ──A,B,D──▶ Participant C\n Participant C ──video──▶ │ Server │ ──A,C,D──▶ Participant B\n Participant D ──video──▶ └─────────┘ ──B,C,D──▶ Participant A\n Server CPU: O(1) per stream (just forwarding)\n Server bandwidth: O(n) in + O(n²) out (without simulcast)\n```\n\nA 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.\n\nThe bandwidth problem with SFUs (O(n^2) outbound) is solved by simulcast and SVC. More on that below.\n\n## mediasoup: The Surgeon's Scalpel\n\n[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.\n\nHere's what a basic mediasoup setup looks like:\n\n```javascript\nconst mediasoup = require('mediasoup');\n\n// Create a worker (one per CPU core)\nconst worker = await mediasoup.createWorker({\n logLevel: 'warn',\n rtcMinPort: 40000,\n rtcMaxPort: 49999,\n});\n\n// Create a router (one per room, defines codecs)\nconst router = await worker.createRouter({\n mediaCodecs: [\n {\n kind: 'audio',\n mimeType: 'audio/opus',\n clockRate: 48000,\n channels: 2,\n },\n {\n kind: 'video',\n mimeType: 'video/VP9',\n clockRate: 90000,\n parameters: {\n 'profile-id': 2, // Profile 2 = 10-bit\n },\n },\n {\n kind: 'video',\n mimeType: 'video/H264',\n clockRate: 90000,\n parameters: {\n 'packetization-mode': 1,\n 'profile-level-id': '42e01f', // Baseline\n 'level-asymmetry-allowed': 1,\n },\n },\n ],\n});\n\n// Create a WebRTC transport for a producer (publisher)\nconst transport = await router.createWebRtcTransport({\n listenIps: [{ ip: '0.0.0.0', announcedIp: '203.0.113.1' }],\n enableUdp: true,\n enableTcp: true, // Fallback for restricted networks\n preferUdp: true,\n initialAvailableOutgoingBitrate: 1000000,\n});\n```\n\nThe critical configuration for scale is simulcast. Without it, every subscriber gets the full-resolution stream even if they're rendering a 160x90 thumbnail:\n\n```javascript\n// Producer-side: send 3 simulcast layers\nconst producer = await transport.produce({\n kind: 'video',\n rtpParameters: {\n codecs: [/* ... */],\n encodings: [\n { rid: 'r0', maxBitrate: 100000, scaleResolutionDownBy: 4 },\n { rid: 'r1', maxBitrate: 300000, scaleResolutionDownBy: 2 },\n { rid: 'r2', maxBitrate: 900000, scaleResolutionDownBy: 1 },\n ],\n },\n});\n\n// Subscriber-side: request specific layer\nconst consumer = await transport.consume({\n producerId: producer.id,\n rtpCapabilities: device.rtpCapabilities,\n preferredLayers: { spatialLayer: 1, temporalLayer: 2 },\n});\n\n// Dynamically switch layers based on UI layout\nasync function onTileResize(consumerId, width) {\n const consumer = consumers.get(consumerId);\n if (width < 200) {\n await consumer.setPreferredLayers({ spatialLayer: 0 });\n } else if (width < 600) {\n await consumer.setPreferredLayers({ spatialLayer: 1 });\n } else {\n await consumer.setPreferredLayers({ spatialLayer: 2 });\n }\n}\n```\n\nFor scaling beyond a single server, mediasoup uses pipe transports to connect routers across workers or machines:\n\n```javascript\n// Cascade rooms across servers\nconst { pipeProducer, pipeConsumer } = await router1.pipeToRouter({\n producerId: someProducer.id,\n router: router2, // On another worker or machine\n});\n```\n\nTesting 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.\n\n## LiveKit: The Platform Play\n\n[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:\n\n- Multi-node scaling with Redis-backed room routing\n- Recording and egress (RTMP, HLS, S3)\n- Client SDKs for every platform\n- SIP bridging\n- AI agent framework\n\n```yaml\n# livekit-server.yaml\nport: 7880\nrtc:\n port_range_start: 50000\n port_range_end: 60000\n use_external_ip: true\n tcp_fallback_port: 7881\n\nredis:\n address: redis-cluster.internal:6379\n\nroom:\n max_participants: 500\n empty_timeout: 300\n\n# Enable AV1 SVC\nvideo:\n dynacast: true\n codecs:\n - mime: video/av1\n fmtp: \"\"\n - mime: video/vp9\n fmtp: profile-id=0\n - mime: video/h264\n fmtp: level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f\n```\n\nThe 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.\n\n```typescript\n// LiveKit client SDK, publish with simulcast\nconst room = new Room({\n dynacast: true,\n adaptiveStream: true,\n videoCaptureDefaults: {\n resolution: VideoPresets.h720.resolution,\n },\n publishDefaults: {\n simulcast: true,\n videoSimulcastLayers: [\n VideoPresets.h90,\n VideoPresets.h216,\n VideoPresets.h720,\n ],\n videoCodec: 'av1', // Prefer AV1 with SVC fallback to VP9\n backupCodec: { codec: 'vp9', encoding: {} },\n },\n});\n\nawait room.connect(LIVEKIT_URL, token);\n\n// Publish camera\nconst track = await createLocalVideoTrack();\nawait room.localParticipant.publishTrack(track);\n\n// Subscribe events\nroom.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {\n if (track.kind === Track.Kind.Video) {\n track.attach(document.getElementById(`video-${participant.identity}`));\n }\n});\n```\n\nFor 10K participants, LiveKit's architecture uses multiple SFU nodes with a Redis-backed router:\n\n```\n ┌─────────────┐\n │ Redis │\n │ (routing) │\n └──────┬──────┘\n │\n ┌──────────────┼──────────────┐\n │ │ │\n ┌───────┴───────┐ ┌───┴──────┐ ┌────┴───────┐\n │ LiveKit │ │ LiveKit │ │ LiveKit │\n │ Node 1 │ │ Node 2 │ │ Node 3 │\n │ (rooms 1-50) │ │(rooms │ │ (rooms │\n │ │ │ 51-100) │ │ 101-150) │\n └───────────────┘ └──────────┘ └────────────┘\n```\n\nWhen 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.\n\n## Janus: The Swiss Army Knife\n\nJanus 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.\n\n```javascript\n// Janus client, join a video room\nconst janus = new Janus({\n server: 'wss://janus.example.com:8989',\n success: function() {\n janus.attach({\n plugin: 'janus.plugin.videoroom',\n success: function(pluginHandle) {\n handle = pluginHandle;\n handle.send({\n message: {\n request: 'join',\n room: 1234,\n ptype: 'publisher',\n display: 'Lucio',\n }\n });\n },\n onmessage: function(msg, jsep) {\n if (jsep) {\n handle.createAnswer({\n jsep: jsep,\n media: { audioSend: true, videoSend: true },\n success: function(jsep) {\n handle.send({ message: { request: 'start' }, jsep: jsep });\n },\n });\n }\n },\n });\n },\n});\n```\n\nJanus'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.\n\nBut 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.\n\n## VP9 vs AV1: The Codec Negotiation Battle\n\nThis 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:\n\n```\nVP9 SVC Structure:\n ┌─────────────────────────────────┐\n │ Spatial Layer 2 (720p) │ ← Only sent to subscribers\n │ ┌─────────────────────────────┐ │ who need full quality\n │ │ Spatial Layer 1 (360p) │ │\n │ │ ┌─────────────────────────┐ │ │\n │ │ │ Spatial Layer 0 (180p) │ │ │ ← Base layer, always sent\n │ │ └─────────────────────────┘ │ │\n │ └─────────────────────────────┘ │\n └─────────────────────────────────┘\n Each spatial layer has 3 temporal layers\n (different frame rates: 7.5, 15, 30 fps)\n```\n\nWith 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.\n\nAV1 takes this further with native SVC support and 30-40% better compression:\n\n```javascript\n// AV1 SVC configuration in LiveKit\nconst publishOptions = {\n videoCodec: 'av1',\n videoEncoding: {\n maxBitrate: 1_500_000,\n maxFramerate: 30,\n },\n scalabilityMode: 'L3T3', // 3 spatial, 3 temporal layers\n};\n```\n\nBenchmarks on encoding CPU cost (single 720p30 stream, c6i.xlarge):\n\n| Codec | Mode | CPU Usage | Bitrate | VMAF Score |\n| --- | --- | --- | --- | --- |\n| VP8 | Simulcast 3-layer | 18% | 1.3 Mbps | 82.1 |\n| VP9 | SVC L3T3 | 24% | 0.9 Mbps | 84.7 |\n| H.264 | Simulcast 3-layer | 15% | 1.5 Mbps | 81.3 |\n| AV1 | SVC L3T3 | 72% | 0.6 Mbps | 86.2 |\n\nAV1 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).\n\nThe 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.\n\n## Scaling to 10K: The Architecture We Actually Shipped\n\nHere is an architecture deployed for a live event platform handling up to 10,000 viewers with 8 active speakers:\n\n```\n ┌──────────────┐\n │ Signaling │\n │ Server │\n │ (WebSocket) │\n └──────┬───────┘\n │\n ┌───────────┴───────────┐\n │ │\n ┌───────┴───────┐ ┌───────┴───────┐\n │ Speaker SFU │ │ Viewer SFU │\n │ (LiveKit) │◄─────►│ Cascade │\n │ 8 publishers │ relay │ (LiveKit x4) │\n │ + 50 viewers │ │ 2500 viewers │\n └───────────────┘ │ each │\n └────────────────┘\n```\n\nSpeakers 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.\n\nKey config for the viewer nodes:\n\n```yaml\n# viewer-node.yaml\nroom:\n max_participants: 3000\nrtc:\n congestion_control:\n enabled: true\n probe_mode: padding\n pli_throttle:\n low_quality: 500ms\n mid_quality: 1000ms\n high_quality: 2000ms # Reduce PLI storms from 2500 viewers\n```\n\nThe 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.\n\nTotal 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.\n\nThat'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.\n\n## The Honest Comparison\n\n| Feature | mediasoup | LiveKit | Janus |\n| --- | --- | --- | --- |\n| Language | C++/Node.js | Go | C |\n| Architecture | Library | Server/Platform | Server (plugins) |\n| Max streams/node | ~350 video | ~500 video | ~200 video |\n| Cascading | Manual (pipe) | Built-in | Manual |\n| Recording | Build it | Built-in | Plugin |\n| SIP bridge | No | Yes | Plugin |\n| AV1 SVC | Yes | Yes | Partial |\n| Client SDKs | Community | Official (all) | Community |\n| Learning curve | Steep | Moderate | Moderate |\n\nFor 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.\n\n## Frequently asked questions\n\n### Which WebRTC media server should I use?\n\nmediasoup 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.).\n\n### Can WebRTC really handle 10,000 participants?\n\nNot 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.\n\n### Should I use VP9 or AV1 for WebRTC?\n\nVP9 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.\n\n## Tips\n\n1. **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.\n2. **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.\n3. **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.\n4. **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.\n\n- webrtc\n- mediasoup\n- livekit\n- janus\n- sfu\n- mcu\n- simulcast\n- av1\n- vp9\n- real-time\n- video-streaming\n\n[Back to Blog](<https://lucioduran.com/blog>)","headings":[{"level":1,"text":"Scaling WebRTC to 10,000 Participants: mediasoup, LiveKit, and Janus Compared","url":"https://lucioduran.com/blog/webrtc-media-servers-mediasoup-livekit#title"},{"level":2,"text":"SFU vs MCU: The Architecture Decision That Determines Everything","url":"https://lucioduran.com/blog/webrtc-media-servers-mediasoup-livekit#sfu-vs-mcu-the-architecture-decision-that-determines-everything"},{"level":2,"text":"mediasoup: The Surgeon's Scalpel","url":"https://lucioduran.com/blog/webrtc-media-servers-mediasoup-livekit#mediasoup-the-surgeons-scalpel"},{"level":2,"text":"LiveKit: The Platform Play","url":"https://lucioduran.com/blog/webrtc-media-servers-mediasoup-livekit#livekit-the-platform-play"},{"level":2,"text":"Janus: The Swiss Army Knife","url":"https://lucioduran.com/blog/webrtc-media-servers-mediasoup-livekit#janus-the-swiss-army-knife"},{"level":2,"text":"VP9 vs AV1: The Codec Negotiation Battle","url":"https://lucioduran.com/blog/webrtc-media-servers-mediasoup-livekit#vp9-vs-av1-the-codec-negotiation-battle"},{"level":2,"text":"Scaling to 10K: The Architecture We Actually Shipped","url":"https://lucioduran.com/blog/webrtc-media-servers-mediasoup-livekit#scaling-to-10k-the-architecture-we-actually-shipped"},{"level":2,"text":"The Honest Comparison","url":"https://lucioduran.com/blog/webrtc-media-servers-mediasoup-livekit#the-honest-comparison"},{"level":2,"text":"Frequently asked questions","url":"https://lucioduran.com/blog/webrtc-media-servers-mediasoup-livekit#questions"},{"level":3,"text":"Which WebRTC media server should I use?","url":"https://lucioduran.com/blog/webrtc-media-servers-mediasoup-livekit#question-1"},{"level":3,"text":"Can WebRTC really handle 10,000 participants?","url":"https://lucioduran.com/blog/webrtc-media-servers-mediasoup-livekit#question-2"},{"level":3,"text":"Should I use VP9 or AV1 for WebRTC?","url":"https://lucioduran.com/blog/webrtc-media-servers-mediasoup-livekit#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/blog/webrtc-media-servers-mediasoup-livekit#article-tips"}],"sections":[{"heading":"Scaling WebRTC to 10,000 Participants: mediasoup, LiveKit, and Janus Compared","level":1,"url":"https://lucioduran.com/blog/webrtc-media-servers-mediasoup-livekit","content":"Real-time"},{"heading":"Scaling WebRTC to 10,000 Participants: mediasoup, LiveKit, and Janus Compared","level":1,"url":"https://lucioduran.com/blog/webrtc-media-servers-mediasoup-livekit#title","content":"2026-02-0710 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/webrtc-media-servers-mediasoup-livekit>)"},{"heading":"SFU vs MCU: The Architecture Decision That Determines Everything","level":2,"url":"https://lucioduran.com/blog/webrtc-media-servers-mediasoup-livekit#sfu-vs-mcu-the-architecture-decision-that-determines-everything","content":"Before we compare servers, you need to understand the fundamental architecture choice because it constrains everything else.\n\n**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.\n\n**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.\n\n```\nMCU Architecture:\n Participant A ──video──▶ ┌─────────┐ ──mixed──▶ Participant A\n Participant B ──video──▶ │ MCU │ ──mixed──▶ Participant B\n Participant C ──video──▶ │ Server │ ──mixed──▶ Participant C\n Participant D ──video──▶ └─────────┘ ──mixed──▶ Participant D\n Server CPU: O(n) decode + composite + encode\n Server bandwidth: O(n) in + O(n) out\n\nSFU Architecture:\n Participant A ──video──▶ ┌─────────┐ ──A,B,C──▶ Participant D\n Participant B ──video──▶ │ SFU │ ──A,B,D──▶ Participant C\n Participant C ──video──▶ │ Server │ ──A,C,D──▶ Participant B\n Participant D ──video──▶ └─────────┘ ──B,C,D──▶ Participant A\n Server CPU: O(1) per stream (just forwarding)\n Server bandwidth: O(n) in + O(n²) out (without simulcast)\n```\n\nA 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.\n\nThe bandwidth problem with SFUs (O(n^2) outbound) is solved by simulcast and SVC. More on that below."},{"heading":"mediasoup: The Surgeon's Scalpel","level":2,"url":"https://lucioduran.com/blog/webrtc-media-servers-mediasoup-livekit#mediasoup-the-surgeons-scalpel","content":"[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.\n\nHere's what a basic mediasoup setup looks like:\n\n```javascript\nconst mediasoup = require('mediasoup');\n\n// Create a worker (one per CPU core)\nconst worker = await mediasoup.createWorker({\n logLevel: 'warn',\n rtcMinPort: 40000,\n rtcMaxPort: 49999,\n});\n\n// Create a router (one per room, defines codecs)\nconst router = await worker.createRouter({\n mediaCodecs: [\n {\n kind: 'audio',\n mimeType: 'audio/opus',\n clockRate: 48000,\n channels: 2,\n },\n {\n kind: 'video',\n mimeType: 'video/VP9',\n clockRate: 90000,\n parameters: {\n 'profile-id': 2, // Profile 2 = 10-bit\n },\n },\n {\n kind: 'video',\n mimeType: 'video/H264',\n clockRate: 90000,\n parameters: {\n 'packetization-mode': 1,\n 'profile-level-id': '42e01f', // Baseline\n 'level-asymmetry-allowed': 1,\n },\n },\n ],\n});\n\n// Create a WebRTC transport for a producer (publisher)\nconst transport = await router.createWebRtcTransport({\n listenIps: [{ ip: '0.0.0.0', announcedIp: '203.0.113.1' }],\n enableUdp: true,\n enableTcp: true, // Fallback for restricted networks\n preferUdp: true,\n initialAvailableOutgoingBitrate: 1000000,\n});\n```\n\nThe critical configuration for scale is simulcast. Without it, every subscriber gets the full-resolution stream even if they're rendering a 160x90 thumbnail:\n\n```javascript\n// Producer-side: send 3 simulcast layers\nconst producer = await transport.produce({\n kind: 'video',\n rtpParameters: {\n codecs: [/* ... */],\n encodings: [\n { rid: 'r0', maxBitrate: 100000, scaleResolutionDownBy: 4 },\n { rid: 'r1', maxBitrate: 300000, scaleResolutionDownBy: 2 },\n { rid: 'r2', maxBitrate: 900000, scaleResolutionDownBy: 1 },\n ],\n },\n});\n\n// Subscriber-side: request specific layer\nconst consumer = await transport.consume({\n producerId: producer.id,\n rtpCapabilities: device.rtpCapabilities,\n preferredLayers: { spatialLayer: 1, temporalLayer: 2 },\n});\n\n// Dynamically switch layers based on UI layout\nasync function onTileResize(consumerId, width) {\n const consumer = consumers.get(consumerId);\n if (width < 200) {\n await consumer.setPreferredLayers({ spatialLayer: 0 });\n } else if (width < 600) {\n await consumer.setPreferredLayers({ spatialLayer: 1 });\n } else {\n await consumer.setPreferredLayers({ spatialLayer: 2 });\n }\n}\n```\n\nFor scaling beyond a single server, mediasoup uses pipe transports to connect routers across workers or machines:\n\n```javascript\n// Cascade rooms across servers\nconst { pipeProducer, pipeConsumer } = await router1.pipeToRouter({\n producerId: someProducer.id,\n router: router2, // On another worker or machine\n});\n```\n\nTesting 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."},{"heading":"LiveKit: The Platform Play","level":2,"url":"https://lucioduran.com/blog/webrtc-media-servers-mediasoup-livekit#livekit-the-platform-play","content":"[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:\n\n- Multi-node scaling with Redis-backed room routing\n- Recording and egress (RTMP, HLS, S3)\n- Client SDKs for every platform\n- SIP bridging\n- AI agent framework\n\n```yaml\n# livekit-server.yaml\nport: 7880\nrtc:\n port_range_start: 50000\n port_range_end: 60000\n use_external_ip: true\n tcp_fallback_port: 7881\n\nredis:\n address: redis-cluster.internal:6379\n\nroom:\n max_participants: 500\n empty_timeout: 300\n\n# Enable AV1 SVC\nvideo:\n dynacast: true\n codecs:\n - mime: video/av1\n fmtp: \"\"\n - mime: video/vp9\n fmtp: profile-id=0\n - mime: video/h264\n fmtp: level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f\n```\n\nThe 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.\n\n```typescript\n// LiveKit client SDK, publish with simulcast\nconst room = new Room({\n dynacast: true,\n adaptiveStream: true,\n videoCaptureDefaults: {\n resolution: VideoPresets.h720.resolution,\n },\n publishDefaults: {\n simulcast: true,\n videoSimulcastLayers: [\n VideoPresets.h90,\n VideoPresets.h216,\n VideoPresets.h720,\n ],\n videoCodec: 'av1', // Prefer AV1 with SVC fallback to VP9\n backupCodec: { codec: 'vp9', encoding: {} },\n },\n});\n\nawait room.connect(LIVEKIT_URL, token);\n\n// Publish camera\nconst track = await createLocalVideoTrack();\nawait room.localParticipant.publishTrack(track);\n\n// Subscribe events\nroom.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {\n if (track.kind === Track.Kind.Video) {\n track.attach(document.getElementById(`video-${participant.identity}`));\n }\n});\n```\n\nFor 10K participants, LiveKit's architecture uses multiple SFU nodes with a Redis-backed router:\n\n```\n ┌─────────────┐\n │ Redis │\n │ (routing) │\n └──────┬──────┘\n │\n ┌──────────────┼──────────────┐\n │ │ │\n ┌───────┴───────┐ ┌───┴──────┐ ┌────┴───────┐\n │ LiveKit │ │ LiveKit │ │ LiveKit │\n │ Node 1 │ │ Node 2 │ │ Node 3 │\n │ (rooms 1-50) │ │(rooms │ │ (rooms │\n │ │ │ 51-100) │ │ 101-150) │\n └───────────────┘ └──────────┘ └────────────┘\n```\n\nWhen 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."},{"heading":"Janus: The Swiss Army Knife","level":2,"url":"https://lucioduran.com/blog/webrtc-media-servers-mediasoup-livekit#janus-the-swiss-army-knife","content":"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.\n\n```javascript\n// Janus client, join a video room\nconst janus = new Janus({\n server: 'wss://janus.example.com:8989',\n success: function() {\n janus.attach({\n plugin: 'janus.plugin.videoroom',\n success: function(pluginHandle) {\n handle = pluginHandle;\n handle.send({\n message: {\n request: 'join',\n room: 1234,\n ptype: 'publisher',\n display: 'Lucio',\n }\n });\n },\n onmessage: function(msg, jsep) {\n if (jsep) {\n handle.createAnswer({\n jsep: jsep,\n media: { audioSend: true, videoSend: true },\n success: function(jsep) {\n handle.send({ message: { request: 'start' }, jsep: jsep });\n },\n });\n }\n },\n });\n },\n});\n```\n\nJanus'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.\n\nBut 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."},{"heading":"VP9 vs AV1: The Codec Negotiation Battle","level":2,"url":"https://lucioduran.com/blog/webrtc-media-servers-mediasoup-livekit#vp9-vs-av1-the-codec-negotiation-battle","content":"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:\n\n```\nVP9 SVC Structure:\n ┌─────────────────────────────────┐\n │ Spatial Layer 2 (720p) │ ← Only sent to subscribers\n │ ┌─────────────────────────────┐ │ who need full quality\n │ │ Spatial Layer 1 (360p) │ │\n │ │ ┌─────────────────────────┐ │ │\n │ │ │ Spatial Layer 0 (180p) │ │ │ ← Base layer, always sent\n │ │ └─────────────────────────┘ │ │\n │ └─────────────────────────────┘ │\n └─────────────────────────────────┘\n Each spatial layer has 3 temporal layers\n (different frame rates: 7.5, 15, 30 fps)\n```\n\nWith 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.\n\nAV1 takes this further with native SVC support and 30-40% better compression:\n\n```javascript\n// AV1 SVC configuration in LiveKit\nconst publishOptions = {\n videoCodec: 'av1',\n videoEncoding: {\n maxBitrate: 1_500_000,\n maxFramerate: 30,\n },\n scalabilityMode: 'L3T3', // 3 spatial, 3 temporal layers\n};\n```\n\nBenchmarks on encoding CPU cost (single 720p30 stream, c6i.xlarge):\n\n| Codec | Mode | CPU Usage | Bitrate | VMAF Score |\n| --- | --- | --- | --- | --- |\n| VP8 | Simulcast 3-layer | 18% | 1.3 Mbps | 82.1 |\n| VP9 | SVC L3T3 | 24% | 0.9 Mbps | 84.7 |\n| H.264 | Simulcast 3-layer | 15% | 1.5 Mbps | 81.3 |\n| AV1 | SVC L3T3 | 72% | 0.6 Mbps | 86.2 |\n\nAV1 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).\n\nThe 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."},{"heading":"Scaling to 10K: The Architecture We Actually Shipped","level":2,"url":"https://lucioduran.com/blog/webrtc-media-servers-mediasoup-livekit#scaling-to-10k-the-architecture-we-actually-shipped","content":"Here is an architecture deployed for a live event platform handling up to 10,000 viewers with 8 active speakers:\n\n```\n ┌──────────────┐\n │ Signaling │\n │ Server │\n │ (WebSocket) │\n └──────┬───────┘\n │\n ┌───────────┴───────────┐\n │ │\n ┌───────┴───────┐ ┌───────┴───────┐\n │ Speaker SFU │ │ Viewer SFU │\n │ (LiveKit) │◄─────►│ Cascade │\n │ 8 publishers │ relay │ (LiveKit x4) │\n │ + 50 viewers │ │ 2500 viewers │\n └───────────────┘ │ each │\n └────────────────┘\n```\n\nSpeakers 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.\n\nKey config for the viewer nodes:\n\n```yaml\n# viewer-node.yaml\nroom:\n max_participants: 3000\nrtc:\n congestion_control:\n enabled: true\n probe_mode: padding\n pli_throttle:\n low_quality: 500ms\n mid_quality: 1000ms\n high_quality: 2000ms # Reduce PLI storms from 2500 viewers\n```\n\nThe 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.\n\nTotal 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.\n\nThat'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."},{"heading":"The Honest Comparison","level":2,"url":"https://lucioduran.com/blog/webrtc-media-servers-mediasoup-livekit#the-honest-comparison","content":"| Feature | mediasoup | LiveKit | Janus |\n| --- | --- | --- | --- |\n| Language | C++/Node.js | Go | C |\n| Architecture | Library | Server/Platform | Server (plugins) |\n| Max streams/node | ~350 video | ~500 video | ~200 video |\n| Cascading | Manual (pipe) | Built-in | Manual |\n| Recording | Build it | Built-in | Plugin |\n| SIP bridge | No | Yes | Plugin |\n| AV1 SVC | Yes | Yes | Partial |\n| Client SDKs | Community | Official (all) | Community |\n| Learning curve | Steep | Moderate | Moderate |\n\nFor 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."},{"heading":"Frequently asked questions","level":2,"url":"https://lucioduran.com/blog/webrtc-media-servers-mediasoup-livekit#questions","content":""},{"heading":"Which WebRTC media server should I use?","level":3,"url":"https://lucioduran.com/blog/webrtc-media-servers-mediasoup-livekit#question-1","content":"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.)."},{"heading":"Can WebRTC really handle 10,000 participants?","level":3,"url":"https://lucioduran.com/blog/webrtc-media-servers-mediasoup-livekit#question-2","content":"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."},{"heading":"Should I use VP9 or AV1 for WebRTC?","level":3,"url":"https://lucioduran.com/blog/webrtc-media-servers-mediasoup-livekit#question-3","content":"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."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/blog/webrtc-media-servers-mediasoup-livekit#article-tips","content":"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.\n2. **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.\n3. **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.\n4. **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.\n\n- webrtc\n- mediasoup\n- livekit\n- janus\n- sfu\n- mcu\n- simulcast\n- av1\n- vp9\n- real-time\n- video-streaming\n\n[Back to Blog](<https://lucioduran.com/blog>)"}],"links":[{"text":"Español","url":"https://lucioduran.com/es/blog/webrtc-media-servers-mediasoup-livekit"},{"text":"mediasoup","url":"https://mediasoup.org/"},{"text":"LiveKit","url":"https://livekit.io/"},{"text":"AWS","url":"https://aws.com/"},{"text":"Back to Blog","url":"https://lucioduran.com/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","@id":"https://lucioduran.com/blog/webrtc-media-servers-mediasoup-livekit#article","mainEntityOfPage":{"@type":"WebPage","@id":"https://lucioduran.com/blog/webrtc-media-servers-mediasoup-livekit"},"headline":"Scaling WebRTC to 10,000 Participants: mediasoup, LiveKit, and Janus Compared","description":"A battle-tested comparison of WebRTC media servers. SFU vs MCU architecture, simulcast configuration, VP9/AV1 codec negotiation, and the scaling patterns that actually work when you need 10,000 concurrent participants.","datePublished":"2026-02-07","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"image":"https://statics.lucioduran.com/images/lucioduran-social-card.jpg","url":"https://lucioduran.com/blog/webrtc-media-servers-mediasoup-livekit","inLanguage":"en","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"Real-time","keywords":"webrtc, mediasoup, livekit, janus, sfu, mcu, simulcast, av1, vp9, real-time, video-streaming","wordCount":1881,"timeRequired":"PT10M","workTranslation":[{"@type":"BlogPosting","inLanguage":"es","url":"https://lucioduran.com/es/blog/webrtc-media-servers-mediasoup-livekit"}]},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/blog"},{"@type":"ListItem","position":3,"name":"Scaling WebRTC to 10,000 Participants: mediasoup, LiveKit, and Janus Compared","item":"https://lucioduran.com/blog/webrtc-media-servers-mediasoup-livekit"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Which WebRTC media server should I use?","acceptedAnswer":{"@type":"Answer","text":"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.)."}},{"@type":"Question","name":"Can WebRTC really handle 10,000 participants?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"Should I use VP9 or AV1 for WebRTC?","acceptedAnswer":{"@type":"Answer","text":"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."}}]}]}],"contentHash":"c8486042872d39b8b0c2d4e9a190e33bb26b8ef2484a63871aa12c67aaf079b1","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/blog/webrtc-media-servers-mediasoup-livekit","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":78091,"contentBytes":17573}}
