LiveKit

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 on 2026-10-24 (checked on 2026-10-24) against LiveKit server 1.13.10, livekit-client 2.23.0, livekit-server-sdk 2.19.2, @livekit/components-react 2.9.24, LiveKit Egress 1.15.0 and LiveKit Ingress 1.5.0, on Node.js 24 and Chrome with fake capture devices. The server, the token endpoint, two browser pages, a webhook receiver, an end-to-end encrypted room, a track-composite recording and a WHIP ingress were all run locally with Docker; the React component was type-checked against the published package, as stated after each example.

LiveKit is an open-source (Apache-2.0) WebRTC platform built around a selective forwarding unit written in Go. It packages what a team building a videoconference otherwise assembles by hand: signalling, ICE with a built-in TURN server, simulcast and bandwidth estimation, speaker detection, data messaging, recording and ingest services, client SDKs for every platform and an access-token system. The same APIs run on a server you operate or on LiveKit Cloud.

Which scenario uses it:

  • Huddle (videoconference) uses LiveKit as its "LiveKit variant". The room, the participants, the media routing, the in-call chat and the recording all move from code you wrote (see mediasoup) to configuration and SDK calls. Only a small token endpoint stays on Huddle’s own backend.

  • Pixel Arena (game) can use LiveKit data messages for spectator or voice-chat rooms, but a game server that needs an authoritative tick should use the data channels described in Data Channels.

  • Chatterbox (chat) does not need it, except for an optional voice or video room next to the text chat. Its wire protocol is defined on Message Design and Serialization.

Voice agents (a program that joins a room as a participant and talks to people) are not repeated here: LiveKit Agents covers the agent server, AgentSession, SIP and dispatch. This page covers the room layer underneath, which that page relies on. For the architecture comparison with other SFUs see Topologies: Mesh, SFU and MCU.

The model: rooms, participants, tracks

LiveKit has four nouns that appear in every API.

Term Meaning

Room

A named session. It is created on demand when the first participant joins (or explicitly through the room API) and is deleted after empty_timeout seconds with nobody in it.

Participant

One connection to a room, identified by an identity you choose (unique inside the room) and a server-assigned sid. Browsers, mobile apps, server SDK processes, agents, ingress sessions and SIP callers are all participants.

Track and publication

A track is a media stream (audio, video) with a source such as camera, microphone, screen share or screen share audio. A publication is the room-level record of a track: it exists even when the receiving client is not subscribed.

Subscription

A participant’s decision (automatic by default) to receive a published track. The SFU forwards only subscribed tracks.

A client opens two connections to the server: a WebSocket for signalling (join, publish, subscribe, mute, metadata, data) on the HTTP port, and a WebRTC transport pair for media and data. LiveKit uses a publisher peer connection to send and a subscriber peer connection to receive, so a track added on one side never needs a renegotiation of the other, which avoids the glare problem that perfect negotiation has to solve in a mesh.

sequenceDiagram participant B as Browser (livekit-client) participant T as Huddle token endpoint participant L as LiveKit server participant O as Other participant B->>T: GET /token?room=standup&identity=alice T-->>B: JWT (grants, 10 min TTL) B->>L: WebSocket /rtc?access_token=JWT (join) L-->>B: JoinResponse (participants, ICE servers) B->>L: ICE + DTLS-SRTP over UDP or TCP port B->>L: publish camera and microphone (simulcast) L-->>O: TrackPublished L->>O: forward selected simulcast layer O->>L: data message or RPC L->>B: forward data

DTLS-SRTP is mandatory in WebRTC, so every hop is encrypted, but the hop ends at the SFU: the server decrypts and re-encrypts the media it forwards. That is "encrypted in transit" and not "end-to-end"; the end-to-end option is covered in the encryption section below and on End-to-End Encryption.

Running LiveKit with Docker

The server is a single static binary distributed as the livekit/livekit-server image. Pin a tag instead of latest: the tag v1.13.10 used here is the release of 2026-10-14. A development setup needs one configuration file, one API key pair and a set of published ports.

livekit.yaml: single-node development configuration for Huddle
port: 7880
bind_addresses:
  - ""
redis:
  address: redis:6379
rtc:
  tcp_port: 7881
  port_range_start: 41310
  port_range_end: 41339
  use_external_ip: false
  node_ip: 127.0.0.1
keys:
  huddle-dev-key: change-me-change-me-change-me-32chars
room:
  auto_create: true
  empty_timeout: 300
turn:
  enabled: false
webhook:
  api_key: huddle-dev-key
  urls:
    - http://host.docker.internal:41351/livekit-webhook
logging:
  level: info

The key under keys is the API key and its value is the API secret. Use at least 32 characters for the secret and never reuse the placeholder from this page. Redis is optional for a single node; it is included here because egress and ingress find the server through it, and it is also what lets several LiveKit nodes form a cluster.

compose.yaml: server, Redis, ingress and egress (host ports shifted to avoid clashes)
name: huddle-livekit
services:
  redis:
    image: redis:8.8.3-alpine
  livekit:
    image: livekit/livekit-server:v1.13.10
    command: ["--config", "/etc/livekit.yaml"]
    depends_on: [redis]
    volumes:
      - ./livekit.yaml:/etc/livekit.yaml:ro
    ports:
      - "7880:7880"
      - "7881:7881"
      - "41310-41339:41310-41339/udp"
  ingress:
    image: livekit/ingress:v1.5.0
    depends_on: [livekit]
    volumes:
      - ./ingress.yaml:/etc/ingress.yaml:ro
    environment:
      INGRESS_CONFIG_FILE: /etc/ingress.yaml
    ports:
      - "8080:8080"
      - "1935:1935"
      - "41340:41340/udp"
  egress:
    image: livekit/egress:v1.15.0
    depends_on: [livekit]
    cap_add: [SYS_ADMIN]
    environment:
      EGRESS_CONFIG_FILE: /etc/egress.yaml
    volumes:
      - ./egress.yaml:/etc/egress.yaml:ro
      - ./rec:/out

Source: LiveKit docs: Running LiveKit locally and config-sample.yaml in the livekit repository. The ports entries of the page are the unmodified defaults (7880, 7881 and a UDP range); the example’s own range of thirty UDP ports is enough for fifteen participants because each participant uses two ports.

Ports and addresses

Port Config key Purpose

7880 (TCP)

port

HTTP API and the signalling WebSocket. Put it behind a reverse proxy that terminates TLS: browsers on an HTTPS page require wss://.

7881 (TCP)

rtc.tcp_port

ICE over TCP, the fallback when UDP is blocked.

50000-60000 (UDP)

rtc.port_range_start, rtc.port_range_end

One range of host candidates; each participant uses two ports.

7882 (UDP)

rtc.udp_port

Alternative: multiplex all UDP traffic on one port. When set, the range is not used. This is the easiest option behind a firewall or in Kubernetes.

3478 (UDP), 5349 (TLS)

turn.udp_port, turn.tls_port

The embedded TURN server, off by default. Without a load balancer the TLS port must be 443.

Two settings decide whether a browser can reach the media port, and both are the usual cause of a room that "connects but has no video":

  • The address the server advertises in ICE candidates. use_external_ip: true discovers the public address with STUN and is right for a cloud VM. On a laptop with Docker, set use_external_ip: false and node_ip to 127.0.0.1 (for a browser on the same machine) or to the LAN address (for phones on the same Wi-Fi). Docker’s bridge address is never reachable from the browser.

  • The published UDP range must be the same on both sides (41310-41339:41310-41339/udp), because the advertised candidate carries the container’s port number. Mapping to different host ports breaks ICE silently.

LiveKit’s embedded TURN server issues short-lived credentials to the clients in the join response, so the browser never holds a static password (the same principle explained on ICE, STUN and TURN). Enable it with turn.enabled: true, a domain, tls_port 443 (or 5349 behind a load balancer) and a certificate; the credential lifetime is turn.ttl_seconds, 300 seconds by default and capped at 24 hours. TURN over TLS on 443 is the fallback that gets through restrictive corporate firewalls.

Access tokens

A LiveKit server never authenticates users: it trusts a signed JWT issued by your backend with the API secret. The token carries the participant identity and a video grant describing what that participant may do. The browser asks Huddle’s backend for a token after the user logs in; the API secret never reaches the browser.

token-server.mjs: Huddle token endpoint and room administration (Node.js, livekit-server-sdk 2.19)
import http from 'node:http';
import { AccessToken, RoomServiceClient } from 'livekit-server-sdk';

const API_KEY = process.env.LIVEKIT_API_KEY ?? 'huddle-dev-key';
const API_SECRET = process.env.LIVEKIT_API_SECRET ?? 'change-me-change-me-change-me-32chars';
const LIVEKIT_URL = process.env.LIVEKIT_URL ?? 'ws://localhost:7880';

async function createToken(room, identity, isHost) {
  const at = new AccessToken(API_KEY, API_SECRET, {
    identity,
    name: identity,
    ttl: '10m',
    metadata: JSON.stringify({ app: 'huddle' }),
  });
  at.addGrant({
    room,
    roomJoin: true,
    canPublish: true,
    canSubscribe: true,
    canPublishData: true,
    canUpdateOwnMetadata: true,
    roomAdmin: isHost,
  });
  return at.toJwt(); // a Promise<string> since livekit-server-sdk 2.0
}

http.createServer(async (req, res) => {
  const url = new URL(req.url, `http://${req.headers.host}`);
  if (url.pathname === '/token') {
    const identity = url.searchParams.get('identity');
    if (!identity) { res.writeHead(400).end('identity required'); return; }
    const token = await createToken(url.searchParams.get('room') ?? 'huddle-demo', identity, url.searchParams.get('host') === '1');
    res.writeHead(200, { 'content-type': 'application/json' });
    res.end(JSON.stringify({ url: LIVEKIT_URL, token }));
  } else if (url.pathname === '/rooms') {
    const svc = new RoomServiceClient(LIVEKIT_URL.replace('ws', 'http'), API_KEY, API_SECRET);
    const rooms = await svc.listRooms();
    res.writeHead(200, { 'content-type': 'application/json' });
    res.end(JSON.stringify(await Promise.all(rooms.map(async (r) => ({
      room: r.name,
      participants: (await svc.listParticipants(r.name)).map((p) => p.identity),
    })))));
  } else {
    res.writeHead(404).end();
  }
}).listen(3000);

Source: LiveKit docs: Authentication, tokens and LiveKit server SDK for JavaScript reference. A real endpoint authenticates the caller first (see Authentication and Authorization) and derives identity, the room and the host flag from that session, not from query parameters as this demo does.

The grants that matter for a videoconference:

Grant Effect

roomJoin and room

Permit joining the named room. A token without roomJoin is rejected.

canPublish, canPublishSources

Whether the participant may publish, and which sources (camera, microphone, screen_share, screen_share_audio). Use it for a "viewer" role in a webinar.

canSubscribe

Whether the participant may receive tracks.

canPublishData

Whether data messages, text streams, byte streams and RPC may be sent.

canUpdateOwnMetadata

Whether the participant may change its own name, metadata and attributes. Without it, setAttributes fails with "does not have permission to update own metadata".

roomAdmin

Moderation through the room API (mute, remove, update permissions). Give it to the host only.

roomCreate, roomList, roomRecord, ingressAdmin, hidden

Create or delete rooms, list rooms, use egress, manage ingress, or join invisibly (a recorder, a moderator).

The token also carries attributes (string key-value pairs visible to everyone), metadata and, since agents, a roomConfig that applies only when the token creates the room. When no TTL is given the SDKs default to six hours; a short TTL (ten minutes here) is the revocation mechanism on a self-hosted server, because tokens cannot be revoked there. Expiry applies to the initial connection; the server issues refreshed tokens to connected clients so reconnects keep working. On LiveKit Cloud, removing a participant or changing permissions also revokes the old token for ten minutes.

The client SDK

livekit-client is a TypeScript library for the browser (and React Native, with a separate package for the native WebRTC layer). Its central class is Room. The sequence is always the same: create a Room with options, register event handlers, connect(url, token), then publish.

index.html: a Huddle participant (Room, connect, publish, subscribe, data messages)
<!doctype html>
<meta charset="utf-8">
<title>Huddle (LiveKit)</title>
<div id="grid"></div>
<script src="/livekit-client.js"></script>
<script>
const { Room, RoomEvent } = LivekitClient;

window.joinHuddle = async function joinHuddle(identity, roomName) {
  const { url, token } = await (await fetch(`/token?room=${roomName}&identity=${identity}`)).json();
  const room = new Room({ adaptiveStream: true, dynacast: true });
  window.room = room;
  window.events = [];
  window.messages = [];
  const log = (e) => window.events.push(e);

  room
    .on(RoomEvent.ConnectionStateChanged, (s) => log(`state:${s}`))
    .on(RoomEvent.ParticipantConnected, (p) => log(`joined:${p.identity}`))
    .on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
      log(`subscribed:${participant.identity}:${track.kind}`);
      document.getElementById('grid').append(track.attach()); // <video> or <audio>
    })
    .on(RoomEvent.DataReceived, (payload, participant, kind, topic) => {
      window.messages.push({ from: participant?.identity, topic, text: new TextDecoder().decode(payload) });
    })
    .on(RoomEvent.ActiveSpeakersChanged, (speakers) => log(`speakers:${speakers.map((s) => s.identity)}`));

  await room.connect(url, token);
  await room.localParticipant.setCameraEnabled(true);
  await room.localParticipant.setMicrophoneEnabled(true);
};

window.sendChat = (text) =>
  window.room.localParticipant.publishData(new TextEncoder().encode(text), { reliable: true, topic: 'chat' });
</script>

Source: LiveKit client SDK for JavaScript reference and LiveKit docs: Data messages. The UMD bundle is served from node_modules/livekit-client/dist/livekit-client.umd.js; with a bundler use import { Room, RoomEvent } from 'livekit-client'. Two pages opened in one Chrome with fake devices each ended with the other’s audio and video tracks subscribed and live, the chat message from alice arrived at bob with the topic chat, and the active-speaker event fired for the synthetic beep of the fake microphone.

Connecting and the connection state

room.connect(url, token, options) resolves when signalling and the transport are up. room.state is one of disconnected, connecting, connected, reconnecting and signalReconnecting; RoomEvent.ConnectionStateChanged reports the transitions and RoomEvent.Reconnecting, Reconnected and Disconnected carry the lifecycle. Reconnection is automatic and tries first to resume (same peer connection, same subscriptions) and then to restart ICE; a full reconnect re-publishes the local tracks. Room.prepareConnection(url, token) warms up DNS and TLS while the user is still on the pre-join screen. RoomConnectOptions has autoSubscribe (default true) and rtcConfig for custom ICE servers, and Room options include peerConnectionTimeout, websocketTimeout and maxRetries.

Tracks and devices

localParticipant.setCameraEnabled(true), setMicrophoneEnabled(true) and setScreenShareEnabled(true) capture and publish in one call; the last one needs a user gesture and shows the browser’s picker, as described on Screen Sharing. Capture and publish defaults live in the room options:

  • videoCaptureDefaults (deviceId, facingMode, resolution) and audioCaptureDefaults (echoCancellation, noiseSuppression, autoGainControl).

  • publishDefaults (simulcast, videoCodec, videoEncoding, screenShareEncoding, dtx, red, forceStereo, scalabilityMode).

room.switchActiveDevice('videoinput', deviceId) changes a device without re-publishing, and Room.getLocalDevices('audioinput') lists devices (labels are empty before permission, a property of Media Capture and Devices). Remote audio is subject to the browser’s autoplay rules: call room.startAudio() from a click handler when room.canPlaybackAudio is false, and listen to RoomEvent.AudioPlaybackStatusChanged.

Events a Huddle UI needs: ParticipantConnected/ParticipantDisconnected, TrackPublished, TrackSubscribed, TrackUnsubscribed, TrackMuted/TrackUnmuted, ActiveSpeakersChanged, ParticipantMetadataChanged, ParticipantAttributesChanged, RoomMetadataChanged, MediaDevicesChanged, TrackSubscriptionFailed and DataReceived.

Data: messages, text streams, RPC and attributes

LiveKit offers five ways to move non-media data, all through the SFU and all governed by canPublishData:

API Use in Huddle

publishData(bytes, { reliable, topic, destinationIdentities })

Small, fire-and-forget messages: reactions, "who is typing". reliable: false uses an unordered, unretransmitted channel, the same trade-off as the game channel in Data Channels.

sendText(text, { topic }) and registerTextStreamHandler(topic, handler)

In-call chat. Text streams are chunked, so a long message does not hit the data-channel message size limit.

sendFile(file, { topic }) and registerByteStreamHandler

A file drop to the room or to named participants.

performRpc and room.registerRpcMethod

Request-response between two participants: "raise hand" to the host, with a typed error on timeout.

setAttributes, setMetadata, setName

State that late joiners must also see (hand raised, role). It is synchronised by the server and surfaced by ParticipantAttributesChanged.

Hand raise by RPC, chat by text stream, state by attributes
// host page
room.registerRpcMethod('huddle.raiseHand', async (data) => {
  console.log(`${data.callerIdentity} asks to speak: ${data.payload}`);
  return JSON.stringify({ ok: true });
});
room.registerTextStreamHandler('chat', async (reader, info) => {
  console.log(`${info.identity}: ${await reader.readAll()}`);
});

// participant page
const reply = await room.localParticipant.performRpc({
  destinationIdentity: 'bob',
  method: 'huddle.raiseHand',
  payload: JSON.stringify({ seat: 3 }),
});
await room.localParticipant.sendText('hi bob', { topic: 'chat' });
await room.localParticipant.setAttributes({ hand: 'up' }); // needs canUpdateOwnMetadata

Source: LiveKit docs: Data messages, text streams, RPC and participant attributes. Run between two headless pages, the RPC returned {"ok":true}, the text arrived on the chat topic and the attribute was visible on the other participant. The first attempt without canUpdateOwnMetadata in the token was rejected by the server, which is how that grant entered the token example above.

Quality: simulcast, adaptive stream and dynacast

An SFU does not transcode, so quality adaptation means choosing which encoding to forward. LiveKit’s defaults already do the right thing for a meeting; the options exist to tune it.

  • Simulcast is on by default: the publisher sends several resolutions of the camera (the VideoPresets h180, h360, h720 family) and the server forwards the best one that fits each subscriber’s downlink. For VP9 and AV1 the server uses SVC layers (scalabilityMode, default L3T3_KEY) instead of separate streams. See Codecs, Simulcast and SVC.

  • Adaptive stream (new Room({ adaptiveStream: true })) makes the subscriber report the size of each attached <video> element, so the server sends a 180p layer for a thumbnail and pauses a track whose element is hidden. It works only through track.attach(); if you render a track yourself, set adaptiveStream: { pauseVideoInBackground: false } or control quality manually.

  • Dynacast (dynacast: true) is the publisher side of the same idea: when no subscriber consumes the high layer, the publisher stops encoding it and saves CPU and uplink. With VP9 and AV1 it can pause only the whole stream.

  • Manual control on a RemoteTrackPublication: setSubscribed(false), setVideoQuality(VideoQuality.LOW) and setVideoDimensions({ width, height }). Use them for a "speaker view" with one large tile and many thumbnails when not relying on adaptive stream.

Statistics (getRTCStatsReport() of a local or remote track, and the connection-quality event RoomEvent.ConnectionQualityChanged) feed the quality indicator described on Stats, Quality and Bandwidth.

Webhooks

The server notifies your backend of room events with signed HTTP POSTs. The signature is an Authorization header holding a JWT whose claim is the SHA-256 hash of the exact request body, so the body must be passed to the receiver as the raw text before any JSON parsing.

webhook.mjs: verifying and logging LiveKit webhooks
import http from 'node:http';
import { WebhookReceiver } from 'livekit-server-sdk';

const receiver = new WebhookReceiver('huddle-dev-key', 'change-me-change-me-change-me-32chars');

http.createServer(async (req, res) => {
  if (req.method !== 'POST' || req.url !== '/livekit-webhook') { res.writeHead(404).end(); return; }
  const chunks = [];
  for await (const c of req) chunks.push(c);
  try {
    const event = await receiver.receive(Buffer.concat(chunks).toString('utf8'), req.headers.authorization);
    console.log(event.event, event.room?.name ?? '', event.participant?.identity ?? '');
    res.writeHead(200).end();
  } catch (e) {
    console.error('rejected webhook:', e.message);
    res.writeHead(401).end();
  }
}).listen(3001);

Source: LiveKit docs: Webhooks and the WebhookReceiver class of the server SDK. Joining a room with two browsers produced room_started, participant_joined and track_published lines for each participant, and a request with a forged Authorization header was rejected with 401. Events also exist for track_unpublished, participant_left, room_finished, egress_started, egress_updated, egress_ended and ingress_started/ingress_ended. Webhooks can be delivered more than once and out of order, so handlers must be idempotent.

End-to-end encryption

Because the SFU decrypts SRTP, a compromised or curious server can see the media. LiveKit’s E2EE adds a second layer: the sender encrypts each encoded frame with a key the server never has (the SFrame approach standardised as RFC 9605), and the receivers decrypt after the SFU has forwarded it. The browser hook is an encoded transform; LiveKit runs it in a Web Worker that you supply, which is also why the SDK ships livekit-client.e2ee.worker.js. Chrome’s older createEncodedStreams() and the standard RTCRtpScriptTransform are both handled by the SDK, so the application does not choose between them. The cryptographic and key-management background is on End-to-End Encryption.

e2ee.js: a room encrypted with a shared key
import { Room, ExternalE2EEKeyProvider, isE2EESupported } from 'livekit-client';

if (!isE2EESupported()) throw new Error('This browser cannot encrypt frames end to end');

const keyProvider = new ExternalE2EEKeyProvider();
await keyProvider.setKey(roomKeyFromYourKeyExchange); // never hard-code a key, never send it through LiveKit

const room = new Room({
  encryption: {
    keyProvider,
    worker: new Worker(new URL('livekit-client/e2ee-worker', import.meta.url)),
  },
});
await room.setE2EEEnabled(true);
await room.connect(url, token);

Source: LiveKit docs: End-to-end encryption. In the verification run the worker was served as a plain script (new Worker('/livekit-client.e2ee.worker.js')) and both pages reported isEncrypted for the other participant and subscribed to its tracks; the option is named encryption in livekit-client 2.23.0, while the earlier name e2ee is deprecated but still accepted.

What E2EE does and does not cover:

  • It encrypts audio and video frames, and (when encryption is set) data messages and streams. Signalling, room and participant metadata, names and the fact that a track exists remain readable by the server, which only has TLS between you and it.

  • LiveKit gives you the mechanism and a shared-key provider. Generating, distributing and rotating keys (per room, or per participant with a custom BaseKeyProvider) is your job; a common design sends the key with the token over your own authenticated channel. Everyone who ever held the key can decrypt recordings of the room.

  • Server-side features that need the media in the clear stop working: egress and agents cannot read an encrypted room unless they are given the key, and a participant that joins without the key sees encrypted frames and errors on RoomEvent.EncryptionError. Plan the key hand-off before promising recording.

The React components

@livekit/components-react provides a context-based UI layer on top of livekit-client. It requires React 18 or later and livekit-client 2.20.1 or later as peer dependencies, and ships its CSS in @livekit/components-styles.

HuddleRoom.tsx: a complete Huddle room with the stock components
import '@livekit/components-styles';
import { useState } from 'react';
import { Track } from 'livekit-client';
import {
  LiveKitRoom,
  RoomAudioRenderer,
  ControlBar,
  GridLayout,
  ParticipantTile,
  useTracks,
  useDataChannel,
  useConnectionState,
} from '@livekit/components-react';

function Stage() {
  const tracks = useTracks(
    [
      { source: Track.Source.Camera, withPlaceholder: true },
      { source: Track.Source.ScreenShare, withPlaceholder: false },
    ],
    { onlySubscribed: false },
  );
  return (
    <GridLayout tracks={tracks} style={{ height: 'calc(100vh - 120px)' }}>
      <ParticipantTile />
    </GridLayout>
  );
}

function HandRaise() {
  const [hands, setHands] = useState<string[]>([]);
  const { send } = useDataChannel('hands', (msg) => {
    const who = msg.from?.identity ?? 'unknown';
    setHands((h) => (h.includes(who) ? h : [...h, who]));
  });
  return (
    <button onClick={() => send(new TextEncoder().encode('up'), { reliable: true })}>
      Raise hand ({hands.length} up)
    </button>
  );
}

function Status() {
  return <span>{useConnectionState()}</span>;
}

export function HuddleRoom({ serverUrl, token }: { serverUrl: string; token: string }) {
  return (
    <LiveKitRoom
      serverUrl={serverUrl}
      token={token}
      connect
      audio
      video
      options={{ adaptiveStream: true, dynacast: true }}
      data-lk-theme="default"
    >
      <Status />
      <Stage />
      <HandRaise />
      <ControlBar />
      <RoomAudioRenderer />
    </LiveKitRoom>
  );
}

Source: LiveKit docs: React components reference and components-js on GitHub. The file compiles without errors under tsc --strict against @livekit/components-react 2.9.24, livekit-client 2.23.0 and React 19.

The pieces, from highest to lowest level:

  • LiveKitRoom creates the Room, connects when connect is true, publishes the microphone and camera when audio and video are set, provides the room context and exposes onConnected, onDisconnected, onError, onMediaDeviceFailure and onEncryptionError. Changing token or serverUrl reconnects.

  • VideoConference is a ready-made meeting UI (grid, focus layout, chat, control bar). Use it for a prototype and replace it with your own layout when Huddle needs a lobby or a custom stage; PreJoin is the matching device-check screen.

  • GridLayout, CarouselLayout, FocusLayout, ParticipantTile, ControlBar, Chat and RoomAudioRenderer (which plays all remote audio and handles autoplay) are the building blocks.

  • Hooks: useTracks, useParticipants, useRemoteParticipants, useLocalParticipant, useRoomContext, useConnectionState, useIsSpeaking, useSpeakingParticipants, useChat and useDataChannel.

With Next.js, create the token in a route handler or server action and render HuddleRoom in a client component.

Egress: recording and streaming

Recording happens on the server through a separate service, livekit/egress, which joins the room as a hidden recorder, receives media like any subscriber and writes it with GStreamer. It finds the LiveKit server through Redis and is started through the server API with the API key pair (a token with the roomRecord grant can also use it). The types:

  • Room composite renders a web layout in Chrome inside the egress container, so it is CPU heavy but looks like the meeting; web records any URL.

  • Participant and track composite combine a participant’s, or chosen, audio and video tracks without a browser, which is far cheaper.

  • Track writes one track unmodified (with the passthrough preset for video).

  • Auto egress is configured on the room (CreateRoom) and starts recording when the room starts.

Outputs are MP4 or OGG files, HLS segments and thumbnails to local disk or S3, GCS, Azure and compatible storage, and live RTMP streams to platforms that accept them. The file path template accepts variables such as {room_name} and {time}.

record.mjs: record one participant’s camera and microphone as MP4
import { EgressClient, RoomServiceClient, EncodedFileOutput, EncodedFileType } from 'livekit-server-sdk';

const host = 'http://localhost:7880';
const [key, secret] = ['huddle-dev-key', 'change-me-change-me-change-me-32chars'];

const [participant] = await new RoomServiceClient(host, key, secret).listParticipants('standup');
const audio = participant.tracks.find((t) => t.type === 0).sid;
const video = participant.tracks.find((t) => t.type === 1).sid;

const egress = new EgressClient(host, key, secret);
const output = new EncodedFileOutput({ fileType: EncodedFileType.MP4, filepath: '/out/huddle-{room_name}-{time}.mp4' });

let info = await egress.startTrackCompositeEgress('standup', output, { audioTrackId: audio, videoTrackId: video });
console.log('started', info.egressId);

await new Promise((r) => setTimeout(r, 8000));
info = await egress.stopEgress(info.egressId);
const [done] = await egress.listEgress({ egressId: info.egressId });
console.log(done.status, done.fileResults.map((f) => f.filename)); // EGRESS_COMPLETE once the file is finalised

Source: LiveKit docs: Egress overview and LiveKit docs: Self-hosting egress. Running this against the egress container above for eight seconds produced an MP4 of about one megabyte whose reported duration was the recording length. The current egress documentation groups participant, track-composite and track requests under a generic StartEgress request with a media source; the helper methods used here belong to the same server SDK and remain available in 2.19.2. Egress needs the Linux SYS_ADMIN capability for Chrome and plenty of CPU for a room composite, and a recording must be stopped (or the room ended) for the file to be finalised: never kill the container mid-recording.

Ingress and WHIP

Ingress brings external media into a room: a broadcaster using OBS, a hardware encoder, a media file or an HLS URL appears as a participant. It supports RTMP(S), WHIP (RFC 9725), and pull from HTTP or SRT URLs. WHIP is the standardised HTTP signalling for WebRTC ingest: the encoder POSTs an SDP offer with Content-Type: application/sdp to the endpoint and receives 201 Created with the SDP answer and a Location header for the session; the same mechanism is explained on WHIP, WHEP and Live Streaming. WHEP, its playback counterpart, is still an Internet-Draft; LiveKit viewers receive the stream as a normal subscribed track instead.

whip.mjs: create a WHIP ingress and publish a browser camera to it
import { IngressClient, IngressInput } from 'livekit-server-sdk';

const ingress = new IngressClient('http://localhost:7880', 'huddle-dev-key', 'change-me-change-me-change-me-32chars');
const info = await ingress.createIngress(IngressInput.WHIP_INPUT, {
  name: 'huddle-stage',
  roomName: 'standup',
  participantIdentity: 'stage-camera',
  participantName: 'Stage camera',
  enableTranscoding: false, // forward the encoder's stream untouched
});
// the WHIP endpoint is <whip_base_url>/<streamKey>, for example http://localhost:8080/w/<streamKey>
const endpoint = `http://localhost:8080/w/${info.streamKey}`;

// in a page (or any WHIP encoder):
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
const pc = new RTCPeerConnection();
stream.getTracks().forEach((t) => pc.addTrack(t, stream));
await pc.setLocalDescription(await pc.createOffer());
await new Promise((r) => (pc.iceGatheringState === 'complete' ? r() : (pc.onicegatheringstatechange = () => pc.iceGatheringState === 'complete' && r())));
const resp = await fetch(endpoint, { method: 'POST', headers: { 'content-type': 'application/sdp' }, body: pc.localDescription.sdp });
await pc.setRemoteDescription({ type: 'answer', sdp: await resp.text() }); // resp.status === 201

Source: LiveKit docs: Ingress overview and RFC 9725: WebRTC-HTTP Ingestion Protocol (WHIP). Against the ingress container the POST returned 201 with a Location of the form /w/<streamKey>/<resourceId>, ICE reached connected, and a third page that had joined the room received the track from the participant stage-camera. In that run the url field of the returned ingress was empty because whip_base_url was not set in the ingress configuration; set whip_base_url (and rtmp_base_url) to the public address so that info.url can be handed to the broadcaster. The ingress service needs its own UDP port for WHIP media (published as 41340 above), at least four CPU cores and four gigabytes of memory per instance, and the same Redis as the server. The stream key is a bearer credential: treat it like a password, and delete the ingress when the broadcast ends.

LiveKit Cloud and self-hosting

The SDK and API are identical; only the URL and credentials change. LiveKit Cloud is the managed service: you create a project, copy its URL (a wss:// address on the livekit.cloud domain) and API key pair, and use them where this page uses ws://localhost:7880 and the dev keys.

Self-hosted LiveKit Cloud

Architecture

One node per room; scale by adding nodes behind Redis, with rooms spread across them.

A global mesh of SFUs; each participant connects to the nearest edge.

Egress, ingress

Separate services you deploy and scale.

Included with the project.

Agents, telephony

You run agent servers and bring a SIP trunk.

Managed agent hosting, inference and phone numbers are available.

Observability, token revocation

Your own metrics and short token TTLs.

Dashboard and analytics; revocation of removed participants for ten minutes.

Data residency and cost

Your hosts, your bandwidth bill.

Region pinning; usage-based pricing (see the vendor pricing page).

Source: LiveKit docs: LiveKit Cloud. A single self-hosted node is documented as suitable for roughly three thousand participants on a room, while Cloud lists no per-room limit; multi-node self-hosting is covered on Distributed setup and, for the network side, on Scaling WebRTC and TURN. Start on Cloud or on the Docker setup of this page, and keep all LiveKit URLs and keys in configuration so the switch is a deployment change.

The Huddle LiveKit variant

Huddle has three media modes: a mesh for up to four participants, an SFU mode on mediasoup (see mediasoup) and this variant. The mapping between the hand-built SFU and LiveKit shows what the platform removes:

Concern mediasoup mode (you build it) LiveKit mode (you configure it)

Signalling

A WebSocket protocol for transports, producers and consumers.

Built in: the /rtc WebSocket of the server.

Room and participant state

Maps in the Huddle backend.

The server’s room, participants, attributes and webhooks.

Authentication

Huddle’s own join message.

A signed token with grants from one endpoint.

Simulcast and layer choice

Router settings and consumer.setPreferredLayers.

Default behaviour plus adaptiveStream and dynacast.

TURN

A separate coturn with an ephemeral secret.

The embedded TURN server, or coturn through rtcConfig.

Chat, hand raise

Messages on the signalling socket.

sendText, performRpc, attributes.

Recording

A PlainTransport and FFmpeg.

Egress service.

The flow of a Huddle LiveKit session is: the user logs in; the page asks /token for a token for room standup; the page connects with adaptiveStream and dynacast; a pre-join screen calls Room.prepareConnection and checks devices; the host’s token has roomAdmin; chat is a text stream on topic chat; hand raise is an attribute plus an RPC to the host; the host starts a track-composite egress; and a webinar viewer receives a token with canPublish: false. Huddle’s backend keeps only the token endpoint, the room-creation policy and the webhook receiver.

Security

  • The API secret is a root credential. Whoever has it can mint tokens for any identity and room and drive the room API. Keep it in a secret store, rotate it by listing two keys under keys during the transition, and never ship it to a browser or a mobile app.

  • Tokens are bearer credentials. Issue them after authentication, with the smallest grants and a short TTL, and put the identity and room into the token yourself, not from client input. A token in the WebSocket URL ends up in proxy logs, which is one more reason for a short TTL (see Authentication and Authorization).

  • Terminate TLS in front of the server. The signalling port must be reached as wss:// from an HTTPS page, and getUserMedia requires a secure context anyway. Restrict the UDP range and the TURN ports in the firewall to exactly those listed above.

  • Verify webhooks with the SDK receiver and the raw body, and make handlers idempotent.

  • Treat stream keys, egress and room-API permissions as admin power. Do not give roomRecord, roomCreate or ingressAdmin to meeting participants. A hidden participant (hidden: true) can observe a room without being listed, so only issue it to trusted back-end processes.

  • E2EE is not automatic. Without encryption the server sees the media; with it the key distribution is yours.

  • Cross-origin and iframes. Embedding the room in an iframe needs Permissions-Policy (camera, microphone, display-capture) on the embedding page.

  • Resource limits. Cap participants (room.max_participants), and rate-limit the token endpoint so that it cannot be used to create unbounded rooms.

Common errors and pitfalls

Symptom Cause and fix

Connects, but no remote video, ICE stays checking

The advertised address or UDP ports are wrong. Set node_ip or use_external_ip, publish the same UDP range on host and container, open the firewall, or enable the TCP fallback port 7881.

could not establish signal connection on HTTPS

The page loaded over HTTPS uses ws://. Use wss:// behind a TLS-terminating proxy that upgrades WebSockets and has a long read timeout.

invalid token or token expired

Wrong key pair, clock skew, or a TTL shorter than the time between fetching and connecting. Refetch the token on reconnect instead of caching it.

does not have permission to update own metadata and similar

The grant is missing: canPublish, canPublishData or canUpdateOwnMetadata.

await at.toJwt() is missing, and the token is [object Promise]

In livekit-server-sdk 2.x toJwt() returns a promise; code written for 1.x did not await it.

No audio in a remote tile

Autoplay policy. Render RoomAudioRenderer or call room.startAudio() from a click.

Video stays at 180p or is black in a custom layout

Adaptive stream cannot see the element because the track was not rendered with attach(), or the element is hidden or zero-sized. Disable adaptive stream or fix the layout.

Webhook always rejected

The body was parsed and re-serialised before verification, or a proxy altered it. Pass the raw text.

E2EE participant sees garbage or errors

Different keys, a key set after connecting, or a participant without setE2EEEnabled(true). Set the key before connecting and enable E2EE on every client.

Egress stuck in EGRESS_STARTING or fails

The egress service cannot reach Redis or the server, lacks SYS_ADMIN, or has too little CPU for a room composite. Check its container logs.

Ingress session works but info.url is empty

whip_base_url or rtmp_base_url is not configured on the ingress service.

Version and history notes

  • Server: 1.13.10 (2026-10-14) is the baseline; releases are frequent and the image tags v1.13, v1.13.10 and latest are published, so pin a patch tag. Clients negotiate a protocol version, and the project’s guidance is to keep server and client SDKs reasonably close.

  • livekit-client 2.x replaced the earlier 1.x API: room.localParticipant is the same, but several options moved (e2ee to encryption, per-participant registerRpcMethod to room.registerRpcMethod), and text and byte streams (sendText, sendFile) were added beside publishData.

  • livekit-server-sdk 2.x made AccessToken.toJwt() asynchronous. Its egress documentation was reorganised around a single StartEgress request with source and output descriptions.

  • @livekit/components-react 2.x is the current line; it declares livekit-client 2.20.1 or later as a peer.

  • Ingress and egress are versioned separately from the server (1.5.0 and 1.15.0 at the time of writing).

  • Standards: LiveKit builds on the W3C WebRTC recommendation, JSEP (RFC 9429), BUNDLE (RFC 9143), WHIP (RFC 9725) and, for end-to-end encryption, the SFrame specification (RFC 9605). The transport-wide congestion control feedback it relies on was an Internet-Draft that is widely implemented; RFC 8888 is the published congestion-control feedback format.