Over WebRTC data channels, a browser game sends 2 MB map snapshots beside small inputs; why do the inputs stall, and how should the snapshots be sent?
answer
- one association, one congestion window
- fragments occupy consecutive sequence numbers
- 16 KB without interleaving
- max-message-size and a TypeError
- bufferedAmount as backpressure
basics
~20 sBase SCTP cannot interleave messages, so a 2 MB message's fragments monopolise the association and inputs on every channel wait; it may also exceed maxMessageSize and throw. Send snapshots in chunks of about 16 KB and pace them with bufferedAmount.
solid answer
~40 sEvery data channel on a peer connection shares one SCTP association and one congestion window. RFC 4960 SCTP cannot interleave user messages, so once a sender starts a 2 MB message its fragments go out ahead of messages on every other stream; RFC 8260 calls this sender-side head-of-line blocking and adds the I-DATA chunk to fix it, but support is negotiated per association. RFC 8831 says that without interleaving a sender SHOULD keep messages to 16 KB. Size is capped too: the peer's `max-message-size` SDP attribute (RFC 8841, default 64K) feeds `RTCSctpTransport.maxMessageSize`, and `send()` throws a `TypeError` above it. So split each snapshot into ~16 KB chunks with an offset header on a reliable ordered channel, keep inputs on their own unordered channel, and pace the chunks with `bufferedAmount`, `bufferedAmountLowThreshold` and the `bufferedamountlow` event.
code
pseudocode · 17 linesHEADER_BYTES = 12 // snapshot id, offset, total length
// latestSnapshotId: id of the newest snapshot the game has produced
CHUNK = 16 * 1024 - HEADER_BYTES
HIGH_WATER = 1024 * 1024
inputs = pc.createDataChannel("inputs", {ordered: false, maxRetransmits: 0})
snapshots = pc.createDataChannel("snapshots") // reliable, ordered
snapshots.bufferedAmountLowThreshold = 256 * 1024
function sendSnapshot(id, bytes):
for offset from 0 to length(bytes) - 1 step CHUNK:
if snapshots.bufferedAmount > HIGH_WATER:
await next "bufferedamountlow" event on snapshots
if id < latestSnapshotId:
return // superseded: stop queuing
chunk = bytes[offset : min(offset + CHUNK, length(bytes))]
snapshots.send(header(id, offset, length(bytes)) + chunk)go deeper
Recall that all data channels on a connection share one SCTP association, and that a data channel message has a maximum size.
Explain how base SCTP fragments a large message, why that blocks other streams, and how max-message-size becomes maxMessageSize and a TypeError.
Design the transfer: separate channels by class, chunk to 16 KB, pace with bufferedAmount and bufferedamountlow, and drop superseded snapshots instead of trusting interleaving.
Decide which data deserves the peer path at all; bulk content may belong on ordinary HTTP while the data channel keeps the latency-critical traffic.
## Why the inputs stall Every `RTCDataChannel` on one peer connection is a pair of streams inside **one SCTP association**, carried in one DTLS connection. Two facts about that association explain the stall: - **Shared congestion window.** RFC 8831 notes that SCTP runs congestion control per association, so all streams share one window. A 2 MB reliable transfer uses that window too. - **No interleaving in base SCTP.** In RFC 4960 the Transmission Sequence Number (TSN) both identifies each DATA chunk for reliable transfer and sequences a message's fragments for reassembly, and the protocol requires all fragments of a user message to have consecutive TSNs. Once the sender starts a large message, other messages wait. RFC 8260 calls this **sender-side head-of-line blocking**. So putting the snapshot on its own channel does **not** isolate it: separate streams give separate *ordering*, but they share one TSN sequence and one congestion window. A small input sent while a 2 MB snapshot is in flight waits for the snapshot's remaining fragments. ## Interleaving, and why you cannot count on it RFC 8260 adds the **I-DATA chunk**, which carries a Message Identifier (MID) and a Fragment Sequence Number (FSN) so that fragments of different messages can interleave, plus stream schedulers that choose which stream sends next. RFC 8831 says interleaving **SHOULD** be used — and also says that **as long as it is not supported, the sender SHOULD limit messages to 16 KB** to avoid monopolising the association. I-DATA support is negotiated when the association is set up, and whether a given peer supports it is an implementation matter, so an application that wants predictable latency chunks anyway. RFC 8831 also defines a per-channel priority for weighted fair queueing, and DCEP carries it in `DATA_CHANNEL_OPEN`; the `RTCDataChannelInit` dictionary in WebRTC 1.0 has no member for it, so the application cannot rely on priority to rescue its inputs. ## How big a message may be | Source | What it says | |---|---| | RFC 8841 `max-message-size` | the largest SCTP user message the endpoint is willing to **receive**; `0` means any size; absent means **64K** | | W3C `RTCSctpTransport.maxMessageSize` | the smaller of the remote `max-message-size` (65536 if absent) and what the local side can send, with `0` treated as unlimited | | W3C `send()` | throws a **`TypeError`** if the data exceeds `maxMessageSize`; throws an **`OperationError`** if the send buffer has no room | A peer that omits the attribute caps you at 64K, so a 2 MB `send()` throws rather than quietly fragmenting. RFC 8831 adds that a receiver must be prepared for a sender attempting arbitrarily large messages — the limit protects receivers too. ## Sending the snapshot properly 1. **Separate the classes.** Inputs on an unordered, partially reliable channel; snapshots on a **reliable, ordered** channel so every chunk arrives once and in sequence. 2. **Chunk.** Split each snapshot into messages of at most 16 KB, each with a small application header — snapshot id, offset, total length — so the receiver can reassemble and discard a superseded snapshot. 3. **Pace.** `bufferedAmount` counts application bytes queued by `send()` that the transport has not yet sent. Stop sending above a high-water mark, set `bufferedAmountLowThreshold`, and resume on the `bufferedamountlow` event, which fires when `bufferedAmount` falls from above the threshold to at or below it. The threshold starts at zero. 4. **Supersede.** If a newer snapshot is ready before an older one finishes, stop queuing the old one's remaining chunks; a reliable channel will still deliver what was already sent. ## What pacing does and does not fix - **It does** keep the local queue short, so a burst of snapshot chunks does not sit ahead of inputs for seconds. - **It does** prevent `send()` throwing `OperationError` when the send buffer fills. - **It does not** make the network faster: if snapshots need more bandwidth than the path offers, they crowd out inputs within the shared congestion window. - **It does not** replace a smaller payload: delta snapshots or a lower snapshot rate are the real cure for a path that is simply too narrow. The 16 KB figure is RFC 8831's guidance for associations without interleaving; the high-water mark and the low threshold in any sender are tuning values to measure, not constants from a specification.
- What does an RTCDataChannel's bufferedAmount measure, and why should a sender watch it?It counts bytes of application data queued with send() that the underlying transport has not yet sent, excluding protocol framing and operating-system buffers. Sending faster than the path drains only lengthens the queue, delays everything behind it and can make send() throw OperationError when buffer space runs out. A high-water check plus bufferedAmountLowThreshold and the bufferedamountlow event gives the sender backpressure.
- If both peers negotiate RFC 8260 interleaving, can the game drop chunking?Only partly. I-DATA lets fragments of different messages interleave, which removes the monopolisation. But the peer's max-message-size still caps each message, a 2 MB reliable message still consumes the shared congestion window, and a superseded snapshot cannot be abandoned midway. Chunking keeps working whichever way the association negotiated.
saying these in an interview costs you the question
- Putting the snapshot on its own data channel isolates it from the inputs.
- SCTP always interleaves messages from different channels, so size never matters.
- send() quietly fragments any message, however large it is.
- Without a max-message-size attribute there is no message size limit.
- bufferedAmount reports the bytes the peer has already received.