WebRTC DataChannel tuning: ordered vs unordered, maxRetransmits and message size

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 (default true). When true, messages are delivered to the receiver in send order, so a gap blocks everything behind it until it is filled. Set it to false and messages surface as soon as they arrive.
  • maxRetransmits (default null = infinite). A count of retransmission attempts before the message is abandoned. 0 means send once, never retry.
  • maxPacketLifeTime (default null). 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".

one peer connection · three SCTP streams · independent head-of-line DTLS / SCTP over UDP 'events' — ordered: true, reliable fire · score · death · chat — a gap here waits for the retransmit 'state' — ordered: false, maxRetransmits: 0 60 Hz position — newest wins, loss is free, never stalls 'fx' — ordered: false, maxPacketLifeTime: 50 one quick retry, then the data is stale — discard it backpressure is per channel: watch bufferedAmount, resume on bufferedamountlow (threshold 65535)

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. A TypeError if the data is too large for the peer to receive, and an OperationError if 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 maxRetransmits and maxPacketLifeTime set → TypeError at 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 bufferedAmount check → growing latency, then OperationError.
  • In-band channel creation with no ondatachannel guard → race on connect; use negotiated: true with 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.

Related
→ Tick rate, interpolation and lag compensation → Client-side prediction and rollback netcode → WebSocket vs SSE vs WebTransport → Sending files with no internet → Portfolio & projects
How do I make a WebRTC data channel unreliable like UDP?

Create it with { ordered: false, maxRetransmits: 0 }. RFC 8831 states that limiting retransmissions to zero combined with unordered delivery gives a UDP-like service where each message is sent exactly once and delivered in the order it is received — no retransmission delay, no head-of-line blocking.

What is the difference between maxRetransmits and maxPacketLifeTime?

Two ways to say "give up": a retransmission count versus a millisecond budget. You may set only one — specifying both throws a TypeError. Use a count when you think in packets, a lifetime when the data has a real deadline. Setting neither leaves the channel fully reliable.

How large can a data channel message be?

The ceiling is negotiated via the max-message-size SDP attribute (RFC 8841); absent, 64 KB is assumed, and 0 means any size. Most browsers accept 256 KB or more, but without RFC 8260 interleaving a big message blocks your other channels — so chunk bulk data at around 16 KB.

Should I use one data channel or several?

Several, split by delivery requirement: reliable-ordered for events and chat, unordered-unreliable for high-frequency state, a separate reliable channel for bulk transfer. SCTP streams are independent, so they do not block each other, and negotiated: true with agreed ids removes the in-band handshake.

How do I avoid overflowing the send buffer?

Check channel.bufferedAmount before sending, set bufferedAmountLowThreshold (65535 is a sane start) and resume on the bufferedamountlow event. Ignore it and send() eventually throws OperationError. For live state, drop the stale update instead of queueing it.