pc.createDataChannel('game') gives you a reliable, ordered channel. That is a
TCP-shaped default sitting inside an API people reach for precisely because they want UDP
behaviour — and it is why so many browser multiplayer prototypes feel fine on a LAN and rubbery
over mobile data. One lost packet stalls everything behind it. This post is the configuration
half of data channels: the three options that change delivery semantics, how to split traffic
across channels, and the two limits — message size and send buffering — that only show up under
load.
What is underneath: SCTP, not UDP
A data channel is an SCTP stream running over DTLS over UDP. SCTP is the interesting part,
because it gives you two things TCP cannot: many independent streams inside one association, so
a stalled file transfer does not delay your chat, and partial reliability (PR-SCTP,
RFC 3758) which lets a message be abandoned after a retransmission count or a time budget.
WebRTC exposes exactly those capabilities through three createDataChannel options:
ordered, maxRetransmits and maxPacketLifeTime.
The detail worth knowing, from RFC 8831: reliability is a property of an individual SCTP message, not of the stream. The browser applies your channel's setting per message, which is why "unreliable" does not mean "lossy transport" — it means "do not spend time recovering this particular message".
The three knobs, precisely
ordered(defaulttrue). When true, messages are delivered to the receiver in send order, so a gap blocks everything behind it until it is filled. Set it tofalseand messages surface as soon as they arrive.maxRetransmits(defaultnull= infinite). A count of retransmission attempts before the message is abandoned.0means send once, never retry.maxPacketLifeTime(defaultnull). A time budget in milliseconds; SCTP stops retransmitting after it expires.
You may set one of the two reliability options. Passing both throws a
TypeError from createDataChannel. Omitting both gives full reliability.
And the combination the spec calls out by name — ordered: false with
maxRetransmits: 0 — is the one that "provides a UDP-like service where each user
message is sent exactly once and delivered in the order received".
Split channels by delivery requirement, not by feature
The mistake I see most often is one channel carrying everything, with a type field
in the payload. Channels are cheap and SCTP streams are independent, so split them by what the
data needs:
// 60 Hz snapshots: newest supersedes oldest, so never pay for recovery
const state = pc.createDataChannel('state', {
ordered: false,
maxRetransmits: 0
});
// discrete events that must not be lost or reordered
const events = pc.createDataChannel('events'); // reliable + ordered default
// time-bounded: worth one retry, worthless after 50 ms
const fx = pc.createDataChannel('fx', { ordered: false, maxPacketLifeTime: 50 });
The rule of thumb: if a newer message makes an older one irrelevant, the channel should be unreliable and unordered. Positions, rotations, velocities, cursor coordinates, spectator counts. If a message is a fact that must arrive exactly once — a shot fired, a payment confirmed, a chat line — it belongs on the reliable channel. Mixing them means your cheapest traffic delays your most important traffic.
One setup detail that removes a whole class of race conditions: negotiated: true.
By default a channel is negotiated in-band — one side calls createDataChannel, the
other waits for ondatachannel — which means your code has to handle "the channel
does not exist yet". If both sides agree on ids up front you skip that entirely:
// both peers, identical code
const events = pc.createDataChannel('events', { negotiated: true, id: 0 });
const state = pc.createDataChannel('state', { negotiated: true, id: 1, ordered: false, maxRetransmits: 0 });
Message size: the limit that bites in production
There is no single number, which is exactly why this breaks late. The ceiling is negotiated with
the max-message-size SDP attribute (RFC 8841); if it is absent, the assumed default
is 64 KB, and 0 means "any size, limited by memory". Most current
browsers will accept at least 256 KB. But size is not just about acceptance:
- Head-of-line blocking across channels. Without message interleaving (RFC 8260), one large message on one channel monopolises the association and adds latency to every other channel. Your 40 KB texture chunk delays your 24-byte position update.
send()throws. ATypeErrorif the data is too large for the peer to receive, and anOperationErrorif the data would need buffering and there is no room.- Interop. ~16 KB chunks remain the safe, boring choice for anything bulk — file transfer, asset streaming, state dumps. Chunk it, number the chunks, reassemble.
For a game snapshot none of this should be near the limit anyway. If a single update is over a kilobyte, the fix is usually the encoding, not the transport: pack fields into a typed array instead of JSON, quantise positions, and send only what changed. I wrote about the arithmetic of that in tick rate and bandwidth budgeting.
Backpressure: bufferedAmount is not optional
Outbound data is buffered automatically, and you cannot set the buffer size — but you can read
channel.bufferedAmount and you can be told when it drains. That is the whole
flow-control API:
channel.bufferedAmountLowThreshold = 65535;
channel.onbufferedamountlow = () => pump(); // resume producing
function trySend(bytes) {
if (channel.bufferedAmount > 512 * 1024) return false; // over ceiling: drop or pause
channel.send(bytes);
return true;
}
The correct reaction to a full buffer depends on the channel. For a file transfer, pause the
reader and wait for bufferedamountlow. For live state, drop the older
update — queueing stale snapshots is how you build a channel that is reliable, ordered and
seconds behind reality. Watch bufferedAmount in your own telemetry too: a steadily
rising value is the earliest honest signal that you are sending faster than the link allows,
long before users describe it as lag.
Failure modes checklist
- Reliable-ordered default left on a 60 Hz state channel → rubber-banding under any loss.
- Both
maxRetransmitsandmaxPacketLifeTimeset →TypeErrorat channel creation. - One channel for everything → bulk traffic blocking gameplay traffic.
- Messages over the negotiated
max-message-size→send()throws on some peers only, so it passes local testing. - No
bufferedAmountcheck → growing latency, thenOperationError. - In-band channel creation with no
ondatachannelguard → race on connect; usenegotiated: truewith fixed ids. - Unordered channel with no sequence number in the payload → you cannot tell a late packet from a new one. Always stamp a tick or sequence and discard anything older than what you have applied.
That last point is the one people miss when they finally switch ordered off. The
transport stops guaranteeing order, which means your code now owns ordering. It is two
lines — a tick counter in the packet and an if (tick <= lastApplied) return; at
the receiver — and without them, unordered delivery quietly makes your simulation jitter in a
new and more confusing way.
Test the way the network actually behaves, not the way your LAN does. Throttle to 200 ms RTT with 2% loss, then watch whether gameplay degrades gracefully or collapses. A well-split set of channels gets fuzzier under those conditions; a single reliable channel simply stops.