Reliable vs Unreliable Data Channels for Game State
Real-time multiplayer games push two very different kinds of data over a peer connection: high-frequency positional state that is obsolete the moment a newer packet arrives, and discrete events (a shot fired, a door opened, a score change) that must never be lost. This guide is part of the Data Channels & SCTP guide, and it resolves the exact decision of which RTCDataChannel reliability profile to use for each traffic class so you minimize perceived latency without dropping anything that matters.
Context & Trade-offs
The core trade-off is head-of-line blocking versus delivery guarantees. A reliable, ordered channel behaves like TCP: a single lost packet stalls every later message on that stream until SCTP retransmits it. For positional state sent at 20–60 Hz, that stall is the worst possible outcome — by the time the retransmitted “where the player was 50 ms ago” packet arrives, three fresher positions have already been queued behind it. The newest state always supersedes the old, so a dropped packet costs nothing while a stalled stream costs visible rubber-banding.
The numbers make the case concrete. On a path with 1–3% packet loss and a 60 ms round trip, a reliable channel adds one full RTT of recovery latency (roughly 60–120 ms) to every packet behind a loss, and that delay compounds at high send rates. An unreliable channel with maxRetransmits: 0 simply drops the lost packet and delivers the next one on schedule, holding update latency to the raw network delay. For positional state you want ordered: false plus maxRetransmits: 0. For events you want a separate reliable ordered channel so a lost “player died” message is always recovered, accepting that recovery costs an RTT — events are infrequent enough that the occasional retransmit stall is invisible.
Consider what head-of-line blocking actually does at 60 Hz. Each tick is about 16.7 ms apart. If a single position packet is lost on a reliable ordered channel, SCTP holds back every later packet until the retransmission completes one RTT later — at 60 ms RTT that is roughly four ticks’ worth of positions queued and then delivered in a burst. The remote player visibly freezes, then snaps forward (the classic rubber-band). With maxRetransmits: 0 the lost packet is simply gone; the next tick’s position arrives 16.7 ms later as usual and the player keeps moving smoothly. Because the newest position fully describes the player’s current state, the missing intermediate sample is never needed. This is the entire argument for unreliable positional state in one sentence: stale data has no value, so trading occasional loss for never stalling is always the right call for state that supersedes itself.
Events invert that logic. A “score +1” or “player picked up the flag” message is not superseded by anything — drop it and the two clients permanently disagree about the game world. These messages are also infrequent (a handful per second at most), so even if one is lost and recovered a full RTT later, the player rarely notices a 60–120 ms delay on a discrete event, and there is nothing queued behind it to stall. That asymmetry — frequent superseding state versus rare must-arrive events — is why the right architecture is always two channels, not one compromise channel tuned in the middle.
There is a third, narrower category worth naming: time-bounded effects such as a brief sound trigger or a transient particle spawn, where you want one quick recovery attempt but no value in delivering the message after it is visually irrelevant. maxPacketLifeTime: 50 fits these: SCTP retransmits for up to 50 ms and then discards. Use it sparingly; most game traffic falls cleanly into “supersedes itself” (unreliable) or “must arrive” (reliable), and adding a third channel only pays off when you genuinely have time-sensitive but loss-tolerant effects.
| Traffic class | Channel config | Why |
|---|---|---|
| Position / rotation / velocity | ordered: false, maxRetransmits: 0 |
Newest packet supersedes old; loss is free, stalls are not |
| Game events (fire, score, death) | ordered: true, reliable |
Must arrive exactly once, order matters |
| Time-bounded effects | ordered: false, maxPacketLifeTime: 50 |
Tolerate one quick retransmit, then discard |
Run both channels on the same RTCPeerConnection — they share one SCTP association, so the unreliable state channel never blocks the reliable event channel and vice versa. The signaling that sets up that connection is covered in WebSocket Signaling Implementation.
One subtlety worth budgeting for: both channels still share the association’s congestion window, so a burst of reliable events being retransmitted under loss can briefly reduce the throughput available to the state channel. In practice event traffic is tiny relative to 60 Hz state, so the effect is negligible, but it is the reason you should keep events lean (small binary or compact JSON) and avoid sending bulk data on the same connection without rate-limiting it. If you must move a large payload mid-match — say a downloadable replay — give it its own throttled channel and cap its outbound rate so it cannot starve the state stream that the gameplay depends on.
Minimal Runnable Implementation
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
// Unreliable + unordered: 60 Hz positional state. Lost packets are dropped, never resent.
const stateChannel = pc.createDataChannel('state', {
ordered: false,
maxRetransmits: 0 // datagram semantics: send once, no retransmission, no HOL blocking
});
// Reliable + ordered: discrete gameplay events that must not be lost.
const eventChannel = pc.createDataChannel('events', {
ordered: true // omit reliability options -> fully reliable (TCP-like)
});
stateChannel.binaryType = 'arraybuffer';
// Pack position into a compact binary frame; JSON would waste bytes at 60 Hz.
function sendPosition(x, y, z, yaw) {
if (stateChannel.readyState !== 'open') return;
// Drop our own outbound update if the queue is backing up — newer state is coming anyway.
if (stateChannel.bufferedAmount > 64 * 1024) return;
const buf = new ArrayBuffer(16);
const view = new DataView(buf);
view.setFloat32(0, x); view.setFloat32(4, y);
view.setFloat32(8, z); view.setFloat32(12, yaw);
stateChannel.send(buf); // ~16 bytes/packet * 60 Hz = ~1 KB/s per peer
}
function sendEvent(evt) {
if (eventChannel.readyState !== 'open') return;
eventChannel.send(JSON.stringify(evt)); // reliable: SCTP retransmits until delivered
}
stateChannel.onmessage = (e) => {
const v = new DataView(e.data);
applyRemotePosition(v.getFloat32(0), v.getFloat32(4), v.getFloat32(8), v.getFloat32(12));
};
eventChannel.onmessage = (e) => applyRemoteEvent(JSON.parse(e.data));
Reproduction Steps & Debugging Log Patterns
- Open the connection and log both channels’
readyState; expectstateandeventsto each reach'open'after the SCTP association completes. - Start streaming positions at 60 Hz and log
stateChannel.bufferedAmountonce per second. On a healthy link it stays near 0; a steadily climbing value means you are outrunning the link and should drop or throttle outbound state. - Throttle the network in Chrome DevTools (add 3% packet loss, 60 ms latency) and compare. The unreliable channel keeps delivering the latest position; if you temporarily switch it to reliable+ordered you will see message timestamps bunch and then burst — the signature of head-of-line blocking.
- Open
chrome://webrtc-internalsand inspect thedata-channelstats: the unreliable channel showsmessagesSentfar exceedingmessagesReceivedunder loss (dropped packets, as intended), while the event channel showsmessagesSent === messagesReceived. - Confirm the SCTP transport reports
state: 'connected'and that a forced packet loss never closes either channel.
Expected console output under 3% loss looks like state bufferedAmount=0 sent=1800 recv=1746 (gaps are normal) alongside events sent=12 recv=12 (no gaps ever). A growing bufferedAmount on the state channel is the one red flag worth alerting on.
When interpreting these stats, remember the two channels tell opposite stories on purpose. A sent/received gap on the state channel is success — it proves loss is being dropped rather than retransmitted, which is the whole point. The same gap on the event channel is a bug, because reliable delivery should make those counters match exactly. Build your dashboards to alert on a gap for events and on rising bufferedAmount for state, and to ignore the (expected) gap for state. If you ever see the event channel’s bufferedAmount climb, your event rate has exceeded what the link can reliably carry, which usually means an event is being emitted in a hot loop and should be coalesced before it is sent.
Common Implementation Mistakes
- Putting positional state on a reliable channel. This reintroduces TCP-style head-of-line blocking; one lost packet delays every later position, producing visible rubber-banding under even light loss.
- Putting events on the unreliable channel. A dropped “player died” or “score +1” message silently desyncs game state. Events belong on the reliable ordered channel.
- Sending JSON at 60 Hz. Text framing wastes bandwidth and CPU; pack positional state into a fixed binary layout with
DataViewand setbinaryType = 'arraybuffer'. - Never checking
bufferedAmounton the state channel. Even unreliable channels ride SCTP flow control; if you outrun the link the queue grows. Drop your own stale outbound updates rather than buffering them. - Assuming
maxRetransmits: 0means UDP with no congestion control. It still flows over SCTP, so a flood can still build congestion on the shared association. Cap your send rate to the game’s tick rate.
FAQ
Can one peer connection carry both reliable and unreliable channels?
Yes. Create both channels on the same RTCPeerConnection; they multiplex onto one SCTP association as independent streams, so the unreliable state channel and the reliable event channel never block each other.
Is maxRetransmits: 0 the same as maxPacketLifeTime: 0?
Effectively for “never retransmit,” but they are distinct knobs and you may set only one. Use maxRetransmits: 0 for strict drop-on-loss positional state, and a small maxPacketLifeTime (for example 50 ms) when you can tolerate one quick recovery attempt before discarding.
Should I still interpolate if the channel is unreliable?
Yes. Client-side interpolation and a small jitter buffer smooth over the dropped packets that unreliable delivery intentionally allows, which is exactly why dropping rather than retransmitting old state produces a better-feeling game.
Related: see the parent Data Channels & SCTP guide for the full reliability matrix and buffering controls, and the WebSocket Signaling Implementation guide for the offer/answer exchange that brings the connection up before any channel opens.