mediasoup

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

This page was written against mediasoup 3.31.0 (the mediasoup npm package, ISC licence, Node.js 22 or later), mediasoup-client 3.25.3, ws 8.22.0 and the Rust crate mediasoup 0.32.0, and everything was checked on 2026-10-24. The server and client below were run for real: the server natively on macOS and in a Debian-based Docker container, and the client in two headless Chrome 155 instances with fake camera and microphone, which exchanged media through the SFU.

mediasoup is the SFU of the Huddle videoconference application. In the mesh mode described in Peer Connections and SDP and Signaling and Perfect Negotiation every participant uploads one copy of its media to every other participant, which stops working at about four people. With an SFU (Selective Forwarding Unit, see Topologies: Mesh, SFU and MCU) each participant uploads once, and the server forwards the packets to everybody who subscribed, without decoding or mixing them. The chat application Chatterbox does not need an SFU, and the Pixel Arena game only needs the SFU’s cousins for unreliable data (see Data Channels).

What mediasoup is, and what it is not

mediasoup is a library, not a server. You embed it in a Node.js (or Rust) program, and that program decides everything else: who may join, how rooms are named, how browsers and server exchange parameters, how many workers to start, where recordings go. The project states this explicitly: mediasoup has no signalling protocol of its own. This is the main difference from LiveKit, which ships a complete server with signalling, tokens and SDKs, and from the gateways in Janus and Jitsi. In exchange you get a small, fast media core and total freedom over the application protocol.

Internally mediasoup has two layers:

  • a JavaScript layer with the API you call (createWorker, router.createWebRtcTransport, transport.produce and so on);

  • a C++ layer, the mediasoup-worker executable, which does all the media work (ICE, DTLS, SRTP, RTP and RTCP handling, bandwidth estimation, simulcast and SVC layer selection) on top of libuv. The JavaScript process starts it as a child process and talks to it through a pair of pipes with a binary (FlatBuffers) message format.

Because the worker is a native binary, installation matters; see Installation and the native worker. The browser half is mediasoup-client, a JavaScript library that wraps RTCPeerConnection and gives the application a much smaller model: a Device, send and receive transports, producers and consumers. The client never produces or consumes SDP that you have to look at. The server never sees an SDP either: they exchange RTP parameters and capabilities as JSON.

The object model

Object Role

Worker

One mediasoup-worker subprocess, pinned to one CPU core. It owns routers, transports and the sockets of its WebRtcServer. If it crashes, everything in it is lost, so listen to the died event.

Router

A set of codecs (mediaCodecs) and the place where media is routed between the transports of one room. A router lives in one worker and shares its core with the other routers of that worker.

WebRtcServer

Optional but recommended: one UDP and TCP port (or a few) on which a worker accepts the ICE and DTLS traffic of all its WebRTC transports, demultiplexed by ICE username fragment.

WebRtcTransport

One ICE plus DTLS-SRTP connection to one browser transport. A browser normally has one send transport and one receive transport.

PlainTransport, PipeTransport, DirectTransport

Plain RTP in and out (FFmpeg, GStreamer, recording and SIP bridges), routing between routers, and injecting or reading RTP from Node.js code. The first is the entry point for the recording techniques of Recording and Media Processing.

Producer

One media track received from a browser (audio or video), including all simulcast or SVC layers of that track.

Consumer

One media track sent to a browser. A consumer always belongs to exactly one producer and carries a single, continuous stream, even when the producer is simulcast: the worker switches layers inside it.

DataProducer, DataConsumer

The same pair for SCTP data channels (enableSctp: true on the transport).

ActiveSpeakerObserver, AudioLevelObserver

RTP observers that watch audio producers and report the dominant speaker or the loudest ones, from the audio level header extension of RFC 6464.

Every object has an id, an appData object for your own bookkeeping, a close() method and a closed property. Closing an object closes its children: closing a transport closes its producers and consumers, and closing a producer emits producerclose on all its consumers. The server side of the Huddle code relies on this: when a WebSocket closes, closing the peer’s transports is enough.

The signalling flow

mediasoup does not prescribe how the browser and the server talk, but the documentation recommends one order of operations, which every correct integration follows. The figure shows it for two Huddle participants, Alice and Bob, with a WebSocket as signalling channel (see The WebSocket Protocol) and the media path in the last steps.

sequenceDiagram autonumber participant A as Alice (mediasoup-client) participant S as Huddle signalling (Node.js) participant R as mediasoup Router participant B as Bob (mediasoup-client) A->>S: join {room} S->>R: getRoom / createRouter S-->>A: routerRtpCapabilities, existing producers A->>A: Device.load(routerRtpCapabilities) A->>S: createTransport (send) S->>R: createWebRtcTransport({ webRtcServer }) S-->>A: id, iceParameters, iceCandidates, dtlsParameters A->>A: device.createSendTransport(params) A->>S: connectTransport {dtlsParameters} (on connect event) S->>R: transport.connect A->>S: produce {kind, rtpParameters} (on produce event) S->>R: transport.produce S-->>A: producer id S-->>B: newProducer {producerId, peerId, kind} B->>S: consume {producerId, recvRtpCapabilities} S->>R: router.canConsume then transport.consume paused S-->>B: consumer id, kind, rtpParameters B->>B: recvTransport.consume(...) B->>S: resumeConsumer S->>R: consumer.resume A-)R: SRTP media over the WebRtcServer port R-)B: SRTP media (selected layer)

Four details of this flow cause most of the beginner mistakes.

  • The connect event of a mediasoup-client transport fires once, the first time the transport is used to produce or consume. Its callback must be called after the server ran transport.connect({ dtlsParameters }), and the errback if that failed. The produce event fires for every transport.produce(), and its callback receives the id of the server-side producer.

  • The client must send its receive capabilities (device.recvRtpCapabilities) when asking to consume, and the server must call router.canConsume() first: a client without a codec of the producer cannot be served.

  • Consumers are created paused and resumed once the client has built its own consumer. Resuming at once loses the first packets, and with video it delays the first keyframe request.

  • pause, resume and close are not mirrored automatically between the two sides. The documentation says so explicitly: if the server closes a producer, the client learns about it only if you signal it. In the Huddle code the server forwards producerclose as a consumerClosed notification.

Installation and the native worker

mkdir huddle-sfu && cd huddle-sfu
npm install mediasoup@3.31.0 mediasoup-client@3.25.3 ws@8.22.0

The postinstall script of mediasoup downloads a prebuilt mediasoup-worker for the platform and architecture from the GitHub release, checks that it runs on the current host, and otherwise compiles it locally. Compiling needs Python 3.10 or later with pip, a C++17 compiler (build-essential on Debian, Xcode command line tools on macOS) and several minutes. These variables are the ones to know:

  • MEDIASOUP_WORKER_BIN points to your own worker binary and skips both download and compilation;

  • MEDIASOUP_SKIP_WORKER_PREBUILT_DOWNLOAD forces a local build;

  • PYTHON names the interpreter when python3 is not Python 3.10 or newer;

  • on Alpine (musl) the linux-headers package is needed to build.

Two things that were observed while running this page’s example:

  • Recent npm releases do not run dependency install scripts that are not allow-listed. The first npm install of the example finished in three seconds and left no worker binary; npm rebuild mediasoup ran the postinstall script and downloaded the macOS arm64 worker. In CI, approve the script (npm install-scripts approve mediasoup) or run the rebuild step, otherwise the first createWorker() fails with a missing executable.

  • In a node:24-slim image the prebuilt Linux worker was fetched but failed its check and the installer fell back to a local build, which then failed for lack of pip and a compiler. The Dockerfile below uses the full node:24-bookworm image and installs python3-pip and build-essential, and the build took about two minutes. A common production pattern is a multi-stage build that compiles once and copies node_modules/mediasoup/worker/out/Release/mediasoup-worker into a slim runtime image.

The Huddle SFU server

The server below is the complete SFU mode of Huddle: an HTTP server that serves the browser bundle, a WebSocket endpoint at /ws for signalling, and one router per room. Messages are small JSON objects: a request is { "id": 1, "method": "produce", "data": { ... } } and is answered by { "id": 1, "data": { ... } } or { "id": 1, "error": "..." }, and the server pushes notifications as { "notify": "newProducer", "data": { ... } }. This envelope is deliberately not the Chatterbox chat protocol: chat messages are application data, whereas these messages are the control plane of one media server. See Message Design and Serialization for the chat envelope.

{
  "name": "huddle-sfu",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "dependencies": {
    "mediasoup": "3.31.0",
    "mediasoup-client": "3.25.3",
    "ws": "8.22.0"
  }
}
// Huddle SFU server: WebSocket signalling + mediasoup (one worker, one router per room).
import http from 'node:http';
import { readFile } from 'node:fs/promises';
import { WebSocketServer } from 'ws';
import * as mediasoup from 'mediasoup';

const HTTP_PORT = Number(process.env.HTTP_PORT ?? 41200);
const RTC_PORT = Number(process.env.RTC_PORT ?? 41201);
const ANNOUNCED = process.env.ANNOUNCED_ADDRESS ?? '127.0.0.1';

const mediaCodecs = [
  { kind: 'audio', mimeType: 'audio/opus', clockRate: 48000, channels: 2 },
  { kind: 'video', mimeType: 'video/VP8', clockRate: 90000 },
  { kind: 'video', mimeType: 'video/H264', clockRate: 90000,
    parameters: { 'packetization-mode': 1, 'profile-level-id': '42e01f', 'level-asymmetry-allowed': 1 } },
];

const worker = await mediasoup.createWorker({ logLevel: 'warn' });
worker.on('died', (err) => { console.error('worker died', err); process.exit(1); });

const webRtcServer = await worker.createWebRtcServer({
  listenInfos: [
    { protocol: 'udp', ip: '0.0.0.0', announcedAddress: ANNOUNCED, port: RTC_PORT },
    { protocol: 'tcp', ip: '0.0.0.0', announcedAddress: ANNOUNCED, port: RTC_PORT },
  ],
});

const rooms = new Map(); // name -> { router, peers: Map, speakerObserver }

async function getRoom(name) {
  let room = rooms.get(name);
  if (!room) {
    const router = await worker.createRouter({ mediaCodecs });
    const speakerObserver = await router.createActiveSpeakerObserver({ interval: 300 });
    room = { router, peers: new Map(), speakerObserver };
    speakerObserver.on('dominantspeaker', ({ producer }) => {
      broadcast(room, null, 'activeSpeaker', { peerId: producer.appData.peerId });
    });
    rooms.set(name, room);
  }
  return room;
}

function broadcast(room, exceptId, notify, data) {
  for (const p of room.peers.values()) {
    if (p.id !== exceptId) p.ws.send(JSON.stringify({ notify, data }));
  }
}

const server = http.createServer(async (req, res) => {
  const file = req.url === '/' ? '/index.html' : req.url.split('?')[0];
  try {
    const body = await readFile(new URL(`./public${file}`, import.meta.url));
    res.writeHead(200, { 'content-type': file.endsWith('.js') ? 'text/javascript' : 'text/html' });
    res.end(body);
  } catch { res.writeHead(404).end(); }
});
const wss = new WebSocketServer({ server, path: '/ws' });

let nextPeer = 1;
wss.on('connection', (ws) => {
  const peer = { id: `p${nextPeer++}`, ws, transports: new Map(), producers: new Map(), consumers: new Map(), room: null };

  const handlers = {
    async join({ room: roomName, name }) {
      const room = await getRoom(roomName);
      peer.room = room; peer.name = name;
      room.peers.set(peer.id, peer);
      const existing = [];
      for (const other of room.peers.values()) {
        for (const pr of other.producers.values()) existing.push({ producerId: pr.id, peerId: other.id, kind: pr.kind });
      }
      return { peerId: peer.id, routerRtpCapabilities: room.router.rtpCapabilities, existing };
    },
    async createTransport() {
      const transport = await peer.room.router.createWebRtcTransport({
        webRtcServer, enableUdp: true, enableTcp: true, preferUdp: true,
      });
      peer.transports.set(transport.id, transport);
      return {
        id: transport.id,
        iceParameters: transport.iceParameters,
        iceCandidates: transport.iceCandidates,
        dtlsParameters: transport.dtlsParameters,
      };
    },
    async connectTransport({ transportId, dtlsParameters }) {
      await peer.transports.get(transportId).connect({ dtlsParameters });
    },
    async produce({ transportId, kind, rtpParameters }) {
      const producer = await peer.transports.get(transportId)
        .produce({ kind, rtpParameters, appData: { peerId: peer.id } });
      peer.producers.set(producer.id, producer);
      if (kind === 'audio') await peer.room.speakerObserver.addProducer({ producerId: producer.id });
      broadcast(peer.room, peer.id, 'newProducer', { producerId: producer.id, peerId: peer.id, kind });
      return { id: producer.id };
    },
    async consume({ transportId, producerId, rtpCapabilities }) {
      if (!peer.room.router.canConsume({ producerId, rtpCapabilities })) throw new Error('cannot consume');
      const consumer = await peer.transports.get(transportId)
        .consume({ producerId, rtpCapabilities, paused: true }); // resume after the client is ready
      peer.consumers.set(consumer.id, consumer);
      consumer.on('producerclose', () => {
        peer.consumers.delete(consumer.id);
        peer.ws.send(JSON.stringify({ notify: 'consumerClosed', data: { consumerId: consumer.id } }));
      });
      return { id: consumer.id, producerId, kind: consumer.kind, rtpParameters: consumer.rtpParameters, type: consumer.type };
    },
    async resumeConsumer({ consumerId }) { await peer.consumers.get(consumerId).resume(); },
    async setPreferredLayers({ consumerId, spatialLayer, temporalLayer }) {
      await peer.consumers.get(consumerId).setPreferredLayers({ spatialLayer, temporalLayer });
    },
    async getStats({ consumerId }) {
      const stats = await peer.consumers.get(consumerId).getStats();
      return stats.map((s) => ({ type: s.type, byteCount: s.byteCount, bitrate: s.bitrate, score: s.score }));
    },
  };

  ws.on('message', async (raw) => {
    const msg = JSON.parse(raw.toString());
    try {
      const data = await handlers[msg.method](msg.data ?? {});
      ws.send(JSON.stringify({ id: msg.id, data }));
    } catch (e) {
      ws.send(JSON.stringify({ id: msg.id, error: e.message }));
    }
  });
  ws.on('close', () => {
    for (const t of peer.transports.values()) t.close(); // closes producers and consumers too
    if (peer.room) {
      peer.room.peers.delete(peer.id);
      broadcast(peer.room, peer.id, 'peerLeft', { peerId: peer.id });
    }
  });
});

server.listen(HTTP_PORT, () => console.log(`huddle-sfu http://localhost:${HTTP_PORT} rtc ${RTC_PORT}`));
process.on('SIGTERM', () => { worker.close(); process.exit(0); });

Points worth studying in this file:

  • One router per room, one worker for the demo. The router’s mediaCodecs list is the set of codecs the room supports. mediasoup also adds RTX and RTCP feedback entries to the router capabilities. The two video codecs, VP8 and H.264, are the ones that support simulcast in mediasoup (VP9 is the codec for SVC).

  • WebRtcServer. A single UDP port and a single TCP port (both 41201 here) serve every transport of the worker. Without it, each WebRtcTransport opens its own port from rtcMinPort to rtcMaxPort, and a production deployment must open thousands of ports in the firewall. With it, the firewall rule is one UDP port and one TCP port per worker. Transports opt in with the webRtcServer option, and enableUdp, enableTcp and preferUdp then select the candidate protocols.

  • announcedAddress. The worker binds 0.0.0.0, but the ICE candidates that it hands to the browser must contain an address the browser can reach. Behind NAT or in Docker this is the public (or host) address, not the container’s. It is required whenever ip is 0.0.0.0 or ::. On a developer machine 127.0.0.1 worked for Chrome, both when the server ran natively and when it ran in Docker with the ports published.

  • Paused consumers. consume() is called with paused: true; resumeConsumer is the second half of the handshake.

  • ActiveSpeakerObserver. One observer per room watches all audio producers and emits dominantspeaker with the producer that dominates the conversation. It is the server-side answer to the active speaker detection that mesh mode does with getStats() audio levels, and it keeps working when the client subscribes to audio from a hundred peers. AudioLevelObserver is the alternative that reports the loudest producers and a silence event.

  • Cleanup. Closing the send and receive transports closes producers and consumers; the other peers are told with peerLeft, and consumers whose producer disappeared receive consumerClosed.

The Huddle browser client

The client opens the WebSocket, loads a Device with the router capabilities, creates two transports, captures the camera and microphone and publishes them, then consumes everything that exists or appears.

// Huddle SFU client built on mediasoup-client.
import { Device } from 'mediasoup-client';

export async function startHuddle({ room, name }) {
  const ws = new WebSocket(`ws://${location.host}/ws`);
  await new Promise((r) => (ws.onopen = r));

  const pending = new Map();
  const listeners = {};
  let seq = 0;
  ws.onmessage = (ev) => {
    const m = JSON.parse(ev.data);
    if (m.notify) return listeners[m.notify]?.(m.data);
    const p = pending.get(m.id); pending.delete(m.id);
    m.error ? p.reject(new Error(m.error)) : p.resolve(m.data);
  };
  const request = (method, data) => new Promise((resolve, reject) => {
    const id = ++seq; pending.set(id, { resolve, reject });
    ws.send(JSON.stringify({ id, method, data }));
  });

  const joined = await request('join', { room, name });
  const device = await Device.factory();
  await device.load({ routerRtpCapabilities: joined.routerRtpCapabilities });

  async function makeTransport(direction) {
    const params = await request('createTransport');
    const t = direction === 'send' ? device.createSendTransport(params) : device.createRecvTransport(params);
    t.on('connect', ({ dtlsParameters }, ok, fail) =>
      request('connectTransport', { transportId: t.id, dtlsParameters }).then(ok, fail));
    if (direction === 'send') {
      t.on('produce', ({ kind, rtpParameters }, ok, fail) =>
        request('produce', { transportId: t.id, kind, rtpParameters }).then(({ id }) => ok({ id }), fail));
    }
    t.on('connectionstatechange', (s) => (window.states ??= []).push(`${direction}:${s}`));
    return t;
  }

  const sendTransport = await makeTransport('send');
  const recvTransport = await makeTransport('recv');

  const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: { width: 1280, height: 720 } });
  const audio = await sendTransport.produce({ track: stream.getAudioTracks()[0] });
  const video = await sendTransport.produce({
    track: stream.getVideoTracks()[0],
    encodings: [
      { maxBitrate: 150_000, scaleResolutionDownBy: 4, scalabilityMode: 'L1T3' },
      { maxBitrate: 500_000, scaleResolutionDownBy: 2, scalabilityMode: 'L1T3' },
      { maxBitrate: 1_500_000, scalabilityMode: 'L1T3' },
    ],
  });

  const consumers = new Map();
  async function consumeProducer({ producerId, peerId }) {
    const info = await request('consume', { transportId: recvTransport.id, producerId, rtpCapabilities: device.recvRtpCapabilities });
    const consumer = await recvTransport.consume({ id: info.id, producerId, kind: info.kind, rtpParameters: info.rtpParameters });
    await request('resumeConsumer', { consumerId: consumer.id });
    consumers.set(consumer.id, { consumer, peerId });
    return consumer;
  }
  listeners.newProducer = consumeProducer;
  listeners.consumerClosed = ({ consumerId }) => { consumers.get(consumerId)?.consumer.close(); consumers.delete(consumerId); };
  listeners.activeSpeaker = ({ peerId }) => { window.activeSpeaker = peerId; };

  for (const e of joined.existing) await consumeProducer(e);

  return {
    peerId: joined.peerId, audio, video, consumers, request,
    async inboundBytes() {
      let total = 0;
      for (const { consumer } of consumers.values()) {
        for (const s of (await consumer.getStats()).values()) if (s.type === 'inbound-rtp') total += s.bytesReceived;
      }
      return total;
    },
  };
}

The server serves a tiny page that imports the bundle, and the bundle can be produced with any bundler because mediasoup-client is an ES module package with a few dependencies. The example used esbuild:

<!doctype html><meta charset="utf-8"><title>Huddle SFU</title>
<script type="module">
  import { startHuddle } from './huddle.js';
  window.startHuddle = startHuddle;
</script>
// build.mjs: bundle client.js and mediasoup-client into public/huddle.js
import { build } from 'esbuild';
await build({ stdin: { contents: "export { startHuddle } from './client.js'", resolveDir: '.' },
  bundle: true, format: 'esm', outfile: 'public/huddle.js', target: 'es2022' });

Notes on the client API:

  • Device.factory() is the current constructor and detects the browser’s handler (Chrome, Firefox and Safari have their own); new Device() is deprecated. device.load({ routerRtpCapabilities }) can be called only once; calling it again throws InvalidStateError. device.canProduce('video') tells whether the browser can send a kind of media.

  • produce({ track }) with no encodings sends one stream. With three entries it sends simulcast. Index 0 is the lowest quality, and the application must not set rid or active: mediasoup-client assigns them. The three encodings above (150 kbit/s at quarter size, 500 kbit/s at half size, 1.5 Mbit/s at full size, each L1T3, one spatial and three temporal layers) are the classic Huddle ladder; see Codecs, Simulcast and SVC for the browser side.

  • produce options also include codec (it must be in device.sendRtpCapabilities.codecs), codecOptions (for Opus: opusDtx, opusFec, opusStereo; for video: videoGoogleStartBitrate and friends), stopTracks (default true: closing the producer stops the track), disableTrackOnPause and zeroRtpOnPause.

  • consumer.pause() only disables the local track; to stop the server from sending, the server consumer must be paused too. A tile that is scrolled out of view should pause the server consumer, which saves downlink and worker CPU.

  • The ICE servers for the transport are passed as iceServers to createSendTransport; for clients behind strict firewalls give them the TURN setup from ICE, STUN and TURN. Plain UDP to the WebRtcServer port is usually enough for the server side, because the server has a public address.

Running it and checking the media

The driver launches Chrome with fake media devices through puppeteer-core (installed next to the example, never in a project), opens the page twice, lets both join the room standup, and then reads inbound-rtp bytes on the receiving side twice, two seconds apart, to prove that media flows through the SFU and keeps flowing.

import puppeteer from 'puppeteer-core';
const browser = await puppeteer.launch({
  executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
  headless: 'new',
  args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--no-first-run'],
});
const open = async (name) => {
  const page = await browser.newPage();
  page.on('console', (m) => console.log(`[${name}]`, m.text()));
  page.on('pageerror', (e) => console.log(`[${name}] pageerror`, e.message));
  await page.goto('http://localhost:41200/');
  await page.evaluate((n) => startHuddle({ room: 'standup', name: n }).then((h) => (window.h = h)), name);
  return page;
};
const a = await open('alice');
const b = await open('bob');
await new Promise((r) => setTimeout(r, 4000));
for (const [n, p] of [['alice', a], ['bob', b]]) {
  const r = await p.evaluate(async () => {
    const first = await h.inboundBytes();
    await new Promise((r) => setTimeout(r, 2000));
    const second = await h.inboundBytes();
    const kinds = [...h.consumers.values()].map((c) => c.consumer.kind);
    return { peerId: h.peerId, consumers: kinds, first, second, states: window.states, speaker: window.activeSpeaker };
  });
  console.log(n, JSON.stringify(r));
}
// layer selection and server-side stats on alice's video consumer
const r2 = await a.evaluate(async () => {
  const [id] = [...h.consumers].filter(([, v]) => v.consumer.kind === 'video').map(([k]) => k);
  await h.request('setPreferredLayers', { consumerId: id, spatialLayer: 0, temporalLayer: 2 });
  await new Promise((r) => setTimeout(r, 2500));
  return h.request('getStats', { consumerId: id });
});
console.log('consumer stats', JSON.stringify(r2));
await b.close();
await new Promise((r) => setTimeout(r, 500));
console.log('after bob left alice consumers:', await a.evaluate(() => h.consumers.size));
await browser.close();

A run produced, for the second participant, consumers: ["audio","video"], bytesReceived growing from 97166 to 149675 in two seconds, and both transports reaching the connected state. The server’s consumer statistics showed score: 10 (the best value, on a 0 to 10 scale) at about 160 kbit/s after the consumer was asked for spatial layer 0. When Bob’s page was closed, Alice’s consumer count dropped to 0. The same run against the Dockerised server gave the same results.

Compose and Dockerfile

FROM node:24-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends python3-pip build-essential \
    && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json ./
ENV PYTHON=python3 PIP_BREAK_SYSTEM_PACKAGES=1
RUN npm install --omit=dev --no-audit --no-fund
COPY server.js ./
COPY public ./public
EXPOSE 41200/tcp 41201/tcp 41201/udp
CMD ["node", "server.js"]
name: huddle-sfu
services:
  sfu:
    build: .
    environment:
      # the address browsers put in their ICE candidates: the Docker host, not the container
      ANNOUNCED_ADDRESS: ${ANNOUNCED_ADDRESS:-127.0.0.1}
    ports:
      - "41200:41200/tcp"
      - "41201:41201/udp"
      - "41201:41201/tcp"

Docker’s --network host works on Linux only and is what production deployments use to avoid the userland proxy for UDP. On Docker Desktop publish the ports as shown. For a second machine on the LAN, set ANNOUNCED_ADDRESS to the host’s LAN address; a browser on another device cannot reach 127.0.0.1.

Layer selection with simulcast and SVC

A simulcast producer arrives as several RTP streams (one per encoding). The consumer, however, is one stream whose rtpParameters contain a single encoding with a new random SSRC, so the receiving browser sees one continuous video track and does not renegotiate anything when quality changes. Inside the worker, a consumer of type simulcast (or svc, for VP9 with a scalabilityMode such as L3T3 or L3T3_KEY) picks the spatial and temporal layer to forward, based on the estimated available bandwidth of the receiving transport. The estimator needs the transport-wide congestion control or REMB feedback, and initialAvailableOutgoingBitrate (600 kbit/s by default) is the starting value before the estimator has data.

The application can steer this with three calls:

  • consumer.setPreferredLayers({ spatialLayer, temporalLayer }) caps the quality, for example spatialLayer: 0 for a thumbnail tile and the highest layer for the speaker. The worker can still go lower when bandwidth is short. consumer.currentLayers and the layerschange event tell what is actually forwarded. In the example the setPreferredLayers request is the one the Huddle UI sends when a tile shrinks.

  • consumer.setPriority(1..255) decides which consumers get bandwidth first when the transport’s budget is not enough for all of them. Give the active speaker 255 and the thumbnails 1.

  • consumer.requestKeyFrame() asks the producer’s encoder for a keyframe, for example after a layer switch that looks wrong. The worker rate-limits keyframe requests to the producer to one per second, to protect the sender’s uplink; the keyFrameRequestDelay producer option tunes it.

Switching the spatial layer needs a keyframe of the new layer, so quality changes are not instantaneous. SVC with K-SVC (_KEY modes) needs one too to raise the maximum spatial layer. The consumer score and the producer score events (0 to 10) are the cheap health indicators to surface in a UI.

Active speaker and audio levels

The ActiveSpeakerObserver in the server code listens to the RFC 6464 audio level header extension that the browsers send by default, and emits dominantspeaker when a participant has led the conversation for a short time. The interval option (in milliseconds) controls how often the observer evaluates. The Huddle client stores the id as window.activeSpeaker; a real UI highlights the tile and calls setPriority and setPreferredLayers for the speaker’s video. In the verification run Alice’s client received the notification activeSpeaker with her own peer id (p1), because the fake microphone of Chrome produces a periodic beep. AudioLevelObserver({ maxEntries, threshold, interval }) is a different tool: it emits volumes with the loudest entries and silence, which suits volume meters.

Data channels through the SFU

Transports created with enableSctp: true carry data channels. The client calls sendTransport.produceData(); the server’s produceData() creates a DataProducer, and each other participant gets a DataConsumer from transport.consumeData(). This gives Huddle its in-call chat over SCTP. For Pixel Arena the same mechanism can be used for state updates to many players, but a game server that owns the simulation usually talks to each player with its own channel, which is the model in Multiplayer Game Architecture. DataChannel options (ordered, maxPacketLifeTime, maxRetransmits) are explained in Data Channels. sctpZeroChecksum and the built-in SCTP stack (useBuiltInSctpStack in createWorker) are tuning options of recent releases.

Scaling: workers, routers and hosts

The official scalability guidance has a few numbers worth remembering:

  • A worker uses one core. Start at most one worker per CPU core of the host.

  • A worker typically handles about 500 consumers in total, depending on the CPU. In a room of four people who all send and receive audio and video that is 4 x 3 x 2 = 24 consumers, so one worker holds roughly twenty such rooms.

  • A router (a room) cannot be split across workers. A single broadcaster with more than 200 to 300 viewers exceeds one worker; use router.pipeToRouter({ producerId, router }) to pipe the producer into routers on other workers (or other hosts, through a PipeTransport), and let viewers consume from those. Piped producers keep all their simulcast streams.

  • Many viewers requesting keyframes can multiply the broadcaster’s bitrate; the one-per-second limit softens it.

  • To go beyond one host, run a mediasoup process per host and put your own signalling in front. mediasoup has no cluster protocol: a room directory (Redis, a database) maps room names to hosts, and the same Scaling WebRTC and TURN and Scaling WebSockets reasoning applies to the signalling tier.

A minimal multi-worker allocator keeps the least loaded worker for each new room:

import os from 'node:os';
import * as mediasoup from 'mediasoup';

const workers = [];
for (let i = 0; i < os.availableParallelism(); i++) {
  const worker = await mediasoup.createWorker({ logLevel: 'warn' });
  const webRtcServer = await worker.createWebRtcServer({
    listenInfos: [
      { protocol: 'udp', ip: '0.0.0.0', announcedAddress: process.env.ANNOUNCED_ADDRESS, port: 41201 + i },
      { protocol: 'tcp', ip: '0.0.0.0', announcedAddress: process.env.ANNOUNCED_ADDRESS, port: 41201 + i },
    ],
  });
  worker.on('died', () => process.exit(1)); // let the supervisor restart the whole process
  workers.push({ worker, webRtcServer, rooms: 0 });
}

export function pickWorker() {
  const best = workers.reduce((a, b) => (a.rooms <= b.rooms ? a : b));
  best.rooms++;
  return best;
}

(This allocator is a sketch that follows the documented API; the verified run above used one worker.) Use worker.getResourceUsage() for CPU numbers instead of the room count when rooms are very uneven in size.

The Rust crate

mediasoup also exists as a Rust crate (mediasoup 0.32.0 on crates.io and docs.rs), a port of the same API with the same worker underneath: WorkerManager, Worker, Router, WebRtcTransport and the other types mirror the JavaScript objects, and the worker runs as a thread of your process rather than a child process. Choose it when the rest of your backend is Rust and you want to avoid a Node.js process; the signalling and the browser half (mediasoup-client) stay the same.

Common errors and pitfalls

  • ICE never connects (transport state stays connecting, then failed): the browser cannot reach the address in announcedAddress, or the UDP port is not open on the firewall or not published by Docker. Check the candidates that createWebRtcTransport returned, try the TCP candidate by setting preferTcp, and watch the browser’s chrome://webrtc-internals page.

  • announcedAddress is required: creating a listen info on 0.0.0.0 without it throws.

  • DTLS failure after connect: the connect callback was called before the server’s transport.connect() completed, or the same transport was connected twice.

  • Cannot consume: router.canConsume() returned false because the client’s recvRtpCapabilities do not include a codec of the producer; the client sent the wrong capabilities (sendRtpCapabilities instead of recvRtpCapabilities) or the router lacks a codec the browsers support.

  • Black video only in one browser: the codec list of the router offers only H.264 profiles that this browser does not support. Include VP8 as a baseline.

  • Everything stops at once: the worker died (out of memory, a bug) and no died handler restarted the process.

  • Rooms never freed: routers stay open until closed; close the router when the last peer leaves.

  • Missing audio: a producer that never gets its ActiveSpeakerObserver registered will not influence it, and addProducer accepts only audio producers.

  • Install script skipped: see Installation and the native worker.

Security

  • Authenticate the signalling, because everything after the WebSocket is trusted. Verify a short-lived token on the upgrade request or in the join request (see Authentication and Authorization), and authorise room names. The demo above has no authentication and must not be exposed as is.

  • Never let a client pick a transport or producer id it does not own. The server looks up ids in the peer’s own maps; a shared global map would let one participant consume another room’s producers.

  • DTLS-SRTP protects each hop only. The SFU decrypts and re-encrypts the media, so a compromised server sees it; for end-to-end privacy use insertable streams as in End-to-End Encryption.

  • Rate-limit signalling requests and bound the number of transports and producers per peer, since each costs worker resources.

  • Expose only the WebRtcServer ports. The signalling port should sit behind TLS (wss://) as shown in Proxies, Load Balancers and Deployment.

  • The TURN credentials, if you use any, must be short-lived values from the signalling server, never constants in the page.

Versions and history

The 3.x series is the current line. WebRtcServer appeared in 3.10.0, and 3.13.0 replaced the JSON messages between the Node.js process and the worker with FlatBuffers and introduced TransportListenInfo, which supersedes the per-transport listenIps and the worker-wide rtcMinPort and rtcMaxPort options (both are now deprecated). mediasoup-client 3.25 offers Device.factory() and deprecates new Device(), and the 3.31 package declares Node.js 22 or later in its engines. The 3.31.0 release itself is a fix release: simulcast RTP timestamp jumps on layer switches, a frozen retransmission buffer, an SRTP replay-check error in probation packets and SCTP updates. The project is maintained by Versatica and is licensed ISC. Compare it with the other SFUs in the table of Topologies: Mesh, SFU and MCU, and see Stats, Quality and Bandwidth for the numbers to watch.

Where to go next