Most writing about WebRTC is about calls: cameras, codecs, conferencing servers. But WebRTC also gives the browser a second, quieter capability that no other web API offered for years: a bidirectional message channel over UDP, encrypted, able to drop late messages instead of waiting for them, and able to run directly between two browsers. That is the data channel, and it is the part of WebRTC that belongs alongside WebSocket and WebTransport in a bidirectional-transport toolkit.

This article treats the data channel as a transport and nothing else. It explains the protocol stack beneath it, how channels are created, the four delivery modes, message size limits, why one channel does not block another, how to apply backpressure, and how to terminate data channels on your own server for low-latency client-server traffic. The full media stack, TURN operation and conferencing topologies are in the WebRTC deep dive; the offer and answer exchange you must build is in the signaling article.

Advertisement

The stack under a data channel

A data channel is a stream of SCTP, the Stream Control Transmission Protocol. SCTP was designed for telephony signaling and has two properties TCP lacks: a single association carries many independent streams, and each message can be sent ordered or unordered, reliably or with limited retransmission. Browsers cannot send raw SCTP packets past NATs, so WebRTC runs SCTP in user space, inside a DTLS session, over the UDP path ICE has found. RFC 8831 defines the data channel model and RFC 8832 the small protocol used to open channels.

In the SDP offer, data appears as its own media section, m=application 9 UDP/DTLS/SCTP webrtc-datachannel, with an a=sctp-port attribute naming the SCTP port and an optional a=max-message-size, both defined in RFC 8841. With BUNDLE, that section shares one ICE path and one DTLS session with any audio and video. Because DTLS keys are checked against the fingerprint in the SDP, data is encrypted end to end between the two endpoints even when a TURN server relays the packets.

A data channel is an SCTP stream inside DTLS, riding the ICE-selected UDP pathRTCDataChannel APIsend(), onmessage, bufferedAmountDCEP (RFC 8832)OPEN / ACK on the stream itselfSCTP (RFC 8831)streams, ordering, partial reliabilityDTLSencryption, fingerprint from SDPICE over UDPdirect, reflexive or TURN relayStream 0: chatordered, reliableStream 2: inputunordered, maxRetransmits 0Stream 4: stateunordered, maxPacketLifeTime 100Stream 6: filesordered, reliable, chunkedone associationLoss on one stream does not stall the others;each stream picks its own ordering and reliabilitySignaling (your server)SDP carries m=application ... UDP/DTLS/SCTP webrtc-datachannel, a=sctp-port, a=max-message-size
One SCTP association inside one DTLS session carries every channel. Each channel is a stream with its own ordering and reliability settings, so a lost packet on one does not stall the others.

How a channel is born: DCEP or pre-negotiated

By default, pc.createDataChannel(label, options) opens a channel in-band. The browser picks a stream ID, sends a DATA_CHANNEL_OPEN message on that stream carrying the label, protocol and reliability settings, and the remote side fires a datachannel event and acknowledges. To stop both peers grabbing the same ID at once, RFC 8832 splits the space by DTLS role: the DTLS client uses even stream IDs and the DTLS server uses odd ones.

The alternative is negotiated: true with an explicit id between 0 and 65534. No open message is sent. Both sides call createDataChannel with identical settings and the same ID, and the channel is usable as soon as SCTP is up. Pre-negotiation removes a round trip and the race between the open message and the first data message, and it makes channel layouts declarative. The price is that both sides must agree in code, so treat the layout as a versioned contract.

// Both peers run this. IDs and settings must match exactly on both sides.
const pc = new RTCPeerConnection({ iceServers });
const chat  = pc.createDataChannel("chat",  { negotiated: true, id: 0 });
const input = pc.createDataChannel("input", { negotiated: true, id: 2,
                                              ordered: false, maxRetransmits: 0 });
const state = pc.createDataChannel("state", { negotiated: true, id: 4,
                                              ordered: false, maxPacketLifeTime: 100 });
for (const ch of [chat, input, state]) ch.binaryType = "arraybuffer";
Advertisement

Four delivery modes

Two independent knobs define a channel. ordered controls whether messages are delivered in send order. Reliability is either full (the default), limited by a retransmission count with maxRetransmits, or limited by time with maxPacketLifeTime in milliseconds. You may set one of the two limits, not both.

ModeSettingsBehaviourUse for
Reliable, ordereddefaultsLike a message-framed TCP streamChat, commands, file transfer
Reliable, unorderedordered: falseEverything arrives, no waiting on gapsIndependent events, idempotent updates
Partial, count-limitedordered: false, maxRetransmits: 0Fire and forget, at most onceInput samples, cursor positions
Partial, time-limitedordered: false, maxPacketLifeTime: 100Retries until stale, then dropsGame or telemetry state snapshots

Unreliable delivery is only useful if the application tolerates gaps. Design messages so each one stands alone: send absolute positions, not deltas; send a full snapshot periodically even if most messages are deltas; include a sequence number so the receiver discards anything older than what it already applied. Ordered plus partially reliable is legal but rarely useful, because an ordered stream waiting on a message that is later abandoned gains little over an unordered one.

Message size and framing

Data channels are message-oriented: one send() arrives as one message event, so you never parse a byte stream. The ceiling comes from SDP. RFC 8841 says that if a=max-message-size is absent the default is 64K, and a value of 0 means any size the endpoint can hold in memory. Browsers advertise their own limits, and the effective limit is exposed as pc.sctp.maxMessageSize after negotiation. Read it at runtime; do not hard-code a number from a blog post, and a send larger than the limit throws.

Large messages also hurt latency on other channels, which the next section explains, so chunk anything big to 16 to 64 KiB and reassemble at the receiver. Put a small header on each chunk with a transfer ID, an index and a total count.

Head-of-line blocking, and where it remains

Within one ordered reliable stream, a lost packet stalls later messages on that stream until the retransmission arrives, exactly as TCP does. Across streams there is no such stall: loss on the files channel does not delay the input channel. That is the main architectural reason to use several channels rather than one channel with a type field.

One coupling remains. Classic SCTP sends a large message's fragments contiguously, so a 1 MiB message on one stream can delay a small urgent message on another until it has been sent. The user-message interleaving extension, I-DATA (RFC 8260), removes this, and RFC 8831 says it SHOULD be used, but you should not assume both peers negotiate it. Without it, RFC 8831 advises keeping messages to 16 KB or less so one message cannot monopolise the association. Small chunks are the portable fix. All channels also share one congestion controller, so a bulk transfer still competes with latency-sensitive traffic for the path's capacity; rate-limit bulk channels in the application.

Backpressure: bufferedAmount

send() never blocks. It copies the message into a browser buffer and returns, and bufferedAmount reports how many bytes are queued. A loop that sends faster than the path drains will grow memory until the tab dies or the channel closes with an error. The fix is a high-water mark: stop sending above it, and resume on the bufferedamountlow event, which fires when the queue falls to bufferedAmountLowThreshold.

const HIGH = 1 << 20;          // pause above 1 MiB queued
const CHUNK = 16 * 1024;
async function sendFile(ch, buf, id) {
  ch.bufferedAmountLowThreshold = 256 * 1024;
  const total = Math.ceil(buf.byteLength / CHUNK);
  for (let i = 0; i < total; i++) {
    if (ch.bufferedAmount > HIGH) {
      await new Promise(r => ch.addEventListener("bufferedamountlow", r, { once: true }));
    }
    const head = new Uint32Array([id, i, total]);
    const body = new Uint8Array(buf, i * CHUNK, Math.min(CHUNK, buf.byteLength - i * CHUNK));
    const msg = new Uint8Array(12 + body.length);
    msg.set(new Uint8Array(head.buffer), 0);
    msg.set(body, 12);
    ch.send(msg);
  }
}

Unreliable channels need backpressure too. If the queue grows on the state channel, stop sending old snapshots and send only the newest one; a queued stale snapshot is pure waste.

Server-terminated data channels

A data channel does not need a browser at both ends. A server can be a WebRTC peer, which gives browser clients an encrypted, unordered, partially reliable channel to your backend. Before WebTransport, that was the only way to get UDP-like semantics from a browser to a server. Libraries that implement the full stack include Pion (Go), libdatachannel (C and C++), aiortc (Python) and werift (TypeScript).

A server with a public address does not need full ICE. If your library supports it, it can run ICE-lite, which RFC 8445 defines for endpoints that are always reachable: it answers connectivity checks but does not gather or probe. Check your library's documentation before relying on it. Clients behind restrictive firewalls may still need TURN over TCP or TLS on port 443 to reach it, which the NAT traversal article explains. Here is a minimal aiortc echo peer; the signaling endpoint that carries the SDP is plain HTTP.

from aiohttp import web
from aiortc import RTCPeerConnection, RTCSessionDescription

pcs = set()

async def offer(request):
    params = await request.json()
    pc = RTCPeerConnection()
    pcs.add(pc)

    @pc.on("datachannel")
    def on_channel(channel):
        @channel.on("message")
        def on_message(msg):
            channel.send(msg)                      # echo back on the same channel

    @pc.on("connectionstatechange")
    async def on_state():
        if pc.connectionState in ("failed", "closed"):
            await pc.close()
            pcs.discard(pc)

    await pc.setRemoteDescription(RTCSessionDescription(sdp=params["sdp"], type=params["type"]))
    await pc.setLocalDescription(await pc.createAnswer())   # gathers candidates first
    return web.json_response({"sdp": pc.localDescription.sdp, "type": pc.localDescription.type})

app = web.Application()
app.router.add_post("/offer", offer)
web.run_app(app, port=8080)

Operationally, a server peer is a stateful UDP service. Each connection holds DTLS and SCTP state on one process, so the load balancer cannot spray packets across instances. Route the signaling request to a specific instance and return that instance's own address in its candidates; keep UDP ports open in the security group; and drain instances by refusing new offers and waiting for connections to close.

Worked example: a channel layout for a multiplayer game

A browser game sends player input at 60 Hz and receives world state at 20 Hz from an authoritative server peer. Input messages are about 24 bytes; state snapshots are about 1.2 KB. Lay out four pre-negotiated channels: input (unordered, maxRetransmits 0), state (unordered, maxPacketLifeTime 100), chat (reliable, ordered), and assets (reliable, ordered, chunked, rate-limited).

The arithmetic: upstream input is 60 times 24 bytes, about 1.4 KB/s of payload, though per-packet overhead from SCTP, DTLS, UDP and IP dominates at this size, so batch two samples per packet if the overhead matters. Downstream state is 20 times 1.2 KB, about 24 KB/s per player. Each input carries the last state sequence the client applied, so the server can send deltas against a known baseline and fall back to a full snapshot when acknowledgements stop. A lost input sample is simply superseded by the next one 16 ms later; a lost snapshot is superseded 50 ms later. Nothing waits for a retransmission.

Data channel, WebSocket or WebTransport

PropertyWebSocketWebRTC data channelWebTransport
TransportTCP, one ordered streamSCTP over DTLS over UDPQUIC over UDP (HTTP/3)
Unreliable deliveryNoYes, per channelYes, datagrams
Peer to peerNoYesNo
SetupOne HTTP upgradeSignaling, ICE, DTLS, SCTPOne HTTP/3 session
Server ecosystemEverywhereSpecialised librariesGrowing

Choose WebSocket for ordinary client-server messaging; its setup is cheapest and every proxy understands it, as the WebSocket article shows. Choose the data channel when you need peer-to-peer transfer or must reach browsers without WebTransport support with unreliable delivery. Choose WebTransport for new client-server designs that want streams and datagrams without the WebRTC handshake, once your client base supports it.

Failure modes and operations

  • Silent sends before open. Calling send() before readyState is open throws. Queue in the application until the open event.
  • Mismatched pre-negotiated settings. Two sides with the same ID but different ordering produce confusing behaviour. Version the layout.
  • Path loss. When the network changes, ICE consent checks fail and the connection goes to disconnected, then failed. Call pc.restartIce() and renegotiate rather than tearing down; the channels survive an ICE restart.
  • Memory growth. Unbounded bufferedAmount is the most common production bug. Alert on it.
  • Blind spots. getStats() reports a data-channel entry per channel with messages and bytes sent and received; ship those with the candidate-pair round-trip time to see whether lag is network or application.

What to do next

  1. List your message types and assign each a delivery mode from the table above.
  2. Define the channel layout as pre-negotiated IDs in a shared, versioned module used by both ends.
  3. Make every unreliable message self-contained, with a sequence number and periodic full snapshots.
  4. Chunk large payloads to 16 to 64 KiB and read pc.sctp.maxMessageSize at runtime.
  5. Implement bufferedAmount backpressure on every channel, and alert on queue size.
  6. If you need a server peer, prototype with Pion, libdatachannel or aiortc on a public address (with ICE-lite if the library supports it), and measure it against WebTransport for your client base.
Key takeaway: A WebRTC data channel is an SCTP stream inside DTLS over the ICE path, and one connection can carry many channels, each with its own ordering and reliability. Pre-negotiate a channel layout, give each message type the weakest guarantee it tolerates, keep messages small and self-contained, and gate every send on bufferedAmount. Use data channels for peer-to-peer and unreliable browser traffic; prefer WebSocket for plain client-server messaging and WebTransport where clients support it.