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").

sequenceDiagram participant B as Browser participant S as Signalling (FastAPI WebSocket) participant P as aiortc peer (Python) participant T as STUN / TURN (coturn) participant C as DocsAssistant core B->>S: open WebSocket, send bearer token S->>S: authenticate, derive userId B->>T: STUN binding request T-->>B: server-reflexive candidate B->>B: createDataChannel("chat"), createOffer, setLocalDescription B->>S: offer (SDP) S->>P: setRemoteDescription(offer) P->>T: gather candidates (STUN / TURN) P->>P: createAnswer, setLocalDescription P-->>S: answer (SDP with candidates) S-->>B: answer B->>B: setRemoteDescription(answer) B-->>P: ICE connectivity checks, DTLS, SCTP Note over B,P: data channel "chat" opens, signalling socket can close B->>P: {"type":"ask","text":"How do I..."} P->>C: ask(conversationId, userId, text) C-->>P: token, token, citation, done P-->>B: {"type":"token"}, {"type":"citation"}, {"type":"done"}

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

ordered

true

Messages are delivered in the order they were sent. With false, a lost message does not hold back later ones (no head-of-line blocking on that channel).

maxRetransmits

unset

Maximum number of retransmission attempts for a message; 0 means send once and never retry. Setting it makes the channel partially reliable.

maxPacketLifeTime

unset

Maximum time in milliseconds during which retransmission is attempted. Mutually exclusive with maxRetransmits: supplying both is an error.

negotiated and id

false, auto

When negotiated is true both sides create the channel with the same numeric id and no in-band open message is exchanged, which is handy when the server needs a channel without waiting for the datachannel event.

protocol

empty

A free-form sub-protocol label, for example "docsassistant.v1", so the two sides can agree on the framing.

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

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

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

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

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 Last-Event-ID or a cursor, as in the streaming chat pages.

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 type values. 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 connectionState counts and bufferedAmount as 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).