Janus and Jitsi

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.

Building a videoconference on an SFU library such as mediasoup is a lot of work: you write the signalling, the room model, the authentication, the recording and the user interface. Two open source projects occupy the other end of the spectrum. Janus Gateway is a general-purpose WebRTC server written in C, with a small core and a set of plugins (VideoRoom, TextRoom, Streaming, SIP and others) that you drive through a JSON API. Jitsi Meet is a complete, ready-to-use conferencing product made of a web application, an XMPP server, a conference focus, a media bridge and optional recording and telephony components. This page explains how each of them works, runs both in Docker, drives them from Node.js and headless Chrome for the Huddle scenario, and ends with a rule of thumb for choosing between a ready-made server and your own SFU.

Versions were checked on 2026-10-24: Janus Gateway 1.4.3 (tag v1.4.3, licence GPL-3.0, janus-gateway 1.4.3 on npm for janus.js), and the docker-jitsi-meet release stable-11248-2 published on 2026-10-23, which ships Prosody 13.0.7 and Jitsi Videobridge 2.3.318. The Jitsi projects are licensed under Apache-2.0.

Where each one fits

Aspect Janus Gateway Jitsi Meet

What you get

A WebRTC server with plugins and a JSON API. No user interface of its own besides demos.

A finished conferencing web application plus the servers behind it.

Language and licence

C, GPL-3.0

Java and Kotlin servers, JavaScript and React client, Apache-2.0

Signalling

Janus API: REST over HTTP, WebSocket, RabbitMQ, MQTT, Nanomsg or Unix sockets

XMPP (Prosody) over WebSocket or BOSH

Media topology

One PeerConnection per publisher and one or more for subscribers, a classic SFU (VideoRoom)

Jitsi Videobridge SFU, with a direct peer-to-peer connection for two participants

You write

The application, the room and user model, the front end

Configuration, branding and the integration with your user database (for example JWT)

Customisation

Complete freedom, at the cost of building everything

Embed it, theme it, or build a custom client on lib-jitsi-meet

Huddle fit

A custom Huddle front end with an SFU you did not have to write

The whole of Huddle in one deployment, embedded in an existing site

The Chatterbox chat protocol is not involved here. Both servers are about media, and any chat is carried by the server itself (the Janus TextRoom data channel plugin, or Jitsi’s XMPP multi-user chat). Compare them with the SFUs in mediasoup and LiveKit, and read Topologies: Mesh, SFU and MCU for the background on SFUs.

Janus Gateway

The model: core, transports and plugins

The Janus core terminates WebRTC: it handles ICE, DTLS-SRTP and the data channel transport, and it speaks the Janus API to applications. It contains no application logic. Everything specific, such as a conference room or a recording, lives in a plugin, a shared library loaded at startup. The messages that an application sends to a plugin are opaque to the core: the core relays them and takes care of the WebRTC session negotiation that may accompany them. The plugins shipped with 1.4.3, as reported by the server itself, are:

  • janus.plugin.videoroom: a publish/subscribe SFU for video conferences, the one Huddle needs.

  • janus.plugin.textroom: text-only rooms over data channels.

  • janus.plugin.streaming: relay of RTP streams and files to WebRTC viewers; it is the usual landing point for a stream produced by FFmpeg or GStreamer.

  • janus.plugin.sip and janus.plugin.nosip: a bridge between WebRTC and SIP, and a plugin that leaves the SIP signalling to the application. See Telephony with Asterisk and SIP for the SIP side.

  • janus.plugin.audiobridge: an audio mixing room (an MCU for Opus audio).

  • janus.plugin.videocall, janus.plugin.echotest and janus.plugin.recordplay: a two-party call relay, a loopback test and record/replay.

Applications reach the core through a transport. The REST (HTTP) and WebSocket transports are built in a default installation; RabbitMQ, MQTT, Nanomsg and Unix sockets are optional. All of them carry the same JSON messages.

The JSON API

Every message has a janus field naming its type and a transaction string chosen by the sender, which the server echoes in the answer. The life cycle is:

  1. create a session (the server returns data.id). A session that is idle for longer than session_timeout is destroyed, so clients send keepalive messages.

  2. attach a handle to a plugin (the server returns another data.id). One handle represents one participant’s conversation with one plugin and owns at most one PeerConnection.

  3. Send message requests on the handle. The body is plugin specific, and an optional jsep carries an SDP offer or answer. trickle messages carry ICE candidates.

  4. Receive the answers. A request can be answered immediately with success, or acknowledged with ack while the real result arrives later as an event, matched by transaction. Core notifications such as webrtcup, media, slowlink and hangup arrive the same way.

  5. detach the handle and destroy the session.

Over plain HTTP the events come from a long poll: a GET on the session URL waits up to 30 seconds and returns a keepalive when nothing happened, one event by default or an array with the maxev query parameter. Over WebSocket the same events arrive on the same socket. The socket is opened with the subprotocol janus-protocol, every request carries session_id (and handle_id for handle requests), and closing the socket releases its sessions.

Running Janus in Docker

The project does not publish an official image, and community images lag behind: the best-known one was last rebuilt in 2024. A Dockerfile that builds the 1.4.3 tag on Debian 13 is short enough to own yourself.

FROM debian:trixie-slim AS build
RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential autoconf automake libtool pkg-config git ca-certificates gengetopt \
    libmicrohttpd-dev libjansson-dev libssl-dev libsrtp2-dev libsofia-sip-ua-dev \
    libglib2.0-dev libopus-dev libogg-dev libcurl4-openssl-dev liblua5.3-dev \
    libconfig-dev libnice-dev libwebsockets-dev libusrsctp-dev \
 && rm -rf /var/lib/apt/lists/*
RUN git clone --depth 1 --branch v1.4.3 https://github.com/meetecho/janus-gateway.git /src
WORKDIR /src
RUN sh autogen.sh \
 && ./configure --prefix=/opt/janus --enable-websockets --enable-data-channels \
 && make -j4 && make install && make configs

FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
    libmicrohttpd12t64 libjansson4 libssl3t64 libsrtp2-1 libsofia-sip-ua0t64 \
    libglib2.0-0t64 libopus0 libogg0 libcurl4t64 liblua5.3-0 libconfig11 \
    libnice10 libwebsockets19t64 libusrsctp2 curl ca-certificates \
 && rm -rf /var/lib/apt/lists/*
COPY --from=build /opt/janus /opt/janus
EXPOSE 8088 8188
CMD ["/opt/janus/bin/janus", "-F", "/opt/janus/etc/janus"]

Build it and run it with the HTTP and WebSocket transports published. The media ports are a separate matter: the server picks UDP ports for RTP from the range set by rtp_port_range in janus.jcfg, and a real deployment needs that range open, a nat_1_1_mapping for a public address and a STUN or TURN server (see ICE, STUN and TURN).

docker build -t huddle-janus:1.4.3 .
docker run -d --name huddle-janus -p 8088:8088 -p 8188:8188 huddle-janus:1.4.3
curl -s http://localhost:8088/janus/info

The make configs step installs sample configuration files such as janus.plugin.videoroom.jcfg, which defines a demo room 1234 with the secret adminpwd. Change or remove it before exposing the server.

Driving the API from Node.js

The script below is a Huddle server-side helper. It reads the server information, creates a session, attaches the VideoRoom plugin, creates a room, lists the rooms and joins as a publisher. It shows both answer styles: the room creation is answered synchronously, the join is acknowledged and its result is read from the long poll.

// Huddle: drive Janus's VideoRoom over the plain HTTP REST transport.
const BASE = process.env.JANUS_URL ?? 'http://localhost:8088/janus';
let n = 0;
const tx = () => `huddle-${++n}`;

async function post(path, body) {
  const res = await fetch(BASE + path, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ transaction: tx(), ...body }),
  });
  const json = await res.json();
  if (json.janus === 'error') throw new Error(`${json.error.code}: ${json.error.reason}`);
  return json;
}

// Asynchronous plugin events arrive through a long poll on the session.
async function nextEvent(session) {
  const res = await fetch(`${BASE}/${session}?maxev=1`);
  const events = await res.json();
  return Array.isArray(events) ? events[0] : events;
}

const info = await (await fetch(`${BASE}/info`)).json();
console.log('server', info.version_string, Object.keys(info.plugins).length, 'plugins');

const session = (await post('', { janus: 'create' })).data.id;
const handle = (await post(`/${session}`, { janus: 'attach', plugin: 'janus.plugin.videoroom' })).data.id;

// Synchronous request: the answer is in the HTTP response itself.
const created = await post(`/${session}/${handle}`, {
  janus: 'message',
  body: { request: 'create', description: 'Huddle standup', publishers: 8, is_private: false },
});
const room = created.plugindata.data.room;
console.log('created room', room);

const list = await post(`/${session}/${handle}`, { janus: 'message', body: { request: 'list' } });
console.log('rooms', list.plugindata.data.list.map((r) => `${r.room}:${r.description}`).join(', '));

// Asynchronous request: HTTP answers "ack", the result comes from the long poll.
const ack = await post(`/${session}/${handle}`, {
  janus: 'message',
  body: { request: 'join', ptype: 'publisher', room, display: 'Ada' },
});
console.log('join ack:', ack.janus);
let ev;
do { ev = await nextEvent(session); } while (ev.janus === 'keepalive');
console.log('event:', ev.janus, ev.plugindata.data.videoroom, 'publisher id', ev.plugindata.data.id);

await post(`/${session}`, { janus: 'keepalive' });
await post(`/${session}/${handle}`, { janus: 'detach' });
await post(`/${session}`, { janus: 'destroy' });
console.log('session destroyed');

Against the container above, the script prints the server version 1.4.3 with nine plugins, the id of the new room, the list of rooms (the demo room and the new one), join ack: ack and a joined event carrying the publisher id. Room and publisher ids are 64-bit numbers chosen by the server; keep them as numbers or strings consistently, because very large values lose precision in JavaScript Number. The same API over WebSocket is shown next, with the TextRoom plugin that Pixel Arena could use for lobby chat:

// Pixel Arena lobby: the same Janus API over the WebSocket transport (subprotocol janus-protocol).
const ws = new WebSocket(process.env.JANUS_WS ?? 'ws://localhost:8188', 'janus-protocol');
const pending = new Map();
ws.onmessage = (m) => {
  const msg = JSON.parse(m.data);
  const resolve = pending.get(msg.transaction);
  if (resolve && msg.janus !== 'ack') { pending.delete(msg.transaction); resolve(msg); }
  else if (!msg.transaction) console.log('async:', msg.janus);
};
let n = 0;
const send = (obj) => new Promise((resolve) => {
  const transaction = `arena-${++n}`;
  pending.set(transaction, resolve);
  ws.send(JSON.stringify({ transaction, ...obj }));
});
await new Promise((r) => (ws.onopen = r));
console.log('subprotocol:', ws.protocol);
const session = (await send({ janus: 'create' })).data.id;
const handle = (await send({ janus: 'attach', session_id: session, plugin: 'janus.plugin.textroom' })).data.id;
const reply = await send({ janus: 'message', session_id: session, handle_id: handle, body: { request: 'list' } });
console.log('textroom list:', JSON.stringify(reply.plugindata.data));
await send({ janus: 'destroy', session_id: session });
ws.close();

Source: Janus documentation: Janus API (REST/HTTP) (the WebSocket transport is described on the same page)

Node.js 22 and later provide the browser-style global WebSocket client used here. The script prints subprotocol: janus-protocol and the list of TextRoom rooms (the demo room 1234, with zero participants).

The VideoRoom plugin

VideoRoom is the plugin behind a Janus-based Huddle. A participant first joins a room as a publisher on a master handle and uses publish to send an SDP offer; the answer comes back with a configured event. To watch somebody else the participant creates a separate subscriber handle, because publishers and subscribers always use different PeerConnections. The subscriber joins with a streams array naming the feeds (and optionally the media ids) it wants, receives an attached event with an SDP offer, and answers with start. In the multistream mode of the current API one subscriber PeerConnection can carry the streams of many publishers, and update, subscribe and unsubscribe change the set without creating new connections; the older one-feed-per-PeerConnection style is deprecated.

Other features that matter for Huddle:

  • Simulcast and SVC. A subscriber picks the simulcast substream and temporal layer, or the VP9 and AV1 SVC spatial_layer and temporal_layer, per stream, so the application implements adaptive quality. Background in Codecs, Simulcast and SVC.

  • RTP forwarding. rtp_forward copies a publisher’s media to a plain UDP address, optionally SRTP encrypted. This is how recording and transcoding pipelines are attached (see Recording and Media Processing), and it is also how a stream can be turned into a Streaming plugin mountpoint.

  • Access control. A room has a secret for administrative requests, a pin to join, an allowed list of tokens, and can require end-to-end encryption (require_e2ee). A server-wide admin_key restricts who may create rooms. is_private hides a room from list.

  • Cascading. Publishers can be forwarded between Janus instances with the remote publisher requests, but the coordination between the instances is done by your application.

janus.js and Janode

The browser library is janus.js, a convenience library the Janus authors describe as meant for their demos. It is published on npm as janus-gateway and as ES module builds, and needs the webrtc-adapter package. It hides the session, keepalive and long-poll handling and exposes the handle with callbacks. A Huddle publisher looks like this (the server address is a placeholder):

import Janus from 'janus-gateway';
import adapter from 'webrtc-adapter';

let pub; // the publisher handle
const localVideo = document.querySelector('#local');

Janus.init({
  debug: 'warn',
  dependencies: Janus.useDefaultDependencies({ adapter }),
  callback: () => {
    const janus = new Janus({
      server: ['wss://janus.example.test/ws', 'https://janus.example.test/janus'], // try WebSocket, then HTTP
      success: () => janus.attach({
        plugin: 'janus.plugin.videoroom',
        success: (h) => {
          pub = h;
          pub.send({ message: { request: 'join', ptype: 'publisher', room: 1234, display: 'Ada' } });
        },
        onmessage: (msg, jsep) => {
          if (msg.videoroom === 'joined') {
            // Publish the camera and microphone as soon as we are in the room.
            pub.createOffer({
              tracks: [{ type: 'audio', capture: true }, { type: 'video', capture: true }],
              success: (offer) => pub.send({ message: { request: 'configure', audio: true, video: true }, jsep: offer }),
            });
          }
          if (jsep) pub.handleRemoteJsep({ jsep });
        },
        onlocaltrack: (track, on) => { if (on && track.kind === 'video') localVideo.srcObject = new MediaStream([track]); },
      }),
      error: (err) => console.error('janus', err),
    });
  },
});

This snippet is a sketch of the call sequence and not a complete application: a Huddle page also needs the subscriber handle logic, and the exact options of createOffer and attach are in the library reference. For server-side Node.js clients, the maintainers point to Janode, a separate Node.js SDK.

Authentication and security

Out of the box Janus accepts anybody who can reach its ports. Protect it by placing it behind a reverse proxy with TLS, and by enabling the built-in options: a shared api_secret for all requests, or token-based authentication with tokens that the Admin API (a separate, privately exposed endpoint) can add and revoke. Never expose the admin endpoint publicly, change the demo room secret, and in the browser keep the shared secret out of the page: issue a short-lived token from your own backend after your normal login. The Huddle backend can then decide who may create or join rooms, as in Authentication and Authorization. The webrtcup, media and slowlink events are your first signals of media trouble (see Stats, Quality and Bandwidth).

Jitsi Meet

The components

A Jitsi Meet deployment is a small distributed system. The pieces and their roles are:

  • Jitsi Meet: the React web application served by nginx. It is also the source of the external_api.js embedding script and of lib-jitsi-meet.

  • Prosody: the XMPP server that carries all signalling. Browsers connect over WebSocket or BOSH, join a multi-user chat room per conference, and negotiate media using Jingle. See Jingle Calls and Jitsi for the protocol.

  • Jicofo (Jitsi Conference Focus): joins each room as a participant, authorises the conference and, for every participant, allocates a channel on a videobridge. It balances conferences across bridges.

  • Jitsi Videobridge (JVB): the SFU. It forwards the participants' audio and video and reports its load to Jicofo through a bridge-brewery MUC room. With two participants, clients talk to each other directly and the bridge is used from the third participant on.

  • Jibri: records or live-streams a conference by running a Chrome instance on a virtual display and encoding it.

  • Jigasi: a gateway that lets SIP clients and phone numbers join a conference, and that can transcribe.

flowchart LR B[Browser: Jitsi Meet or lib-jitsi-meet] -->|HTTPS, WebSocket XMPP| W[nginx web] W --> P[Prosody XMPP] P <-->|focus user| J[Jicofo] J -->|allocate channels| V[Jitsi Videobridge] V -->|bridge brewery MUC| P B -.->|SRTP over UDP 10000| V K[Jibri] -->|joins as hidden client| P G[Jigasi] --> P

The docker-jitsi-meet quickstart

The supported way to run the stack on one machine is the docker-jitsi-meet repository. The steps below follow the official handbook; download the latest release archive rather than cloning, because the compose file and the image tags move together.

wget $(wget -q -O - https://api.github.com/repos/jitsi/docker-jitsi-meet/releases/latest | grep zip | cut -d\" -f4)
unzip stable-11248-2.zip && cd docker-jitsi-meet-stable-11248-2   # use the file name you downloaded
cp env.example .env
./gen-passwords.sh                                                 # fills the internal component passwords

mkdir -p ~/.jitsi-meet-cfg/{web,prosody/config,prosody/prosody-plugins-custom,jicofo,jvb,jigasi,jibri,transcriber}
mkdir -p ~/.jitsi-meet-cfg/storage/{jibri,prosody,transcripts,web}
mkdir -p ~/.jitsi-meet-cfg/tmp/{web-crontabs,web-load-test}
chmod 777 ~/.jitsi-meet-cfg/storage/{jibri,prosody,transcripts,web}
chmod 777 ~/.jitsi-meet-cfg/tmp/{web-crontabs,web-load-test}

docker compose up -d
# open https://localhost:8443

The containers run as uid and gid 1000, so the storage directories must be writable by that user, which is the reason for the chmod. On Docker Desktop for macOS a bind mount owned by your own account can leave Prosody unable to write its certificates; in that case the stack starts, the web page loads, but Jicofo and the bridge log No stream features to proceed with because Prosody has no TLS context. Giving Prosody a named volume for /var/lib/prosody fixes it.

# docker-compose.override.yml, next to the downloaded docker-compose.yml
services:
    prosody:
        volumes:
            - prosody-data:/var/lib/prosody
volumes:
    prosody-data:

A few .env settings decide whether the deployment works beyond localhost:

  • PUBLIC_URL must be the exact address users type, including a non-default port. The web container’s config.js is generated from it. Plain HTTP on port 8000 is meant for a reverse proxy; browsers refuse camera access on insecure origins.

  • HTTP_PORT and HTTPS_PORT (default 8000 and 8443) are the published web ports, JVB_PORT (default 10000/udp) is the media port. To run several stacks on one host, change all three and keep JVB_PORT in sync with the firewall.

  • JVB_ADVERTISE_IPS lists the addresses the bridge puts in its ICE candidates. Behind NAT or on a LAN it must contain the public or LAN address of the Docker host, otherwise two-person calls work through the direct connection and calls of three or more fail. Compare with Scaling WebRTC and TURN.

  • ENABLE_AUTH=1 with AUTH_TYPE set to internal, jwt or ldap requires login to create a room. ENABLE_GUESTS=1 lets anonymous users join once a moderator is present.

  • Optional services are separate compose files combined with -f: jibri.yml for recording and live streaming (ENABLE_RECORDING=1), jigasi.yml for SIP, etherpad.yml for shared notes and whiteboard.yml for a whiteboard. Jibri needs a host with enough CPU, as each recording is a full browser.

  • External TURN servers are configured with TURN_HOST, TURN_PORT, TURN_CREDENTIALS, TURNS_HOST and TURNS_PORT, which Prosody hands to clients as ephemeral credentials (background in ICE, STUN and TURN).

Embedding with the IFrame API

The least effort way to put Jitsi into the Huddle front end is the IFrame API: the page loads external_api.js from the Jitsi deployment, creates a JitsiMeetExternalAPI object that inserts an iframe, and then controls it with commands and listens to events. The options include roomName, parentNode, userInfo, jwt, configOverwrite and interfaceConfigOverwrite.

<!doctype html>
<meta charset="utf-8">
<title>Huddle embedded in Jitsi</title>
<div id="meet" style="height:480px"></div>
<script src="https://localhost:8443/external_api.js"></script>
<script>
  const api = new JitsiMeetExternalAPI('localhost:8443', {
    roomName: 'huddle-standup-42',
    parentNode: document.querySelector('#meet'),
    userInfo: { displayName: 'Ada' },
    configOverwrite: { prejoinConfig: { enabled: false }, startWithAudioMuted: false },
    interfaceConfigOverwrite: { TOOLBAR_BUTTONS: ['microphone', 'camera', 'hangup', 'chat'] }
  });
  for (const name of ['videoConferenceJoined', 'participantJoined', 'audioMuteStatusChanged', 'readyToClose']) {
    api.addListener(name, (e) => console.log(name, e));
  }
  // Later: api.executeCommand('toggleAudio'); api.executeCommand('hangup');
</script>

Served from a local page and run in headless Chrome with fake media devices, this joins the room and fires videoConferenceJoined with the display name Ada. After executeCommand('toggleAudio'), isAudioMuted() resolves to true. The commands and events are listed on the handbook’s IFrame commands and events pages. Because the Jitsi page runs in an iframe, the embedding page’s Permissions-Policy must allow camera, microphone and display capture for the Jitsi origin, as explained in Media Capture and Devices; external_api.js sets the iframe’s allow attribute for you.

With ENABLE_AUTH=1 and AUTH_TYPE=jwt (and JWT_APP_ID and JWT_APP_SECRET set in .env), the jwt option takes a token signed by your own backend, which carries the user’s name, avatar and moderator flag. This is the integration point with an existing user database; the secret never reaches the browser, and the token should expire shortly after it is issued. See Authentication and Authorization.

Building a custom client with lib-jitsi-meet

When the Jitsi user interface does not fit, lib-jitsi-meet exposes the same connection and conference logic without it. The handbook loads the library from the deployment it talks to (the copy on npm is a stale 2016 release, so do not install it from there). The global JitsiMeetJS object creates a connection to Prosody, a conference object for a room and local tracks from the camera and microphone.

<!doctype html>
<meta charset="utf-8">
<title>Huddle with lib-jitsi-meet</title>
<script src="https://localhost:8443/libs/lib-jitsi-meet.min.js"></script>
<script>
  async function main() {
    JitsiMeetJS.init({});
    const E = JitsiMeetJS.events;
    const connection = new JitsiMeetJS.JitsiConnection(null, null, {
      hosts: { domain: 'meet.jitsi', muc: 'muc.meet.jitsi' },
      serviceUrl: 'wss://localhost:8443/xmpp-websocket?room=huddle-ljm-7',
    });

    connection.addEventListener(E.connection.CONNECTION_ESTABLISHED, async () => {
      const room = connection.initJitsiConference('huddle-ljm-7', {});
      room.on(E.conference.CONFERENCE_JOINED, () => console.log('joined'));
      room.on(E.conference.TRACK_ADDED, (t) => console.log('track', t.getType(), t.isLocal() ? 'local' : 'remote'));
      room.on(E.conference.USER_JOINED, (id) => console.log('user', id));
      room.setDisplayName('Grace');
      const tracks = await JitsiMeetJS.createLocalTracks({ devices: ['audio', 'video'] });
      for (const t of tracks) room.addTrack(t);
      room.join();
    });
    connection.addEventListener(E.connection.CONNECTION_FAILED, () => console.log('failed'));
    connection.connect();
  }
  main();
</script>

Against the local stack in headless Chrome with fake devices, the page logs connected, a local audio track, a local video track and joined. The domain and muc values are the XMPP domain names of the docker-jitsi-meet defaults (meet.jitsi); change them if you changed XMPP_DOMAIN. The page and the library share an origin policy for the WebSocket: the serviceUrl must be reachable from the page, so a custom client normally lives on the same host or behind the same reverse proxy as the Jitsi web container. Remote tracks are attached to <video> elements in the TRACK_ADDED handler, and the library offers events for dominant speaker, mute changes, connection quality and chat.

Choosing between a ready-made server and an SFU of your own

Pick Jitsi Meet when the product requirement is "meetings": scheduled or ad-hoc rooms, a familiar interface with chat, raise-hand, lobby, recording and dial-in, and you have little appetite for building a front end. Embed it with the IFrame API behind JWT, and spend your effort on branding and the user database. The cost is a heavier stack (five or six containers, a JVM or two) and less freedom to change how the client behaves than a custom application has.

Pick Janus when you need a media server for a custom application, an unusual plugin mix (SIP bridging, RTP ingest and playback, audio mixing, recording) or a very small C footprint, and you are comfortable writing the room and user logic. It leaves the user interface, authentication and scaling to you, and janus.js is a thin helper rather than a framework.

Pick a developer-oriented SFU, mediasoup or LiveKit, when the video call is one feature of a larger product and you want SDKs, a rooms-and-tokens model and a cloud option; LiveKit in particular bundles signalling, TURN, recording (egress) and ingest. For ingest of streams from broadcasting tools into any of these servers see WHIP, WHEP and Live Streaming, and for peers written in Go or Python see Pion and aiortc. The end-to-end design is in Videoconference Architecture and Building a Videoconference App.

Common errors and pitfalls

  • Calls with two people work, three fail. The bridge advertises an unreachable address. Set JVB_ADVERTISE_IPS, open the media UDP port, and consider a TURN server on port 443 for restrictive networks.

  • Camera not allowed in Jitsi. The page was loaded from plain HTTP or an address that does not match PUBLIC_URL. Browsers require a secure context.

  • Jicofo reports No stream features to proceed with. Prosody could not create or read its TLS certificates, usually a permissions problem on /var/lib/prosody or the config directory.

  • Janus ignores your request with error 458 or 459. The session id is unknown (it timed out for lack of keepalives) or the handle id is unknown. Send keepalive more often than session_timeout and handle the timeout event.

  • Janus join works but no media flows. The ICE ports are not reachable. Check rtp_port_range, nat_1_1_mapping and the STUN or TURN configuration, then watch for webrtcup and slowlink events.

  • Mixing legacy and multistream subscriptions. Use the streams array consistently in new code.

  • Leaving demo credentials. The sample VideoRoom configuration, an open admin endpoint and a missing api_secret each let strangers use your server.

  • Exposing internal ports. The Jitsi REST ports of Jicofo and the bridge are bound to the loopback interface by default; keep them that way.