WebRTC Data Channels
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page was written on 2026-10-01 against aiortc 1.15.0 on Python 3.12 and FastAPI 0.142.2 for the signalling
endpoint, with the browser RTCPeerConnection API as documented by MDN and the W3C WebRTC specification (see the
version baseline). The snippets are illustrative: they are
written from the official documentation of each project and were not compiled or run by this repository. Behaviour that
depends on a browser, a network or a TURN server is described cautiously for that reason.
The Streaming Chat APIs page already gives DocsAssistant a WebSocket and an SSE surface, and for a plain chat box those are almost always the right answer. A WebRTC data channel earns its place in one situation: the browser already has (or will have) a peer connection for audio or video, as in a voice or video assistant. Then text, tool events and control messages can ride on the same connection and the same network path instead of opening a second transport. This page explains the moving parts, builds a working shape for DocsAssistant, and is explicit about where the technique stops paying off. The audio side of such a deployment belongs to Voice Agents (browser audio is on Voice in the Browser), which is covered separately and is not repeated here.
What a data channel is
A WebRTC connection (RTCPeerConnection) is a peer-to-peer session between two endpoints. Media travels over SRTP; data
channels travel over SCTP, which is itself carried over DTLS on top of the ICE-selected UDP path. One peer connection
can multiplex many data channels, each with its own label and its own delivery semantics, and creating the first channel
is also what makes the offer include a data section when no audio or video is negotiated.
Two properties set it apart from a WebSocket:
-
Delivery semantics are per channel and configurable: reliable and ordered like TCP, reliable but unordered, or partially reliable (give up on a message after a number of retransmissions or after a time budget).
-
The transport is UDP-based and traverses NATs through ICE, so it needs an out-of-band signalling exchange to set up and usually a relay (TURN) as a fallback. A WebSocket needs only an HTTPS-reachable URL.
In DocsAssistant’s case the "peer" on the server is not a person but a process: the Python service terminates the peer
connection with aiortc, a Python implementation of WebRTC and ORTC built on asyncio, and hands each decoded message to
the same channel-agnostic ask(conversationId, userId, text) core used by every other adapter.
Signalling
WebRTC deliberately does not define how two peers find each other or exchange their session descriptions. The application supplies a signalling channel; a WebSocket is the conventional choice and is what this page uses. The exchange carries three things:
-
an SDP offer from the caller, describing its transports and channels;
-
an SDP answer from the callee;
-
ICE candidates, the network addresses each side can be reached at, either bundled in the SDP or sent incrementally ("trickle ICE").
Once the channel is open the signalling socket has done its job. Keep it only if renegotiation is likely (adding a video track later, or an ICE restart after a network change); otherwise close it so the server holds no extra connection per user.
Creating the channel in the browser
const pc = new RTCPeerConnection({
iceServers: [
{ urls: "stun:turn.example.com:3478" },
{
urls: ["turn:turn.example.com:3478?transport=udp", "turns:turn.example.com:5349?transport=tcp"],
username: turnUsername, // short-lived credentials issued by your API
credential: turnCredential,
},
],
});
const chat = pc.createDataChannel("chat", { ordered: true }); // reliable, ordered: the default
chat.onopen = () => chat.send(JSON.stringify({ type: "ask", text: "How do I configure Maven?" }));
chat.onmessage = (event) => handleFrame(JSON.parse(event.data));
const signalling = new WebSocket("wss://docs.example.com/rtc/signal");
signalling.onopen = async () => {
signalling.send(JSON.stringify({ type: "auth", token: accessToken }));
await pc.setLocalDescription(await pc.createOffer());
// wait for ICE gathering to finish so the offer already contains the candidates
await new Promise((resolve) => {
if (pc.iceGatheringState === "complete") return resolve();
pc.addEventListener("icegatheringstatechange", () => pc.iceGatheringState === "complete" && resolve());
});
signalling.send(JSON.stringify({ type: "offer", sdp: pc.localDescription.sdp }));
};
signalling.onmessage = async ({ data }) => {
const message = JSON.parse(data);
if (message.type === "answer") {
await pc.setRemoteDescription({ type: "answer", sdp: message.sdp });
signalling.close();
}
};
Waiting for gathering to complete is the simplest signalling shape and is what the server below assumes, because aiortc
does not trickle candidates: it gathers during setLocalDescription, so its answer already contains them. The cost is a
slightly slower connection setup when a TURN allocation is slow; a browser-to-browser product would normally trickle
instead. Credentials for the TURN server should be issued per session by your API (see STUN and TURN with coturn)
and never embedded in the page.
Reliability options
createDataChannel takes options that decide how the SCTP stream behaves. They map directly to SCTP’s partial
reliability extension.
| Option | Default | Effect |
|---|---|---|
|
|
Messages are delivered in the order they were sent. With |
|
unset |
Maximum number of retransmission attempts for a message; |
|
unset |
Maximum time in milliseconds during which retransmission is attempted. Mutually exclusive with |
|
|
When |
|
empty |
A free-form sub-protocol label, for example |
For DocsAssistant, the answer stream must be reliable and ordered: a dropped token corrupts the sentence and a reordered
one garbles it. Partial reliability is the wrong tool for the answer itself. It is useful for side information that goes
stale: a "typing" or progress indicator, audio-level meters or a live cursor can use a second channel
({ ordered: false, maxRetransmits: 0 }) so a lost update is simply replaced by the next one and never delays the main
stream. Use separate channels, not a single partially reliable one, because the reliability applies to the whole channel.
const chat = pc.createDataChannel("chat", { protocol: "docsassistant.v1" }); // reliable, ordered
const presence = pc.createDataChannel("presence", { ordered: false, maxRetransmits: 0 }); // fire and forget
Source: W3C WebRTC: RTCDataChannelInit.
The server peer with aiortc
aiortc exposes the same objects as the browser API (RTCPeerConnection, RTCSessionDescription, RTCConfiguration,
RTCIceServer) with asyncio coroutines. The signalling endpoint is an ordinary FastAPI WebSocket route: it receives the
offer, builds a peer connection, registers the datachannel handler and returns the answer.
import json
import os
import uuid
from aiortc import RTCConfiguration, RTCIceServer, RTCPeerConnection, RTCSessionDescription
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
app = FastAPI()
peers: set[RTCPeerConnection] = set()
ICE = RTCConfiguration(
iceServers=[
RTCIceServer(urls="stun:turn.example.com:3478"),
RTCIceServer(
urls="turn:turn.example.com:3478",
username=os.environ["TURN_USERNAME"],
credential=os.environ["TURN_CREDENTIAL"],
),
]
)
def authenticate(token: str) -> str:
"""Validate the bearer token and return the user id (same verifier as the REST API)."""
raise NotImplementedError
@app.websocket("/rtc/signal")
async def signal(ws: WebSocket) -> None:
await ws.accept()
pc = RTCPeerConnection(ICE)
peers.add(pc)
user_id = ""
conversation_id = str(uuid.uuid4())
@pc.on("datachannel")
def on_datachannel(channel):
if channel.label == "chat":
serve_chat(channel, user_id, conversation_id)
@pc.on("connectionstatechange")
async def on_state():
if pc.connectionState in ("failed", "closed"):
await pc.close()
peers.discard(pc)
try:
while True:
message = json.loads(await ws.receive_text())
if message["type"] == "auth":
user_id = authenticate(message["token"])
elif message["type"] == "offer" and user_id:
await pc.setRemoteDescription(RTCSessionDescription(sdp=message["sdp"], type="offer"))
await pc.setLocalDescription(await pc.createAnswer())
await ws.send_text(json.dumps({"type": "answer", "sdp": pc.localDescription.sdp}))
except WebSocketDisconnect:
pass
Source: aiortc API reference and aiortc server example.
Two details matter for production. First, the user_id is fixed by the authenticated signalling socket before the
offer is accepted, so every message arriving on the channel is attributed to a verified user and never to a field in the
payload; this is the same rule as in the
RAG access-control design, and the channel adds no identity of its
own. Second, every connection must be closed and discarded from the peers set: an aiortc peer holds UDP sockets and
DTLS state, so a leak costs far more than an idle WebSocket does. A periodic ping from the client, or an idle timeout on
the server, should close peers whose connectionState stopped changing.
Streaming tokens over the channel
The core streams token, citation and done events. The adapter serialises each one as a small JSON frame and sends
it on the channel; the browser appends tokens as they arrive. The adapter respects the channel’s send buffer rather than
queueing without bound.
import asyncio
import json
from aiortc import RTCDataChannel
MAX_FRAME_BYTES = 16 * 1024 # stay well under the negotiated message size limit
BUFFER_HIGH = 1 * 1024 * 1024 # pause sending above this many queued bytes
async def send_frame(channel: RTCDataChannel, frame: dict) -> None:
payload = json.dumps(frame, ensure_ascii=False)
while channel.bufferedAmount > BUFFER_HIGH: # simple backpressure
await asyncio.sleep(0.01)
channel.send(payload)
def serve_chat(channel: RTCDataChannel, user_id: str, conversation_id: str) -> None:
@channel.on("message")
def on_message(raw):
if isinstance(raw, bytes):
return # this protocol is text frames only
message = json.loads(raw)
if message.get("type") == "ask":
asyncio.ensure_future(answer(channel, user_id, conversation_id, message["text"]))
async def answer(channel: RTCDataChannel, user_id: str, conversation_id: str, text: str) -> None:
request_id = str(uuid.uuid4())
async for event in docs_assistant.ask(conversation_id, user_id, text): # the channel-agnostic core
await send_frame(channel, {"id": request_id, **event}) # token | citation | done
Source: aiortc API: RTCDataChannel.
The id field ties frames to a request, so a client that sends a second question before the first finishes (the
"double-texting" case) can route the frames correctly, and it can ignore the stale ones after the user cancels. The
core itself, including the retriever tool and the Postgres checkpointer that provide memory per conversationId, is
described in LangChain: Streaming and
Serving RAG as an API; for a Spring AI core, the equivalent Flux is in
Spring AI: Streaming & Reactive. The Python snippet above would call an
HTTP or in-process interface of that core, and a JVM service would need a Java WebRTC library instead of aiortc, which
is one more reason this adapter is usually written in the Python deployment.
The message size limit and chunking
Data channels carry messages, not a byte stream, and SCTP over WebRTC has an upper bound on message size. The
transport advertises it as RTCSctpTransport.maxMessageSize, derived from the a=max-message-size attribute in the
SDP. The value depends on the two implementations: the specification’s default when the attribute is absent is small
(on the order of 64 KiB), some browsers advertise much larger values, and in practice cross-browser interoperability is
safest at 16 KiB per message. Sending a message larger than the peer’s limit makes send() throw (or, on older
implementations, closes the channel). Large single messages also delay everything behind them on an ordered channel.
Individual token frames are tiny, so the limit rarely matters for the answer stream. It matters for events that
embed a payload: a done frame carrying the full citation list with snippets, a tool result, or an uploaded document
when the channel is used for attachments. Those need chunking. A small framing layer splits a payload into numbered
pieces and reassembles them on the other side:
import base64
import json
import math
import uuid
def chunk(frame: dict, limit: int = MAX_FRAME_BYTES) -> list[str]:
body = json.dumps(frame, ensure_ascii=False).encode("utf-8")
if len(body) <= limit:
return [json.dumps({"t": "whole", "d": body.decode("utf-8")})] # small: no chunking
size = (limit - 128) * 3 // 4 # leave room for the envelope, base64 overhead
message_id = uuid.uuid4().hex
total = math.ceil(len(body) / size)
return [
json.dumps(
{"t": "part", "m": message_id, "i": i, "n": total,
"d": base64.b64encode(body[i * size:(i + 1) * size]).decode("ascii")}
)
for i in range(total)
]
The browser keeps a map from message id to the parts received and emits the reassembled frame when the received count reaches
n:
const partial = new Map();
function handleRaw(raw) {
const envelope = JSON.parse(raw);
if (envelope.t === "whole") return handleFrame(JSON.parse(envelope.d));
const entry = partial.get(envelope.m) ?? { parts: new Array(envelope.n), received: 0 };
if (entry.parts[envelope.i] === undefined) { entry.parts[envelope.i] = envelope.d; entry.received += 1; }
partial.set(envelope.m, entry);
if (entry.received === envelope.n) {
partial.delete(envelope.m);
const bytes = Uint8Array.from(atob(entry.parts.join("")), (c) => c.charCodeAt(0));
handleFrame(JSON.parse(new TextDecoder().decode(bytes)));
}
}
Because the channel is reliable and ordered, the parts arrive complete and in order, so no retransmission logic is
needed; on an unordered or partially reliable channel the reassembler would also have to handle gaps and time them out.
Cap the number of parts and the total size per message and drop incomplete entries after a timeout, otherwise a
misbehaving peer can make the server (or browser) hold unbounded memory. For binary payloads, send ArrayBuffer
messages directly with a tiny header instead of base64, which costs about a third in size. When the browser reports a
maxMessageSize smaller than your constant, lower the limit at runtime from pc.sctp.maxMessageSize.
Backpressure is the companion problem. send() queues into the channel’s buffer and returns immediately; the sender
should watch bufferedAmount and wait for the bufferedamountlow event before sending more, as send_frame does on the
server with a polling loop. Without it, a slow mobile client makes the server buffer an entire long answer in memory.
STUN and TURN with coturn
ICE tries candidate pairs until one works. Three kinds of candidate matter:
-
host candidates are the machine’s own addresses and work on the same network;
-
server-reflexive candidates are the public address a STUN server reports back, which works through most home NATs;
-
relay candidates are addresses allocated on a TURN server, which forwards traffic and works when direct paths are blocked (symmetric NATs, restrictive corporate firewalls).
STUN is cheap; TURN relays all of the traffic and therefore costs bandwidth, which should be budgeted from the
fraction of users that need it. coturn is the common open-source implementation of both. A minimal configuration, with
long-term credentials derived from a shared secret so the API can mint short-lived usernames. coturn does not expand
environment variables in its configuration file, so the file below is a template rendered at deploy time (for example
with envsubst, or by your secret manager’s agent) into a file readable only by the coturn user; alternatively, leave
static-auth-secret out and pass --static-auth-secret on the command line. Comments sit on their own lines because
coturn does not reliably strip a trailing # … from a value:
# /etc/turnserver.conf.template -- rendered to /etc/turnserver.conf at deploy time
listening-port=3478
tls-listening-port=5349
realm=turn.example.com
fingerprint
use-auth-secret
# replaced with the real secret from your secret store when rendering; never commit the rendered file
static-auth-secret=${TURN_SHARED_SECRET}
cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
no-cli
no-tcp-relay
# block relaying into private, shared, link-local (cloud metadata) and "this network" ranges
denied-peer-ip=0.0.0.0-0.255.255.255
denied-peer-ip=10.0.0.0-10.255.255.255
denied-peer-ip=100.64.0.0-100.127.255.255
denied-peer-ip=169.254.0.0-169.254.255.255
denied-peer-ip=172.16.0.0-172.31.255.255
denied-peer-ip=192.168.0.0-192.168.255.255
denied-peer-ip=fc00::-fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff
denied-peer-ip=fe80::-febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff
Source: coturn: turnserver documentation.
With use-auth-secret (the TURN REST API convention), the credential is an HMAC of a username that embeds an expiry
time, computed from the shared secret; the signalling API returns it to the authenticated user together with the ICE
server list, so credentials expire without any state in coturn. The deny-list matters: an open TURN relay that can reach
internal addresses is a well-known way to pivot into a private network. The link-local range 169.254.0.0/16 deserves
special attention: it holds the cloud instance metadata endpoint (169.254.169.254), and a relay that reaches it turns
TURN into a server-side request forgery (SSRF) channel that can leak the host’s cloud credentials. Exposing TURN on port 443 over TLS (turns:)
helps users behind firewalls that only allow HTTPS egress. The server-side aiortc peer normally has a public address
itself and needs STUN only to learn it; give it a TURN entry too only if it sits behind NAT, as on some container
platforms. Container platforms and UDP are an awkward fit: check that the hosting service forwards the UDP port range
you configure, as discussed for the deployment targets in
LangChain: Deployment.
When a data channel beats a WebSocket
| Situation | Data channel | WebSocket |
|---|---|---|
A voice or video assistant already holds a peer connection |
Text, tool-call events and captions share the connection and its lifetime; one network path, one reconnect story. |
A second connection to keep alive and authenticate, which can fail independently of the media. |
Per-message latency tolerance and partial reliability for ephemeral data (presence, cursors, meters) |
Unordered, no-retransmit channels avoid head-of-line blocking. |
TCP retransmits and orders everything; a lost packet delays all later messages. |
Browser-to-browser traffic that should bypass your servers (collaborative sessions) |
Direct path when ICE succeeds. |
Always relayed through a server. |
Plain text chat with a RAG assistant, no media |
Works, but adds signalling, STUN/TURN, ICE failure modes and DTLS state for no user-visible gain. |
One HTTPS-compatible endpoint; works through nearly every proxy and CDN; trivially load-balanced. |
Corporate networks and strict proxies |
UDP is often blocked; success depends on a TURN relay over TCP or TLS. |
Passes wherever HTTPS does. |
Server-side scaling and operations |
Stateful UDP peers, no standard load balancer story, TURN bandwidth to pay for. |
Mature tooling for sticky sessions, TLS termination, metrics and autoscaling. |
Authentication and authorisation |
Not built in: you reuse the signalling socket’s identity, and must re-authorise on renegotiation. |
Handshake carries cookies or a token; the same middleware as the REST API applies. |
Resuming after a dropped connection |
ICE restart or a new negotiation; app-level resume is up to you. |
Reconnect and resume with a |
The honest summary: for a chat box, prefer SSE or a WebSocket from Streaming Chat APIs. Choose a data channel when a media connection exists anyway, or when partial reliability or a direct browser-to-browser path is a real requirement. The choice among the channel options in general is laid out in the channel matrix, and the surrounding architecture in Channel-Agnostic Architecture. The same user interface will often be reached through more than one adapter, which is why the adapter keeps the transport details out of the core.
Security and operations checklist
-
Authenticate the signalling WebSocket before accepting an offer and bind the peer to that identity; apply the rate limits and quotas of the REST API per user, because a data channel bypasses the HTTP middleware that normally enforces them.
-
Validate every frame: maximum size, JSON schema, allowed
typevalues. A data channel is an untrusted input stream. -
Issue short-lived TURN credentials, deny relaying into private ranges, and cap relay bandwidth per allocation.
-
Treat DTLS as the encryption layer: data is encrypted in transit but a TURN server and your own peer still see the plaintext after decryption, so the privacy properties are those of your service, not of peer-to-peer.
-
Close peers on disconnect, enforce an idle timeout, and bound the number of concurrent peers per process; export
connectionStatecounts andbufferedAmountas metrics (see LangChain: Observability). -
Test the unhappy paths: UDP blocked, TURN unreachable, an ICE restart in the middle of an answer.
-
Accessibility is unchanged by the transport: keep the streamed transcript out of any ARIA live region and announce only state changes through a separate status element, as on Embedding a Web Chat Widget (see also Web Accessibility).
References
-
MDN, RTCDataChannel and RTCPeerConnection.createDataChannel()
-
IETF, RFC 8831: WebRTC Data Channels and RFC 8832: WebRTC Data Channel Establishment Protocol
-
IETF, RFC 8445: Interactive Connectivity Establishment (ICE), RFC 8489: STUN and RFC 8656: TURN
-
aiortc, documentation and repository
-
coturn, repository and wiki