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.sipandjanus.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.echotestandjanus.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:
-
createa session (the server returnsdata.id). A session that is idle for longer thansession_timeoutis destroyed, so clients sendkeepalivemessages. -
attacha handle to a plugin (the server returns anotherdata.id). One handle represents one participant’s conversation with one plugin and owns at most one PeerConnection. -
Send
messagerequests on the handle. Thebodyis plugin specific, and an optionaljsepcarries an SDP offer or answer.tricklemessages carry ICE candidates. -
Receive the answers. A request can be answered immediately with
success, or acknowledged withackwhile the real result arrives later as anevent, matched bytransaction. Core notifications such aswebrtcup,media,slowlinkandhanguparrive the same way. -
detachthe handle anddestroythe 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"]
Source: Janus documentation: Deployment
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
substreamandtemporallayer, or the VP9 and AV1 SVCspatial_layerandtemporal_layer, per stream, so the application implements adaptive quality. Background in Codecs, Simulcast and SVC. -
RTP forwarding.
rtp_forwardcopies 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
secretfor administrative requests, apinto join, anallowedlist of tokens, and can require end-to-end encryption (require_e2ee). A server-wideadmin_keyrestricts who may create rooms.is_privatehides a room fromlist. -
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.jsembedding script and oflib-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.
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_URLmust be the exact address users type, including a non-default port. The web container’sconfig.jsis generated from it. Plain HTTP on port 8000 is meant for a reverse proxy; browsers refuse camera access on insecure origins. -
HTTP_PORTandHTTPS_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 keepJVB_PORTin sync with the firewall. -
JVB_ADVERTISE_IPSlists 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=1withAUTH_TYPEset tointernal,jwtorldaprequires login to create a room.ENABLE_GUESTS=1lets anonymous users join once a moderator is present. -
Optional services are separate compose files combined with
-f:jibri.ymlfor recording and live streaming (ENABLE_RECORDING=1),jigasi.ymlfor SIP,etherpad.ymlfor shared notes andwhiteboard.ymlfor 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_HOSTandTURNS_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>
Source: Jitsi Handbook: IFrame API
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/prosodyor 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
keepalivemore often thansession_timeoutand handle thetimeoutevent. -
Janus join works but no media flows. The ICE ports are not reachable. Check
rtp_port_range,nat_1_1_mappingand the STUN or TURN configuration, then watch forwebrtcupandslowlinkevents. -
Mixing legacy and multistream subscriptions. Use the
streamsarray consistently in new code. -
Leaving demo credentials. The sample VideoRoom configuration, an open admin endpoint and a missing
api_secreteach 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.