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:

sequenceDiagram participant B as Browser (headless Chrome) participant S as Signalling (HTTP POST /offer) participant P as Server peer (Pion, aiortc or libdatachannel) B->>B: getUserMedia, addTransceiver, createDataChannel B->>B: setLocalDescription, wait for ICE gathering B->>S: POST /offer with the SDP offer S->>P: setRemoteDescription(offer) P->>P: createAnswer, setLocalDescription, gather candidates S-->>B: SDP answer with candidates inside B->>P: ICE checks, DTLS handshake (UDP) B->>P: SRTP media and SCTP data channel P-->>B: echoed media, snapshots, statistics

Three design choices shape the code:

  • Non-trickle signalling. The browser waits for iceGatheringState to reach complete before 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 inside setLocalDescription and 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(); }

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.SettingEngine carries 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.MediaEngine is the codec registry. RegisterDefaultCodecs registers the common audio and video codecs; register only the ones you will forward if you want the answer to be strict.

  • interceptor.Registry holds the RTCP machinery (NACK, sender and receiver reports, TWCC). RegisterDefaultInterceptors adds them. If you build a MediaEngine yourself 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))
}

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"]

Points in the program worth copying:

  • The echo track is added with AddTrack before CreateAnswer. 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 in OnTrack after inspecting track.Codec().

  • The goroutine that reads from sender.Read is 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.SendText sends a text message. dc.Send sends a binary message. Calling Send with JSON makes the browser’s JSON.parse fail with [object ArrayBuffer], because a binary message arrives as an ArrayBuffer unless the channel’s binaryType says 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 /offer that allocates a peer connection, UDP sockets and goroutines is a denial-of-service vector (see Security).

  • Periodic PictureLossIndication requests 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:

  • MediaPlayer turns 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).

  • MediaRecorder writes the tracks you give it to a file through PyAV and FFmpeg, choosing the container from the file name.

  • MediaRelay lets several consumers share one incoming track, and MediaBlackhole consumes a track and discards it, which keeps a receiver draining when you do not record.

  • For processing, subclass MediaStreamTrack, read frames from track.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")))
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"]

The aiortc-specific points:

  • recorder.start() is called once the connection is connected, and await 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 reach stop(), including on failed.

  • aiortc runs on one asyncio loop. Anything CPU-bound in a recv() override (an OpenCV or model call) blocks every call; run it with loop.run_in_executor or 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 _pinned patch of create_datagram_endpoint in 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’s SetEphemeralUDPPortRange.

  • 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’s RTCPeerConnection shape, which is what the Data Channels page uses;

  • the native API, PeerConnection with callbacks such as onLocalDescription, onStateChange, onGatheringStateChange and onDataChannel, which is closer to libdatachannel and exposes its RtcConfig options: portRangeBegin and portRangeEnd, enableIceUdpMux, bindAddress, maxMessageSize, iceTransportPolicy and 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" } }

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.

  1. Which port? ICE uses UDP ports chosen at random. Publish a fixed range and constrain the library to it: Pion SetEphemeralUDPPortRange, libdatachannel portRangeBegin and portRangeEnd, 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.

  2. 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.

  3. mDNS. Browsers can hide host addresses behind .local names, and a container cannot answer multicast DNS queries. In tests start Chrome with --disable-features=WebRtcHideLocalIpsWithMdns, and switch mDNS off on the Pion side with MulticastDNSModeDisabled, 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 (MediaPlayer, MediaRecorder)

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 /offer creates sockets, goroutines and threads. Authenticate it with a short-lived token tied to a user and a room, check Origin on browser calls, cap the body, cap the number of concurrent peers per user and per process, and close peers that never reach connected. 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 connecting, then failed in a container

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 AddTrack before CreateAnswer and read from the RTPSender.

JSON.parse fails in the page on [object ArrayBuffer]

Pion DataChannel.Send delivers a binary message.

Use SendText for text, or decode the ArrayBuffer.

The recorded MP4 will not play

MediaRecorder.stop() was never awaited, or the call ended in failed.

Always stop in a finally path and check the file with PyAV.

aiortc stalls every call when one is busy

CPU-bound work inside recv() blocks the event loop.

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.

setRemoteDescription fails with a codec error

The server did not register a codec the browser offered.

Register the codecs you will use in the MediaEngine.

Memory grows over days

Peer connections were not closed after failed or closed.

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.

References