A complete WebRTC stack for Node.js
A complete WebRTC stack for Node.js servers, with a browser-compatible API. Use the same RTCPeerConnection, RTCDataChannel, and RTCRtpTransceiver you know from the browser — running natively on the server, with first-class support for production deployments.
Build SFUs, MCUs, recording servers, WHIP/WHEP endpoints, SIP gateways, conference backends, and any other server-side WebRTC system on a single, complete stack.
- Features
- Install
- Quick start
- API
- Architecture
- ICE modes: lite vs full
- Codec support
- Loss resilience
- Use cases
- Debugging
- Interoperability
- RFC compliance
- Sponsors
- License
- W3C WebRTC API —
RTCPeerConnection,RTCRtpSender/Receiver/Transceiver,RTCDataChannel,RTCDtlsTransport,RTCIceTransport,RTCSctpTransport,RTCCertificate,RTCDTMFSender - Full media stack — VP8, VP9, H.264, H.265, AV1 video; Opus audio; with packetization, jitter buffer, and a three-layer loss-recovery stack (NACK/RTX + RED + FlexFEC)
- ICE — full and lite modes, trickle ICE, ICE restart, host/srflx/relay candidates
- NAT traversal beyond the browser — resolves browsers' mDNS
.localcandidates (draft-ietf-mmusic-mdns-ice-candidates) and gathers gateway-assisted candidates via UPnP-IGD/NAT-PMP/PCP — direct P2P connections that a browser alone can't make, often with no STUN/TURN at all - DTLS-SRTP — AEAD AES-128-GCM and AES-256-GCM (RFC 7714) with AES-128-CM fallback; full handshake with certificate generation and reuse; keys derived via the RFC 5764 DTLS exporter
- Loss resilience — NACK/RTX retransmission, RED redundant audio (RFC 2198), and FlexFEC forward error correction (flexfec-03), all negotiated and wired automatically
- DataChannel — SCTP over DTLS, ordered/unordered, reliable/unreliable, full DCEP (RFC 8832)
- Simulcast — RFC 8853 with RID and
a=ssrc-group:SIMfallback - Bandwidth estimation — REMB and transport-cc feedback, delay-based estimator
- DTMF — full
RTCDTMFSenderper W3C:insertDTMF,ontonechange, RFC 4733 events on the audio stream — ready for SIP gateways - Active speaker detection — RFC 6464 ssrc-audio-level in both directions, exposed via
getSynchronizationSources() - Statistics — full W3C
getStats()with all RTC stat types - Server-grade routing —
WebRTCRouterfor shared UDP port (RFC 9443), supporting many peers on a single port like 443 - Pure JavaScript — no native bindings; runs anywhere Node runs
npm install webrtc-serverRequires Node.js 18 or newer.
import { RTCPeerConnection } from 'webrtc-server';
const pc = new RTCPeerConnection();
pc.ondatachannel = (event) => {
const channel = event.channel;
channel.onmessage = (e) => channel.send('echo: ' + e.data);
};
// Wire up signaling — exchange offer/answer with your client over WebSocket,
// HTTP (WHIP), or whatever transport you prefer.
pc.onicecandidate = (e) => {
if (e.candidate) signaling.send({ candidate: e.candidate });
};
signaling.on('offer', async (offer) => {
await pc.setRemoteDescription(offer);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
signaling.send({ answer });
});
signaling.on('candidate', async (candidate) => {
await pc.addIceCandidate(candidate);
});That's it. Signaling is your responsibility (always is, by spec); everything else mirrors browser behavior.
Receive the browser's webcam stream and forward it elsewhere via the media-processing companion library:
import { RTCPeerConnection } from 'webrtc-server';
import { MediaRecorder, MediaStream } from 'media-processing';
import fs from 'node:fs';
const pc = new RTCPeerConnection();
pc.ontrack = (event) => {
const track = event.track;
console.log('got', track.kind, 'track');
if (track.kind === 'video') {
// track is a real MediaStreamTrack — pipe it to a recorder, transcoder,
// file, or RTMP egress using media-processing's tools.
const recorder = new MediaRecorder(new MediaStream([track]));
recorder.ondataavailable = (e) => fs.appendFileSync('out.webm', e.data);
recorder.start();
}
};
// ...signaling...For production servers handling many peers, you typically want all WebRTC traffic on a single UDP port (often 443 to bypass restrictive firewalls). Use WebRTCRouter:
import { RTCPeerConnection, WebRTCRouter } from 'webrtc-server';
const router = new WebRTCRouter({
announcedAddresses: ['203.0.113.5'], // your server's public IP
});
await router.listen(443);
// Every PeerConnection shares the router's socket and is demultiplexed
// by 5-tuple (fast path) or STUN USERNAME (first packet of a new peer).
function handleNewClient(signaling) {
const pc = new RTCPeerConnection({ router });
// ...usual signaling glue...
}The router handles many concurrent peers on one port. See WebRTCRouter for the shared-port mode (coexistence with TURN/QUIC on 443 per RFC 9443).
The API follows the W3C WebRTC specification as implemented by browsers. If something works in Chrome/Firefox/Safari, it should work here. This section highlights what's specific to webrtc-server; for general WebRTC API documentation, MDN's RTCPeerConnection page is a great reference.
import { RTCPeerConnection } from 'webrtc-server';
const pc = new RTCPeerConnection(configuration);All standard methods are implemented: createOffer, createAnswer, setLocalDescription, setRemoteDescription, addIceCandidate, addTrack, removeTrack, addTransceiver, getSenders, getReceivers, getTransceivers, createDataChannel, getStats, getConfiguration, setConfiguration, restartIce, close.
All standard events are emitted: ontrack, ondatachannel, onicecandidate, onicecandidateerror, onnegotiationneeded, onsignalingstatechange, oniceconnectionstatechange, onicegatheringstatechange, onconnectionstatechange.
The static method RTCPeerConnection.generateCertificate(algorithm) produces an RTCCertificate you can pass via configuration.certificates to reuse a long-lived identity across connections — useful for servers where re-generating certificates per connection is wasteful.
Legacy createOffer options are honored for older codebases: offerToReceiveAudio / offerToReceiveVideo behave per JSEP §5.1 (true ensures a receive-capable transceiver exists; false strips the receive direction from existing ones).
RTCRtpSender.getCapabilities(kind) / RTCRtpReceiver.getCapabilities(kind) return the real codec set — including per-codec sdpFmtpLine sourced from the same tables used in negotiation, so capabilities and on-the-wire SDP can never drift apart. Session descriptions follow RFC 3264 §8: the o= session version increments on every generated offer/answer.
The constructor accepts the standard W3C RTCConfiguration plus a few server-oriented extensions:
const pc = new RTCPeerConnection({
// ── Standard W3C ──
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'turn:turn.example.com', username: 'u', credential: 'p' },
],
iceTransportPolicy: 'all', // or 'relay'
bundlePolicy: 'max-bundle', // 'balanced' | 'max-bundle' | 'max-compat'
rtcpMuxPolicy: 'require',
iceCandidatePoolSize: 0,
certificates: [cert], // RTCCertificate from generateCertificate
// ── Server-side extensions ──
router: webRTCRouter, // share a UDP port — see WebRTCRouter
socket: existingDgramSocket, // bring your own UDP socket
socket6: existingIpv6Socket,
announcedAddresses: ['1.2.3.4'], // public IPs to advertise as host candidates
mode: 'lite', // ICE mode — see below
mdns: true, // resolve .local candidates — auto by mode, see below
portMapping: { description: 'MyApp' }, // gateway-assisted gathering — auto by mode
cert: pemString, key: pemString, // raw cert/key (alternative to certificates)
});When router or any socket is supplied, the connection auto-defaults to ICE lite mode. When no socket is supplied, it acts as a full-ICE client.
Tracks come from the companion media-processing package, which provides MediaStreamTrack, MediaStream, getUserMedia, encoders, decoders, and file/network sources:
import { getUserMedia } from 'media-processing';
const stream = await getUserMedia({ video: true, audio: true });
for (const track of stream.getTracks()) {
pc.addTrack(track, stream);
}You can also build tracks programmatically (frame generators, file readers, RTMP/RTSP ingest) — see the media-processing docs.
Codec preferences via transceiver.setCodecPreferences() are supported. Simulcast is configured via the sendEncodings parameter to addTransceiver:
pc.addTransceiver('video', {
direction: 'sendonly',
sendEncodings: [
{ rid: 'high', maxBitrate: 2_000_000 },
{ rid: 'med', maxBitrate: 500_000, scaleResolutionDownBy: 2 },
{ rid: 'low', maxBitrate: 150_000, scaleResolutionDownBy: 4 },
],
});Standard W3C interface. Both negotiated and in-band channels:
const dc = pc.createDataChannel('chat', {
ordered: true,
maxRetransmits: 3,
});
dc.onopen = () => dc.send('hello');
dc.onmessage = (e) => console.log(e.data);Backed by a full SCTP implementation: ordered/unordered delivery, reliable/partial-reliable, configurable max message size up to 256 KiB by default.
sender.dtmf returns a live RTCDTMFSender on audio senders once telephone-event is negotiated (it's in the default audio codec set, so any standards-following peer will accept it):
const sender = pc.getSenders().find((s) => s.track && s.track.kind === 'audio');
if (sender.dtmf.canInsertDTMF) {
sender.dtmf.ontonechange = (e) =>
console.log(e.tone === '' ? 'done' : 'playing ' + e.tone);
sender.dtmf.insertDTMF('123#', 100, 70); // tones, duration ms, gap ms
}Semantics match the browser: tone characters 0-9 A-D * #, , inserts a 2-second pause, invalid characters throw InvalidCharacterError, duration is clamped to 40–6000 ms, and calling insertDTMF again replaces the pending buffer. On the wire the events ride the audio stream's own SSRC and sequence space per RFC 4733 — fixed event timestamp, growing duration field, triple-sent end packet — so SIP gateways and telephony peers interoperate cleanly.
Every outgoing audio packet carries the RFC 6464 ssrc-audio-level header extension (computed per frame before encoding), and incoming levels are cached per SSRC. Read them with the standard API:
setInterval(() => {
for (const receiver of pc.getReceivers()) {
if (receiver.track.kind !== 'audio') continue;
for (const src of receiver.getSynchronizationSources()) {
// audioLevel is the W3C linear scale: 0.0 (silence) .. 1.0 (0 dBov)
if (src.audioLevel > 0.1) console.log('speaking:', src.source);
}
}
}, 200);This is how "who is talking" indicators work without decoding any audio — which also makes it the right primitive for SFU-side speaker selection.
const report = await pc.getStats();
for (const [id, stat] of report) {
if (stat.type === 'inbound-rtp') console.log(stat.kind, stat.packetsLost);
}All standard stat types are emitted: inbound-rtp, outbound-rtp, remote-inbound-rtp, remote-outbound-rtp, media-source, media-playout, codec, transport, candidate-pair, local-candidate, remote-candidate, certificate, data-channel, peer-connection, sctp-transport.
Shared-port routing for multi-peer servers. Two modes:
Mode 1: Router owns the socket (simple case — no other protocols on the port)
import { WebRTCRouter, RTCPeerConnection } from 'webrtc-server';
const router = new WebRTCRouter({
announcedAddresses: ['203.0.113.5'],
});
await router.listen(443);
const pc = new RTCPeerConnection({ router });
// All peers created with { router } share the one UDP socket on :443.Mode 2: External socket (coexist with TURN/QUIC on the same port — RFC 9443)
import dgram from 'node:dgram';
import { WebRTCRouter, RTCPeerConnection } from 'webrtc-server';
const socket = dgram.createSocket('udp4');
socket.bind(443);
const router = new WebRTCRouter({
socket,
announcedAddresses: ['203.0.113.5'],
});
socket.on('message', (msg, rinfo) => {
if (router.dispatch(msg, rinfo)) return; // routed to a WebRTC peer
// ...else hand off to your TURN or QUIC handler...
});
const pc = new RTCPeerConnection({ router });Inbound packets are routed by 5-tuple (cached fast path) or by parsing the STUN USERNAME attribute on the first packet of a new peer (slow path). The router survives ICE restarts and cleans up automatically when peers close.
router.getPeerCount(); // active peer count
router.hasSession(rinfo); // is this 5-tuple a known peer?
router.close(); // closes router-owned sockets onlyFor advanced cases — modifying SDP between negotiation steps (codec munging, header extension filtering, simulcast config) — the SDP module is available:
import { SDP } from 'webrtc-server';
const parsed = SDP.parse(offerSdp);
parsed.media[0].rtp = parsed.media[0].rtp.filter((c) => c.codec !== 'PCMU');
const modified = SDP.write(parsed);This is an advanced API and isn't required for normal usage.
webrtc-server is built on a stack of focused libraries, each independently usable:
┌─────────────────────────────────────────────────┐
│ webrtc-server (this package) │
│ RTCPeerConnection, signaling, SDP, glue │
└─────────┬──────────────┬──────────────┬─────────┘
│ │ │
┌──────▼──────┐ ┌─────▼────┐ ┌───────▼──────┐
│ rtp-packet │ │ lemon-tls│ │ turn-server │
│ (RTP/RTCP, │ │ (DTLS) │ │ (ICE agent, │
│ SRTP) │ │ │ │ STUN/TURN) │
└──────┬──────┘ └──────────┘ └──────────────┘
│
┌──────▼─────────────┐
│ media-processing │
│ (codecs, tracks, │
│ getUserMedia) │
└────────────────────┘
Each lower-level library is published separately, so you can build narrower products (RTP parser, RTSP server, SIP softphone) without pulling the whole WebRTC stack.
WebRTC peers come in two flavors:
- Full ICE (the browser default) — gathers host/srflx/relay candidates, runs the full connectivity check matrix, sends keepalives. Right for clients behind NATs.
- ICE Lite (the standard server choice) — assumes a public IP, skips connectivity-check initiation, just responds to peer probes. Cheaper, simpler, and the right choice for most server deployments.
webrtc-server picks the right mode automatically based on configuration:
| Scenario | Resolved mode |
|---|---|
new RTCPeerConnection() (no socket/router) |
'full' |
new RTCPeerConnection({ router }) |
'lite' |
new RTCPeerConnection({ socket }) |
'lite' |
Explicit { mode: 'full' | 'lite' } |
as specified |
You can override by passing mode explicitly.
A browser has exactly three ways to reach a peer: host, STUN srflx, TURN relay. webrtc-server ships two more capabilities — enabled automatically where they make sense — that frequently turn "relay or fail" into a direct connection:
mDNS candidate resolution. Every modern browser conceals its LAN
addresses behind .local names (draft-ietf-mmusic-mdns-ice-candidates).
A Node peer that can't resolve them silently loses every direct LAN path —
same-office calls end up crossing the internet through a relay.
webrtc-server resolves them like a browser would (with the draft's safety
rules: UUID-only names, single-IP rule), and a client can also conceal its
own addresses with mdns: { register: true }.
Port-mapping assisted gathering. For full-ICE clients (Electron, CLI,
IoT — the P2P case), the stack asks the gateway for a UDP forwarding rule
via UPnP-IGD, NAT-PMP or PCP, and advertises the external address as an
srflx candidate. Unlike a STUN mapping, a forwarding rule is reachable by
any peer — it keeps working behind symmetric NAT, where classic srflx
fails and browsers fall back to TURN. It's the same NAT traversal
qBittorrent and Syncthing enable by default, and it means two home users
can often connect directly, with zero servers in either the media or
the discovery path. Exposure is no wider than ICE already implies: the
mapped port leads only to the ICE socket, which drops anything that isn't
credentialed STUN or the session's DTLS. Mappings carry finite auto-renewed
leases, are labeled in the router's UI (set portMapping: { description }),
and are removed on close. CGNAT is detected up front and reported via
onicecandidateerror instead of producing useless candidates.
Defaults follow the resolved ICE mode — zero configuration for the common cases, explicit config always wins:
| full (client) | lite (server with router/socket) |
|
|---|---|---|
mdns (resolve .local) |
on | off |
portMapping |
on | off |
mdns: { register } |
opt-in | — |
// Electron P2P client — everything above just works:
const pc = new RTCPeerConnection({ iceServers: [...] });
// Opt out, or customize:
const pc2 = new RTCPeerConnection({ portMapping: false });
const pc3 = new RTCPeerConnection({
mdns: { register: true }, // conceal our host IPs too
portMapping: { description: 'MyApp' }, // what the router UI shows
});Both capabilities live in turn-server's
ICE agent as lazy optional dependencies (mdns-local, port-mapper) —
an SFU deployment that never uses them never loads them.
| Codec | Direction | Notes |
|---|---|---|
| VP8 | send + recv | Full simulcast support |
| VP9 | send + recv | Including SVC layers |
| H.264 | send + recv | Constrained Baseline + Main profiles |
| H.265 | send + recv | RFC 7798; negotiated with Safari natively |
| AV1 | send + recv | With Dependency Descriptor |
| Opus | send + recv | Stereo, in-band FEC, DTX — negotiated fmtp is applied to the encoder |
| RED | send + recv | RFC 2198 redundant audio, offered by default (Chrome-compatible) |
| FlexFEC | send + recv | flexfec-03, own SSRC + a=ssrc-group:FEC-FR |
| DTMF | send + recv | RFC 4733 — full RTCDTMFSender on send, event parsing on receive |
Frame production and consumption is handled by the media-processing companion package — webrtc-server itself handles the wire format, encryption, and negotiation.
Three complementary recovery layers run automatically once negotiated — no configuration needed:
| Layer | Mechanism | Cost | Recovers |
|---|---|---|---|
| NACK / RTX (RFC 4585/4588) | Receiver requests retransmission; sender replays from a ring buffer on the RTX SSRC | One RTT | Any loss, if the packet is still buffered |
| RED (RFC 2198, audio) | Each packet carries the previous Opus frame as redundancy | +1 frame of bitrate | Single losses with zero added latency |
| FlexFEC (flexfec-03, video) | XOR repair packet per group on a dedicated SSRC, paired via a=ssrc-group:FEC-FR |
~1/groupSize bitrate overhead | One loss per group, zero RTT |
Recovered packets are re-injected through the normal receive path, so jitter buffering, NACK bookkeeping, and statistics all see them as regular media. FEC packets themselves are excluded from retransmission and congestion-control accounting, matching libwebrtc behavior. Interop verified against the formats Chrome ships (RED at PT 63 with fmtp <pt>/<pt>, flexfec-03 with repair-window).
- SFU and MCU media servers — full control over routing, simulcast layer selection, and per-peer policy
- Recording servers — server-side recording of WebRTC sessions to file, S3, or other storage
- WHIP/WHEP endpoints — HTTP-based WebRTC ingest and egress
- Live streaming ingest — receive browser streams and forward to RTMP, HLS, or other peers
- Conference backends — build conferencing logic on a WebRTC peer that speaks the same API as the participants
- SIP gateways — bridge WebRTC ↔ SIP/RTP, with DTMF support
- IoT and remote-camera servers — low-latency video from edge devices to browsers
- Browser automation testing — drive WebRTC stacks from Node test runners
Set WEBRTC_DEBUG=1 to enable diagnostic output covering signaling state transitions, RTP/RTCP routing decisions, ICE candidate gathering, DTLS handshake progress, SRTP profile selection (answering 0x0007 / SRTP session ready), FlexFEC encoder/decoder lifecycle, and SCTP send paths:
WEBRTC_DEBUG=1 node server.jsThis produces verbose output and is intended for development; leave it off in production.
Verified against every implementation in sipsorcery/webrtc-interop, in both the client and server roles.
Peer Connection Test
| client | server | |
|---|---|---|
| pion (Go) | ✔ | ✔ |
| aiortc (Python) | ✔ | ✔ |
| libdatachannel (C++) | ✔ | ✔ |
| SIPSorcery (C#) | ✔ | ✔ |
| webrtc-rs (Rust) | ✔ | ✔ |
| werift (TypeScript) | ✔ | ✔ |
| libwebrtc (Chromium m132) | ✔ | — |
| GStreamer | ✔ | — |
| Janus | ✔ | — |
Data Channel Echo Test
| client | server | |
|---|---|---|
| libdatachannel | ✔ | ✔ |
| SIPSorcery | ✔ | ✔ |
| werift | ✔ | ✔ |
Also verified against Chrome directly via the browser test page.
- RFC 2198 — RTP Payload for Redundant Audio Data (RED)
- RFC 3264 — Offer/Answer Model (including o= version discipline)
- RFC 3550 — RTP: A Transport Protocol for Real-Time Applications
- RFC 3711 — Secure Real-time Transport Protocol (SRTP)
- RFC 4585 — Extended RTP Profile for RTCP-Based Feedback (NACK, PLI)
- RFC 4588 — RTP Retransmission Payload Format (RTX)
- RFC 4733 — RTP Payload for DTMF Digits
- RFC 4566 — Session Description Protocol (SDP)
- RFC 5245 / RFC 8445 — Interactive Connectivity Establishment (ICE)
- draft-ietf-mmusic-mdns-ice-candidates — mDNS
.localcandidate resolution and concealment (the mechanism browsers ship; never published as an RFC) - RFC 5763 / RFC 5764 — DTLS-SRTP key exchange
- RFC 5761 — Multiplexing RTP and RTCP
- RFC 6184 — RTP Payload Format for H.264
- RFC 6347 — DTLS 1.2
- RFC 6464 — RTP Header Extension for Client-to-Mixer Audio Level
- RFC 7587 — RTP Payload Format for Opus
- RFC 7714 — AES-GCM for SRTP
- RFC 7741 — RTP Payload Format for VP8
- RFC 7798 — RTP Payload Format for H.265/HEVC
- RFC 8285 — RTP header extensions (two-byte format)
- RFC 8829 — JSEP (offer/answer model)
- RFC 8831 — WebRTC Data Channels
- RFC 8832 — DCEP (Data Channel Establishment Protocol)
- RFC 8841 — SCTP-Based Media Transport
- RFC 8852 — RID (RTP Stream Identifier)
- RFC 8853 — Simulcast (with
a=simulcastand RID) - RFC 9443 — Multiplexing scheme updates for shared UDP ports
- draft-ietf-payload-flexible-fec-scheme-03 — FlexFEC (the wire format Chrome ships)
- AOMedia AV1 RTP Specification
webrtc-server is an evenings-and-weekends project. Building a complete WebRTC stack in pure JavaScript is a significant undertaking — support development via GitHub Sponsors or by sharing the project.
Apache License 2.0
Copyright © 2026 colocohen
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.