Pion and aiortc
|
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 Pion WebRTC 4.2.25 (module github.com/pion/webrtc/v4, Go 1.25), aiortc 1.15.0 (with aioice 0.10.2 and PyAV 17.1.0, Python 3.12), node-datachannel 0.33.4 (Node.js 24, libdatachannel inside) and the W3C WebRTC 1.0 Recommendation (2025-03-13 edition) with JSEP in RFC 9429. The examples were run on macOS with Docker 29 and Chrome 155 started headless with fake camera and microphone devices.
A browser is only one kind of WebRTC peer. Anything that speaks ICE, DTLS, SRTP and SCTP can be a peer, and three libraries make that practical on the server: Pion (Go), aiortc (Python) and libdatachannel through node-datachannel (Node.js). This page shows what each one gives you, what it leaves to you, and how to run it behind Docker and NAT. The browser-side API is covered in Peer Connections and SDP; the data channel API and its options are covered in Data Channels, which already shows short server-peer snippets for all three libraries and are not repeated here.
Why put a peer on the server
Which scenario needs a non-browser peer:
-
Pixel Arena (game) uses a server peer with an unreliable data channel: the authoritative game loop runs on the server, the browser opens an unordered channel with no retransmissions to it, and snapshots flow back over the same channel. This is the case described in Multiplayer Game Architecture. Pion and node-datachannel are the usual choices, because the game server is already a Go or Node.js process.
-
Huddle (videoconference) uses a server peer for things a browser cannot do alone: a recording bot that joins a call and writes it to disk, a reflector used to test a client against a known-good peer, a transcription or moderation bot, and the SFU itself in Topologies: Mesh, SFU and MCU. aiortc is the quickest route to a recording or computer-vision bot; Pion is the base to build forwarding on. LiveKit’s server is written in Go on top of Pion (its README says so), see LiveKit.
-
Chatterbox (chat) has no need for one; see Comparison & Decision Guide.
The three libraries do not make you an SFU on their own. They give you a standards-compliant peer connection and raw access to what arrives. Everything a media server adds — room state, subscriptions, bandwidth estimation policy, simulcast layer selection, scaling across machines — is yours to write, which is why mediasoup and LiveKit exist. Choose a library when the peer is small or unusual; choose a media server when you need rooms.
The shape of every server peer
All three examples below follow the same life cycle, and it is the same one a browser follows:
Three design choices shape the code:
-
Non-trickle signalling. The browser waits for
iceGatheringStateto reachcompletebefore it sends the offer, and the server waits for its own gathering before it answers, so one POST carries everything and no candidate messages are needed. It costs a little start-up time and is ideal for a server peer with a handful of host candidates. For trickle ICE over a WebSocket see Signaling and Perfect Negotiation. aiortc gathers all its candidates insidesetLocalDescriptionand does not trickle, which it calls half-trickle. -
The server answers. The browser is always the offerer, so the server needs no media devices and decides nothing about the transceivers: it adds what it wants to send before creating the answer.
-
The server owns a UDP address. A browser behind NAT can reach out, but the server must be reachable by ICE, which is the part that goes wrong in containers (see Docker, NAT and the UDP port range).
All three servers speak the same tiny contract so one browser page drives them: POST /offer with {type, sdp} returns {type, sdp}. It is deliberately not the Chatterbox protocol; the Chatterbox envelope is defined in Message Design and Serialization and is for WebSocket chat servers.
The browser half
One page is the test client for all three servers. It opens the unreliable arena data channel, sends a player input every 50 ms, keeps only snapshots newer than the last one it saw (the receive rule for unordered channels), and exposes counters so a script can read the outcome. With echo=1 it sends its camera as sendrecv so a server can send it back, with audio=1 it also sends the microphone, and with data=1 it creates no media at all.
<!doctype html>
<meta charset="utf-8">
<title>Pixel Arena probe</title>
<video id="echo" autoplay muted playsinline></video>
<script type="module">
const params = new URLSearchParams(location.search);
const wantAudio = params.get("audio") === "1";
const results = (window.results = { messages: 0, lastTick: 0, server: null });
const dataOnly = params.get("data") === "1";
const stream = dataOnly ? new MediaStream() : await navigator.mediaDevices.getUserMedia({ video: { width: 320, height: 240 }, audio: wantAudio });
const pc = new RTCPeerConnection({ iceServers: [] });
for (const track of stream.getTracks()) pc.addTransceiver(track, { direction: params.get("echo") === "1" ? "sendrecv" : "sendonly", streams: [stream] });
pc.ontrack = ({ streams: [s] }) => { document.getElementById("echo").srcObject = s; };
const arena = pc.createDataChannel("arena", { ordered: false, maxRetransmits: 0 });
arena.onopen = () => {
let seq = 0;
const timer = setInterval(() => arena.send(JSON.stringify({ seq: ++seq, dx: 1, dy: 0 })), 50);
arena.onclose = () => clearInterval(timer);
};
arena.onmessage = ({ data }) => {
const msg = JSON.parse(data);
if (msg.type === "snapshot" && msg.tick > results.lastTick) { results.lastTick = msg.tick; results.messages++; }
if (msg.type === "rtp") results.server = msg;
};
await pc.setLocalDescription(await pc.createOffer());
await new Promise((resolve) => {
if (pc.iceGatheringState === "complete") return resolve();
pc.onicegatheringstatechange = () => pc.iceGatheringState === "complete" && resolve();
});
const res = await fetch("/offer", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(pc.localDescription) });
if (!res.ok) throw new Error(`offer rejected: ${res.status}`);
const answer = await res.json();
results.id = answer.id;
await pc.setRemoteDescription(answer);
pc.onconnectionstatechange = () => { results.state = pc.connectionState; };
window.finish = async () => {
const stats = await pc.getStats();
for (const r of stats.values()) if (r.type === "inbound-rtp" && r.kind === "video") results.framesDecoded = r.framesDecoded;
const v = document.getElementById("echo");
results.echoSize = [v.videoWidth, v.videoHeight];
return results;
};
window.ready = true;
</script>
It is driven by a Node.js script, drive.mjs, that starts Chrome through puppeteer-core with --use-fake-device-for-media-stream --use-fake-ui-for-media-stream (a synthetic camera and microphone and no permission prompt), opens the page from the server under test on 127.0.0.1, which is a secure context and so allows getUserMedia, waits a few seconds and reads window.finish(). The --disable-features=WebRtcHideLocalIpsWithMdns flag makes Chrome offer real host addresses instead of .local names, which a container cannot resolve.
import puppeteer from "puppeteer-core";
const [base, query = "", settle = "4000"] = process.argv.slice(2);
const browser = await puppeteer.launch({
executablePath: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
headless: "new",
args: ["--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream", "--no-first-run",
"--disable-features=WebRtcHideLocalIpsWithMdns", "--autoplay-policy=no-user-gesture-required"],
});
try {
const page = await browser.newPage();
page.on("console", (m) => console.log("page:", m.text()));
page.on("pageerror", (e) => console.log("pageerror:", e.message));
await page.goto(`${base}/arena.html${query}`);
await page.waitForFunction("window.ready === true", { timeout: 20000 });
await new Promise((r) => setTimeout(r, Number(settle)));
const res = await page.evaluate(() => window.finish());
console.log(JSON.stringify(res));
if (res.id) console.log(await (await fetch(`${base}/finish/${res.id}`, { method: "POST" })).text());
} finally { await browser.close(); }
Source: Puppeteer documentation
Pion (Go)
Pion is a pure Go implementation of the WebRTC API, MIT licensed, with no cgo. Its README lists data channels (ordered or unordered, lossy or lossless), sending and receiving audio and video, renegotiation, trickle ICE, STUN and TURN, mDNS candidates, simulcast, NACK, sender and receiver reports, transport-wide congestion control feedback and bandwidth estimation, and Opus, H.264, VP8 and VP9 packetisation. The module follows the browser API closely: PeerConnection, DataChannel, RTPSender, RTPReceiver and the same OnTrack, OnDataChannel and OnConnectionStateChange events.
What makes it different from a browser is the media model. Pion does not decode or encode anything. OnTrack hands you a TrackRemote, ReadRTP returns the next RTP packet exactly as it was received, and a TrackLocalStaticRTP sends packets that you write. Forwarding a track is therefore a loop that reads from one track and writes to another, with no copy of the media payload; decoding, mixing or recording is something you add with a codec library (the Pion repository has save-to-disk and rtp-forwarder examples).
The configuration objects matter more than in a browser:
-
webrtc.SettingEnginecarries the Pion-specific settings: the UDP port range, the 1:1 NAT address mapping, the mDNS mode, timeouts and whether to use a single UDP port for every peer. -
webrtc.MediaEngineis the codec registry.RegisterDefaultCodecsregisters the common audio and video codecs; register only the ones you will forward if you want the answer to be strict. -
interceptor.Registryholds the RTCP machinery (NACK, sender and receiver reports, TWCC).RegisterDefaultInterceptorsadds them. If you build aMediaEngineyourself and skip this, you get no retransmission requests and no congestion feedback.
A Pixel Arena peer that also reflects video
The program below is the smallest complete peer. It serves the page above, accepts POST /offer, answers arena data channel inputs with snapshots, counts incoming video RTP packets and sends the video back unchanged on a TrackLocalStaticRTP. The reflector is also the core of a one-to-one SFU: replace the single output track with one track per subscriber and you have a selective forwarding unit without simulcast.
// Pixel Arena server peer with Pion: answers a browser offer over HTTP, echoes a VP8 video track
// back to the sender and answers player inputs on an unreliable data channel.
package main
import (
"encoding/json"
"log"
"net/http"
"os"
"strconv"
"sync/atomic"
"time"
"github.com/pion/ice/v4"
"github.com/pion/interceptor"
"github.com/pion/rtcp"
"github.com/pion/webrtc/v4"
)
var peers atomic.Int32
const maxPeers = 8
func newAPI() (*webrtc.API, error) {
se := webrtc.SettingEngine{}
lo, _ := strconv.Atoi(os.Getenv("UDP_MIN"))
hi, _ := strconv.Atoi(os.Getenv("UDP_MAX"))
if lo > 0 && hi >= lo {
if err := se.SetEphemeralUDPPortRange(uint16(lo), uint16(hi)); err != nil {
return nil, err
}
}
if ip := os.Getenv("PUBLIC_IP"); ip != "" { // address the browser can reach (Docker, NAT)
se.SetNAT1To1IPs([]string{ip}, webrtc.ICECandidateTypeHost)
}
se.SetICEMulticastDNSMode(ice.MulticastDNSModeDisabled) // disabled: containers cannot answer .local queries
m := &webrtc.MediaEngine{}
if err := m.RegisterDefaultCodecs(); err != nil {
return nil, err
}
ir := &interceptor.Registry{}
if err := webrtc.RegisterDefaultInterceptors(m, ir); err != nil { // NACK, RTCP reports, TWCC
return nil, err
}
return webrtc.NewAPI(webrtc.WithSettingEngine(se), webrtc.WithMediaEngine(m), webrtc.WithInterceptorRegistry(ir)), nil
}
func handleOffer(api *webrtc.API) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
if peers.Load() >= maxPeers {
http.Error(w, "arena full", http.StatusServiceUnavailable)
return
}
var offer webrtc.SessionDescription
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&offer); err != nil || offer.Type != webrtc.SDPTypeOffer {
http.Error(w, "expected an SDP offer", http.StatusBadRequest)
return
}
pc, err := api.NewPeerConnection(webrtc.Configuration{})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
peers.Add(1)
var rtpPackets, rtpBytes atomic.Uint64
// The echo track must exist before the answer is created so that it is part of it.
echo, _ := webrtc.NewTrackLocalStaticRTP(webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8}, "video", "arena-echo")
sender, err := pc.AddTrack(echo)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
go func() { // RTCP from the browser must be read, or the interceptors stall
buf := make([]byte, 1500)
for {
if _, _, err := sender.Read(buf); err != nil {
return
}
}
}()
pc.OnTrack(func(track *webrtc.TrackRemote, _ *webrtc.RTPReceiver) {
if track.Kind() != webrtc.RTPCodecTypeVideo {
return
}
go func() { // ask for a key frame now and then, so late joiners can start decoding
for range time.Tick(2 * time.Second) {
if pc.ConnectionState() >= webrtc.PeerConnectionStateClosed {
return
}
_ = pc.WriteRTCP([]rtcp.Packet{&rtcp.PictureLossIndication{MediaSSRC: uint32(track.SSRC())}})
}
}()
for {
pkt, _, err := track.ReadRTP()
if err != nil {
return
}
rtpPackets.Add(1)
rtpBytes.Add(uint64(len(pkt.Payload)))
_ = echo.WriteRTP(pkt) // the simplest SFU: one input, one output, no copy of the payload
}
})
pc.OnDataChannel(func(dc *webrtc.DataChannel) {
if dc.Label() != "arena" {
_ = dc.Close()
return
}
tick := 0
dc.OnMessage(func(msg webrtc.DataChannelMessage) {
var in struct {
Seq int `json:"seq"`
}
if json.Unmarshal(msg.Data, &in) != nil {
return
}
tick++
reply, _ := json.Marshal(map[string]any{"type": "snapshot", "tick": tick, "ackSeq": in.Seq})
_ = dc.SendText(string(reply)) // Send would deliver a binary message
if tick%20 == 0 {
st, _ := json.Marshal(map[string]any{"type": "rtp", "packets": rtpPackets.Load(), "bytes": rtpBytes.Load()})
_ = dc.SendText(string(st))
}
})
})
pc.OnConnectionStateChange(func(s webrtc.PeerConnectionState) {
log.Printf("peer state: %s", s)
if s == webrtc.PeerConnectionStateFailed || s == webrtc.PeerConnectionStateClosed {
if peers.Add(-1) >= 0 {
_ = pc.Close()
}
}
})
if err := pc.SetRemoteDescription(offer); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
_ = pc.Close()
return
}
answer, err := pc.CreateAnswer(nil)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
gathered := webrtc.GatheringCompletePromise(pc) // non-trickle: candidates go inside the answer
if err := pc.SetLocalDescription(answer); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
<-gathered
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(pc.LocalDescription())
}
}
func main() {
api, err := newAPI()
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
mux.Handle("/", http.FileServer(http.Dir("web")))
mux.HandleFunc("/offer", handleOffer(api))
addr := os.Getenv("ADDR")
if addr == "" {
addr = ":8080"
}
log.Printf("listening on %s", addr)
log.Fatal(http.ListenAndServe(addr, mux))
}
Source: Pion examples: reflect
The module needs a go.mod that requires github.com/pion/webrtc/v4 v4.2.25, github.com/pion/ice/v4, github.com/pion/interceptor and github.com/pion/rtcp; go mod tidy resolves the transitive versions. A two-stage build gives a small image that runs as a non-root user:
FROM golang:1.25 AS build
WORKDIR /src
COPY go.mod ./
COPY main.go ./
RUN go mod tidy && CGO_ENABLED=0 go build -o /arena .
FROM gcr.io/distroless/static-debian12
COPY --from=build /arena /arena
COPY web /web
WORKDIR /
USER nonroot
ENTRYPOINT ["/arena"]
Source: Docker docs: Multi-stage builds
Points in the program worth copying:
-
The echo track is added with
AddTrackbeforeCreateAnswer. A track added afterwards needs renegotiation. The codec on the track must be one the offer contains, so the browser must offer VP8; a browser that prefers another codec can be handled by choosing the output codec inOnTrackafter inspectingtrack.Codec(). -
The goroutine that reads from
sender.Readis required. Pion’s RTCP interceptors only run while someone reads from the sender, so a missing read loop silently disables receiver reports and keyframe requests. -
dc.SendTextsends a text message.dc.Sendsends a binary message. CallingSendwith JSON makes the browser’sJSON.parsefail with[object ArrayBuffer], because a binary message arrives as anArrayBufferunless the channel’sbinaryTypesays otherwise. Match the sending method to what the receiver expects. -
The peer count is capped and the request body is limited with
http.MaxBytesReader; an unauthenticated/offerthat allocates a peer connection, UDP sockets and goroutines is a denial-of-service vector (see Security). -
Periodic
PictureLossIndicationrequests are how a new subscriber gets a key frame sooner. Without them, a late joiner may see a black picture until the sender’s next scheduled key frame.
aiortc (Python)
aiortc is the asyncio implementation of WebRTC and ORTC in Python, BSD-3-Clause licensed. Its documentation states that the API follows the JavaScript one with coroutines instead of promises and pyee events. The README lists ICE with half-trickle and mDNS support, DTLS, SRTP, a pure Python SCTP implementation, data channels, Opus, PCMU and PCMA audio, VP8 and H.264 video, bundling, and RTCP reports including NACK and PLI. Simulcast, SVC and congestion-controlled bitrate adaptation are not in that list, so an aiortc peer is a good bot and a poor SFU.
The part that makes it productive for Huddle is aiortc.contrib.media:
-
MediaPlayerturns a file or a capture device into audio and video tracks you can add to a peer connection (a "virtual participant" that plays a clip). -
MediaRecorderwrites the tracks you give it to a file through PyAV and FFmpeg, choosing the container from the file name. -
MediaRelaylets several consumers share one incoming track, andMediaBlackholeconsumes a track and discards it, which keeps a receiver draining when you do not record. -
For processing, subclass
MediaStreamTrack, read frames fromtrack.recv()(decoded PyAV frames) and return modified frames. The OpenCV face-detection style of bot is built that way.
A Huddle recording bot
The bot answers an offer, adds every incoming track to a MediaRecorder writing an MP4, and still serves the arena data channel. A second endpoint stops the recording and reports what the file contains, so the result is checked rather than assumed.
"""Huddle recording bot with aiortc: answers a browser offer, records the call and echoes inputs."""
import asyncio
import json
import os
import random
import re
from pathlib import Path
import av
from aiohttp import web
from aiortc import RTCPeerConnection, RTCSessionDescription
from aiortc.contrib.media import MediaRecorder
RECORDINGS = Path(os.environ.get("RECORDINGS", "/recordings"))
PUBLIC_IP = os.environ.get("PUBLIC_IP") # address the browser can reach (Docker, NAT)
PORT_RANGE = os.environ.get("UDP_PORTS") # for example "41520-41529"; aiortc has no such option
if PORT_RANGE: # pin the ICE sockets to a published range: a small, test-only patch of the event loop
lo, hi = (int(p) for p in PORT_RANGE.split("-"))
_create = asyncio.BaseEventLoop.create_datagram_endpoint
async def _pinned(self, factory, local_addr=None, **kw):
if local_addr and local_addr[1] == 0:
ports = list(range(lo, hi + 1))
random.shuffle(ports)
for port in ports:
try:
return await _create(self, factory, local_addr=(local_addr[0], port), **kw)
except OSError:
continue
return await _create(self, factory, local_addr=local_addr, **kw)
asyncio.BaseEventLoop.create_datagram_endpoint = _pinned
peers: dict[str, dict] = {}
def advertise(sdp: str) -> str:
"""Replace the container address in host candidates by the address the browser can reach."""
if not PUBLIC_IP:
return sdp
return re.sub(r"(a=candidate:\S+ \d+ udp \d+ )\S+( \d+ typ host)", rf"\g<1>{PUBLIC_IP}\g<2>", sdp)
async def offer(request: web.Request) -> web.Response:
body = await request.json()
if body.get("type") != "offer" or len(body.get("sdp", "")) > 64_000:
raise web.HTTPBadRequest(text="expected an SDP offer")
if len(peers) >= 8:
raise web.HTTPServiceUnavailable(text="arena full")
pc = RTCPeerConnection()
pid = os.urandom(4).hex()
recorder = MediaRecorder(str(RECORDINGS / f"{pid}.mp4"))
peers[pid] = {"pc": pc, "recorder": recorder}
@pc.on("track")
def on_track(track):
recorder.addTrack(track) # video and audio are muxed into one MP4
@pc.on("datachannel")
def on_datachannel(channel):
if channel.label != "arena":
channel.close()
return
tick = 0
@channel.on("message")
def on_message(message):
nonlocal tick
inp = json.loads(message)
tick += 1
channel.send(json.dumps({"type": "snapshot", "tick": tick, "ackSeq": inp["seq"]}))
@pc.on("connectionstatechange")
async def on_state():
if pc.connectionState == "connected":
await recorder.start()
elif pc.connectionState in ("failed", "closed"):
peers.pop(pid, None)
await pc.setRemoteDescription(RTCSessionDescription(sdp=body["sdp"], type="offer"))
await pc.setLocalDescription(await pc.createAnswer()) # aiortc gathers candidates here
return web.json_response({"type": "answer", "sdp": advertise(pc.localDescription.sdp), "id": pid})
async def finish(request: web.Request) -> web.Response:
"""Stop recording, close the peer and report what the MP4 contains."""
pid = request.match_info["pid"]
peer = peers.pop(pid, None)
if peer is None:
raise web.HTTPNotFound()
await peer["recorder"].stop()
await peer["pc"].close()
path = RECORDINGS / f"{pid}.mp4"
with av.open(str(path)) as container:
kinds = {s.type: s.frames for s in container.streams}
duration = float(container.duration or 0) / av.time_base
return web.json_response({"file": path.name, "bytes": path.stat().st_size, "streams": kinds, "seconds": round(duration, 2)})
async def close_all(app: web.Application) -> None:
for peer in list(peers.values()):
await peer["pc"].close()
def make_app() -> web.Application:
RECORDINGS.mkdir(parents=True, exist_ok=True)
app = web.Application(client_max_size=64 * 1024)
app.router.add_post("/offer", offer)
app.router.add_post("/finish/{pid}", finish)
app.router.add_static("/", Path(__file__).parent / "web")
app.on_shutdown.append(close_all)
return app
if __name__ == "__main__":
web.run_app(make_app(), host="0.0.0.0", port=int(os.environ.get("PORT", "8080")))
Source: aiortc examples: server
aiortc==1.15.0
aiohttp==3.14.5
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY bot.py .
COPY web web
RUN useradd -m bot && mkdir /recordings && chown bot /recordings
USER bot
CMD ["python", "bot.py"]
Source: aiortc documentation
The aiortc-specific points:
-
recorder.start()is called once the connection isconnected, andawait recorder.stop()is what finalises the MP4. A file that was never stopped has no index and does not play. The handler that ends a call must always reachstop(), including onfailed. -
aiortc runs on one asyncio loop. Anything CPU-bound in a
recv()override (an OpenCV or model call) blocks every call; run it withloop.run_in_executoror in a worker process. -
aiortc has no setting for the UDP port range. It binds each gathered interface address to an ephemeral port, which does not work when the ports must be published by a container runtime. The
_pinnedpatch ofcreate_datagram_endpointin the example is a test-harness workaround; in production on Linux run the container with host networking or put a TURN server in front. This is the sharpest difference from Pion’sSetEphemeralUDPPortRange. -
The container address aioice gathers is not the one the browser can use. The example rewrites the host candidates in the answer to
PUBLIC_IP. In production the usual fix is the same, or a TURN relay, see ICE, STUN and TURN.
In the recorded run, a four second call produced an MP4 of about 118 KB with 77 video frames and 184 audio frames, and the data channel returned 79 snapshots.
node-datachannel and libdatachannel (Node.js)
node-datachannel is the Node.js binding to libdatachannel, a C++ library that implements data channels, optional SRTP media transport and WebSockets. libdatachannel is MPL-2.0 licensed since 0.18, and node-datachannel ships prebuilt binaries per platform for Node.js 18.20 and later, so there is nothing to compile. It offers two APIs:
-
a polyfill (
node-datachannel/polyfill) with the browser’sRTCPeerConnectionshape, which is what the Data Channels page uses; -
the native API,
PeerConnectionwith callbacks such asonLocalDescription,onStateChange,onGatheringStateChangeandonDataChannel, which is closer to libdatachannel and exposes itsRtcConfigoptions:portRangeBeginandportRangeEnd,enableIceUdpMux,bindAddress,maxMessageSize,iceTransportPolicyand certificate files.
Media is possible with the native Video and Audio tracks, but libdatachannel deals with packetising and RTCP only; you provide already-encoded frames. For anything that handles media, Pion and aiortc are the better fit; node-datachannel is at its best as a lightweight data-channel server inside an existing Node.js service.
The same Pixel Arena peer with the native API
// Pixel Arena server peer with node-datachannel's native API (libdatachannel), non-trickle HTTP signalling.
import http from "node:http";
import { readFile } from "node:fs/promises";
import nodeDataChannel from "node-datachannel";
const { PeerConnection } = nodeDataChannel;
const PORT = Number(process.env.PORT ?? 8080);
const peers = new Set();
function answerOffer(offer) {
return new Promise((resolve, reject) => {
const pc = new PeerConnection(`arena-${peers.size}`, {
iceServers: [],
portRangeBegin: Number(process.env.UDP_MIN ?? 0),
portRangeEnd: Number(process.env.UDP_MAX ?? 0),
});
peers.add(pc);
const timer = setTimeout(() => reject(new Error("ICE gathering timed out")), 5000);
pc.onStateChange((state) => {
if (state === "failed" || state === "closed") { peers.delete(pc); pc.close(); }
});
pc.onGatheringStateChange((state) => {
if (state !== "complete") return;
clearTimeout(timer);
resolve(pc.localDescription()); // {type: "answer", sdp: "..."} with the candidates inside
});
pc.onDataChannel((dc) => {
if (dc.getLabel() !== "arena") return dc.close();
let tick = 0;
dc.onMessage((msg) => {
const input = JSON.parse(msg.toString());
dc.sendMessage(JSON.stringify({ type: "snapshot", tick: ++tick, ackSeq: input.seq }));
});
});
pc.setRemoteDescription(offer.sdp, offer.type); // libdatachannel then generates the answer by itself
});
}
const server = http.createServer(async (req, res) => {
if (req.method === "POST" && req.url === "/offer") {
let body = "";
for await (const chunk of req) {
body += chunk;
if (body.length > 64 * 1024) return res.writeHead(413).end();
}
try {
const answer = await answerOffer(JSON.parse(body));
res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify(answer));
} catch (err) {
res.writeHead(400).end(String(err.message));
}
return;
}
if (req.method === "GET" && req.url.startsWith("/arena.html")) {
return res.writeHead(200, { "Content-Type": "text/html" }).end(await readFile(new URL("./web/arena.html", import.meta.url)));
}
res.writeHead(404).end();
});
server.listen(PORT, "127.0.0.1", () => console.log(`listening on ${PORT}`));
process.on("SIGINT", () => { for (const pc of peers) pc.close(); nodeDataChannel.cleanup(); process.exit(0); });
{ "type": "module",
"dependencies": { "node-datachannel": "0.33.4" } }
Source: node-datachannel: API reference
libdatachannel generates the answer by itself as soon as setRemoteDescription is called with an offer, which is why there is no createAnswer. In the run, this peer was reached by the same page with data=1 and replied with 60 snapshots in three seconds. Read reliability attributes on the side that created the channel, as the Data Channels page explains: the answering side may report defaults.
Docker, NAT and the UDP port range
A server peer in a container has three problems that a browser never has.
-
Which port? ICE uses UDP ports chosen at random. Publish a fixed range and constrain the library to it: Pion
SetEphemeralUDPPortRange, libdatachannelportRangeBeginandportRangeEnd, aiortc not supported (see above). Docker publishes a UDP range with-p 50010-50019:50010-50019/udp; keep the host and container ports identical, because the port in the candidate must be the one the client reaches. -
Which address? The candidate contains the container’s address, which the client cannot reach. Pion’s
SetNAT1To1IPs([]string{ip}, webrtc.ICECandidateTypeHost)advertises the public address instead; the aiortc example rewrites the SDP. A server with a public IP that cannot learn it (cloud NAT) needs the address from configuration or from a STUN query. -
mDNS. Browsers can hide host addresses behind
.localnames, and a container cannot answer multicast DNS queries. In tests start Chrome with--disable-features=WebRtcHideLocalIpsWithMdns, and switch mDNS off on the Pion side withMulticastDNSModeDisabled, as the example does; in production the browser’s candidate is usually a server-reflexive or relayed address anyway.
Compose file for the Pion and aiortc services (the ports in the variables are the defaults):
services:
arena-pion:
build: ./go
ports:
- "${PION_HTTP:-8080}:8080"
- "${PION_UDP_MIN:-50010}-${PION_UDP_MAX:-50019}:${PION_UDP_MIN:-50010}-${PION_UDP_MAX:-50019}/udp"
environment:
UDP_MIN: "${PION_UDP_MIN:-50010}"
UDP_MAX: "${PION_UDP_MAX:-50019}"
PUBLIC_IP: "127.0.0.1" # the address the local browser can reach; use the host's public IP in production
huddle-bot:
build: ./py
ports:
- "${BOT_HTTP:-8081}:8080"
- "${BOT_UDP_MIN:-50020}-${BOT_UDP_MAX:-50029}:${BOT_UDP_MIN:-50020}-${BOT_UDP_MAX:-50029}/udp"
environment:
UDP_PORTS: "${BOT_UDP_MIN:-50020}-${BOT_UDP_MAX:-50029}"
PUBLIC_IP: "127.0.0.1"
volumes:
- recordings:/recordings
volumes:
recordings:
docker compose -p arena-peers up -d --build
# Pion reflector: Chrome sends fake camera video and gets it back
node drive.mjs http://127.0.0.1:8080 "?echo=1"
# aiortc recorder: video and audio are recorded, then the MP4 is inspected
node drive.mjs http://127.0.0.1:8081 "?audio=1"
docker compose -p arena-peers down -v
In the recorded run the Pion service finished with 80 acknowledged snapshots, a server count of 153 received RTP packets and Chrome reporting 77 decoded frames of the echoed 320 by 240 video; the aiortc service recorded 78 video and 185 audio frames in 3.93 seconds.
Choosing between them
| Pion | aiortc | node-datachannel | |
|---|---|---|---|
Language and runtime |
Go, single static binary, goroutines |
Python 3.10 or later, one asyncio loop |
Node.js, native addon |
Media model |
Raw RTP in and out; you bring codecs |
Decoded frames and helpers ( |
Data channels first; encoded frames only |
Congestion control and simulcast |
Interceptors for NACK, reports and TWCC; simulcast supported |
Reports, NACK and PLI; no simulcast or SVC |
Limited |
Fixed UDP port range |
Yes |
No |
Yes |
Typical use here |
Pixel Arena server peer, SFU building block |
Huddle recording and analysis bot |
Pixel Arena peer inside a Node.js game server |
Security
-
The server terminates DTLS-SRTP. Media is encrypted between the browser and the server peer, and the server sees it in the clear, as every SFU does. Recording and transcription bots depend on that. If participants expect that not even the server can read their media, you need end-to-end encryption with encoded transforms and SFrame, see End-to-End Encryption — a bot that records cannot be a participant in such a call unless it holds the key.
-
Protect the signalling endpoint.
POST /offercreates sockets, goroutines and threads. Authenticate it with a short-lived token tied to a user and a room, checkOriginon browser calls, cap the body, cap the number of concurrent peers per user and per process, and close peers that never reachconnected. See Authentication and Authorization and Security. -
Validate data channel input. The server peer receives player inputs from the network. Validate labels, sizes and ranges as for any WebSocket message and drop what does not parse, the same discipline as in The WebSocket Protocol.
-
Authenticate the DTLS peer through signalling. The SDP carries the DTLS fingerprint; whoever can alter signalling can man-in-the-middle the call, so serve signalling over TLS.
-
TURN credentials are ephemeral. If your server peers use a TURN relay, hand them time-limited credentials derived from a shared secret rather than a static password, as in ICE, STUN and TURN. Pion also has a TURN server library,
pion/turn, for those who want to embed one. -
Recordings are personal data. Tell participants, store files outside the web root, restrict access, and delete on a schedule.
Common errors and pitfalls
| Symptom | Cause | Fix |
|---|---|---|
Connection stays |
The candidate holds a container address, or the UDP ports are not published. |
Publish a fixed UDP range, constrain the library to it and advertise the reachable address. |
Chrome connects but no video arrives from Pion |
The answer did not contain a sender, or the RTCP read loop is missing. |
Call |
|
Pion |
Use |
The recorded MP4 will not play |
|
Always stop in a |
aiortc stalls every call when one is busy |
CPU-bound work inside |
Use an executor or a worker process. |
The video stays black for several seconds |
The viewer joined between key frames. |
Request a key frame (PLI) when a subscriber starts. |
|
The server did not register a codec the browser offered. |
Register the codecs you will use in the |
Memory grows over days |
Peer connections were not closed after |
Close in the state handler, and drop references. |
Version and specification notes
-
Pion v4 is the current major line of
github.com/pion/webrtc; imports must include/v4. The release notes of v4.0.0 list the breaking changes against v3. -
JSEP is RFC 9429, which obsoletes RFC 8829, and BUNDLE is RFC 9143, which obsoletes RFC 8843. Some library READMEs, libdatachannel’s among them, still cite the older numbers; the behaviour they describe is the same.
-
Browsers hide host candidates behind mDNS names, which is specified in an Internet-Draft that expired but is implemented by every browser; libdatachannel and Pion implement it, and aiortc lists mDNS support.
-
aiortc 1.15.0 requires Python 3.10 or later and bundles PyAV, so FFmpeg codecs come with the wheel.
-
Transport-wide congestion control in Pion’s interceptors follows a draft that was never published as an RFC; RFC 8888 is the standardised feedback format.
Related pages
-
Data Channels for the data channel options and the short snippets for each library
-
Topologies: Mesh, SFU and MCU, mediasoup, LiveKit and Janus and Jitsi when you need a full media server
-
Recording and Media Processing for MediaRecorder and server-side recording
-
WHIP, WHEP and Live Streaming for ingest into a server peer
-
Scaling WebRTC and TURN for running many peers
-
LiveKit Agents for voice agents built on a media server
-
WebRTC Data Channels (AI conversational channels) for an aiortc server in an assistant
References
-
Pion WebRTC: https://github.com/pion/webrtc
-
Pion WebRTC v4 package documentation: https://pkg.go.dev/github.com/pion/webrtc/v4
-
Pion examples: https://github.com/pion/webrtc/tree/master/examples
-
Pion project site: https://pion.ly/
-
aiortc documentation: https://aiortc.readthedocs.io/en/latest/
-
aiortc repository: https://github.com/aiortc/aiortc
-
node-datachannel: https://github.com/murat-dogan/node-datachannel
-
libdatachannel: https://github.com/paullouisageneau/libdatachannel
-
RFC 9429: JavaScript Session Establishment Protocol (JSEP): https://www.rfc-editor.org/rfc/rfc9429
-
RFC 9143: Negotiating Media Multiplexing Using SDP (BUNDLE): https://www.rfc-editor.org/rfc/rfc9143
-
RFC 8831: WebRTC Data Channels: https://www.rfc-editor.org/rfc/rfc8831
-
WebRTC for the Curious (Pion authors): https://webrtcforthecurious.com/