Chunking: Sending a Whole File Over a Data Channel

You have a file in the browser. You want to send it to another browser. No upload server, no cloud storage, no relay. The bytes go straight from one peer's memory to the other's, over a single RTCDataChannel.

That channel runs on SCTP over DTLS over UDP. It is encrypted end to end, and once the connection is up, nothing in the data path transits a server. STUN helped the two peers find each other; after that, STUN is done. The file never touches it.

The problem is that you cannot hand the whole file to the channel and walk away. A file is one large Blob. The channel sends discrete messages, each with a size ceiling. The send side has a buffer that fills faster than the network drains it. Push too hard and the connection closes.

It helps to know what sits under the channel. RTCDataChannel is a message abstraction over SCTP. SCTP runs inside a DTLS session for encryption, and DTLS runs over UDP, which carries the packets between peers. Each layer adds framing to every message, so the bytes on the wire are always somewhat more than your payload. None of that is your concern at the API (you call send() with bytes and the bytes arrive), but it explains the two limits below. The message-size ceiling exists because SCTP has to fragment, transmit, and reassemble; the send buffer exists because SCTP cannot emit faster than UDP and congestion control allow. Chunking is the shape your transfer takes once you respect both.

So you cut the file into chunks. You send a small description first, then the chunks in order, then a marker that says you are done. The receiver collects the pieces and rebuilds the file. This page covers that loop in full: the size limits that force chunking, how to slice and read a Blob, how to pick a chunk size, the metadata handshake, sequencing and reassembly, progress on both ends, and the backpressure rule that keeps the send loop honest.

SENDER DATA CHANNEL RECEIVER File one Blob slice chunk 0 chunk 1 ··· chunk N file-meta (JSON) chunk bytes file-end (JSON) reliable · ordered chunk 0 chunk 1 ··· chunk N new Blob Blob download

Why one send() is not enough

Two limits stop you from sending a file in a single call.

SCTP has a maximum message size

The data channel delivers messages, not a byte stream. Each message has a maximum size. Browsers negotiate this during the SCTP handshake and advertise it through RTCSctpTransport.maxMessageSize. Modern Chrome and Firefox negotiate large values (often 256 KB or more), but the spec only guarantees support for messages up to 64 KiB across implementations. Anything larger risks failing on the peer that advertised the smaller ceiling.

A message that exceeds the negotiated maximum throws on send(), or worse, is silently dropped on some older paths. You cannot assume the other side accepts a 50 MB message. Treat 64 KiB as the safe interoperable upper bound, and stay well under it.

The negotiation is asymmetric, which is the trap. Each peer advertises the largest message it is willing to receive. The send ceiling you must respect is the value the other peer advertised, exposed to you as peerConnection.sctp.maxMessageSize. A browser that accepts 256 KB locally tells you nothing about what the remote accepts. If you size chunks against your own generous limit and the remote negotiated 64 KiB, every send fails, but only against that one peer. The same code works against a peer with a matching limit. That asymmetry is why the failure looks intermittent and why hardcoding 16 KiB sidesteps the whole class of bug.

There is a deeper reason to keep messages small. SCTP can fragment and reassemble large messages, but reassembly happens in memory on the receiver before your onmessage handler ever runs. A single huge message means the receiver buffers the whole thing before you see one byte. That defeats streaming, blocks progress reporting, and spikes memory. Small messages give you a steady flow you can measure and write as it arrives. A 1 GB file sent as one message would have to fully arrive and reassemble before your code runs once; the same file as 16 KiB chunks gives you roughly 65,000 handler calls you can act on individually.

The send buffer fills

send() does not block and does not wait for the network. It copies your bytes into an internal outgoing buffer and returns immediately. SCTP drains that buffer onto the wire as fast as congestion control allows.

If you call send() in a tight loop over a whole file, you enqueue megabytes in milliseconds. The buffer is not infinite. Chrome closes the data channel when bufferedAmount crosses roughly 16 MB. The connection drops mid-transfer and you get no useful error.

bufferedAmount is the count of bytes queued for sending but not yet handed to the network. You read it to know how full the buffer is. You must keep it below a threshold. That is backpressure, and the send loop has to obey it, covered below and in full in messaging reliability.

The buffer fills because send() and the network run at different speeds. Your loop reads from a local file and calls send() at memory speed, hundreds of megabytes per second. The network drains the buffer at whatever the path allows, often a few megabytes per second, sometimes far less on a congested or distant link. The gap accumulates in the buffer. Over a whole file the gap is enormous, so without pacing the buffer reaches its cap in the first fraction of a second and the channel dies. The chunk size does not save you here; a tight loop of 16 KiB sends fills the buffer just as fast as one big send. Only checking bufferedAmount between sends keeps it bounded.

Constraint Where it lives What it forces
Max message size RTCSctpTransport.maxMessageSize, ~64 KiB safe floor Cut the file into chunks
Send buffer cap bufferedAmount, ~16 MB hard limit in Chrome Pace the send loop
Receiver reassembly Memory on the receiving peer Keep chunks small, write as they arrive

Reading bytes out of a file

A file selected through <input type="file"> or drag-and-drop arrives as a File, which is a Blob with a name and a modified date. A Blob is immutable binary data. You never read all of it at once.

Blob.slice(start, end) returns a new Blob that references a byte range of the original. It does not copy data. It does not touch the disk. It just narrows the window.

const chunkBlob = file.slice(offset, offset + CHUNK_SIZE);

To send those bytes, you turn the slice into an ArrayBuffer. Two ways exist.

The modern way is Blob.arrayBuffer(), which returns a promise:

const buffer = await file.slice(offset, offset + CHUNK_SIZE).arrayBuffer();
channel.send(buffer);

The older way is FileReader, which uses callbacks:

const reader = new FileReader();
reader.onload = (e) => channel.send(e.target.result);
reader.readAsArrayBuffer(file.slice(offset, offset + CHUNK_SIZE));

Both produce an ArrayBuffer. Prefer arrayBuffer(): it composes with async/await and keeps the send loop linear. FileReader predates promises and forces you to thread state through event handlers.

Sending binary on the channel

RTCDataChannel.send() accepts a string, a Blob, an ArrayBuffer, or an ArrayBufferView (a typed array like Uint8Array). Send an ArrayBuffer for file chunks. You read the bytes yourself, so you control exactly what crosses the wire.

The receiver controls how it gets those bytes back through channel.binaryType. Set it to "arraybuffer":

channel.binaryType = "arraybuffer";

The default is "blob" in some browsers and "arraybuffer" in others. Set it explicitly on the receiving channel so event.data is always an ArrayBuffer. Do not rely on the default; it has differed across browsers historically.

Note the split. The library's src/salon/protocol.js helpers (send, broadcast, dispatcher) wrap every message in JSON.stringify. That is right for game state and control messages: small JSON objects with a type field. File chunks are different. They are raw binary. You send them with channel.send(arrayBuffer) directly, bypassing the JSON wrappers, because stringifying binary would balloon the size and corrupt the bytes. The metadata and the end marker are small JSON objects and can ride the JSON path; the chunk payloads are binary and do not.

There is a concrete cost to getting this wrong. If you ran a chunk through JSON.stringify, you would first have to convert the bytes to a string (typically base64), which inflates the payload by about a third and adds an encode step on the sender and a decode step on the receiver. You would also push a 16 KiB chunk past 21 KiB on the wire, eating into the message-size budget for no benefit. Sending the ArrayBuffer directly is both correct and cheaper. The rule is simple: structured control data goes through the JSON helpers; opaque binary goes straight to channel.send.

The two kinds of message coexist on one channel because the receiver can tell them apart by JavaScript type. A JSON message arrives as a string; a chunk arrives as an ArrayBuffer. The receiver branches on typeof event.data. This is why you do not need a second channel for control traffic: one channel carries both, and the type of event.data is the discriminator.

Choosing a chunk size

16 KiB (16384 bytes) is the standard chunk size for data channel transfers. It is the size most reference implementations use, and it is comfortably under every browser's maxMessageSize.

Why 16 KiB and not larger? Three reasons.

Interoperability. 16 KiB is below the 64 KiB interoperable floor, so it works against any peer regardless of what message size they negotiated. You never have to read maxMessageSize and adapt.

Backpressure granularity. Smaller chunks let you check bufferedAmount more often. With 16 KiB chunks, the buffer grows in 16 KiB steps, so you can hold it close to a target without overshooting. Large chunks overshoot the threshold in one jump.

Smooth progress. Smaller chunks update the progress bar more frequently and keep the receiver writing in small, steady increments.

The tradeoff is per-message overhead. Each message carries SCTP framing. Tiny chunks mean more messages, more framing, more onmessage calls, lower throughput. There is a floor below which overhead dominates and speed drops.

Chunk size Throughput Backpressure control Risk
1-4 KiB Low: overhead dominates Fine-grained None, just slow
16 KiB Good Good None: the safe default
64 KiB Higher Coarse At the interop ceiling
256 KiB+ Highest where supported Coarse, overshoots threshold May exceed peer's maxMessageSize

Start at 16 KiB. If you have measured both peers' maxMessageSize and you control both ends, you can raise it toward 64 KiB or beyond for throughput. Do not raise it blind. The size that fails will fail only against the peer that negotiated the smaller ceiling, which makes the bug intermittent and hard to reproduce. See large files for tuning under sustained transfer.

The metadata handshake

The receiver needs to know what is coming before the bytes arrive. It needs the file name to label the download, the total size to compute progress and to know when the transfer is complete, and the MIME type to reconstruct a Blob the browser treats correctly.

So the first message is metadata, sent as JSON:

send(channel, {
  type: "file-meta",
  name: file.name,
  size: file.size,
  mime: file.type,
});

This rides the JSON path because it is a small object, and it uses the type discriminator that dispatcher in src/salon/protocol.js keys on. The receiver's dispatcher routes file-meta to a handler that resets its state and prepares to collect chunks.

The metadata also lets the receiver decide before any bytes arrive. It can reject a transfer that is too large for available memory, refuse a MIME type it will not handle, or prompt the user to confirm the download by name. None of that is possible if the first thing you send is bytes. Sending a description first turns a blind byte stream into a transfer the receiver can reason about. Include only what the receiver needs: name, size, and type are enough for a download. If you add an integrity checksum, it usually travels at the end (you cannot hash the file faster than you read it, and computing it up front would delay the first chunk), so the checksum rides the file-end marker, not file-meta. See integrity.

After the metadata, the chunks flow as binary. The receiver knows the total size, so it can detect completion by counting received bytes against size. It does not strictly need a per-chunk header. The chunks arrive in order. SCTP in reliable, ordered mode (the data channel default) guarantees that, so the receiver appends them as they come without sequence numbers.

Ordering matters here. The default RTCDataChannel is reliable and ordered: every message arrives, exactly once, in the order sent. That is what file transfer needs, and it is why you do not have to number chunks or handle gaps. If you configure the channel as unordered or unreliable for a different reason, ordering breaks and you must add sequence numbers and reassemble by index. For files, keep the channel in its default reliable-ordered mode. The reliability modes are detailed in messaging reliability and the channel setup in data channels.

The sender: metadata, then a backpressure-aware chunk loop

The send loop reads a slice, sends it, advances the offset, and repeats until the offset reaches the file size. Before each send it checks bufferedAmount. If the buffer is too full, it stops and waits for the channel to drain.

How backpressure works

channel.bufferedAmount is the queued byte count. channel.bufferedAmountLowThreshold is a level you set. When the buffer drains below that threshold, the channel fires a bufferedamountlow event. You pause the loop when the buffer is high and resume on that event.

const CHUNK_SIZE = 16 * 1024;          // 16 KiB
const HIGH_WATER = 1 * 1024 * 1024;    // pause when buffer exceeds 1 MB

async function sendFile(channel, file) {
  channel.binaryType = "arraybuffer";
  channel.bufferedAmountLowThreshold = 256 * 1024; // resume when buffer drops below 256 KB

  // 1. Metadata first.
  send(channel, {
    type: "file-meta",
    name: file.name,
    size: file.size,
    mime: file.type,
  });

  // 2. Chunk loop with backpressure.
  let offset = 0;
  while (offset < file.size) {
    // Wait if the send buffer is too full. Overrunning it closes the channel.
    if (channel.bufferedAmount > HIGH_WATER) {
      await once(channel, "bufferedamountlow");
    }

    const slice = file.slice(offset, offset + CHUNK_SIZE);
    const buffer = await slice.arrayBuffer();
    channel.send(buffer);
    offset += buffer.byteLength;

    onProgress(offset, file.size); // bytes sent / total
  }

  // 3. End-of-file marker.
  send(channel, { type: "file-end" });
}

// Resolve once an event fires, then detach the listener.
function once(target, event) {
  return new Promise((resolve) => {
    target.addEventListener(event, resolve, { once: true });
  });
}

Three rules make this loop correct.

Check before send, not after. Read bufferedAmount before queueing the next chunk. If you check after, you have already enqueued past the limit.

Use the threshold and the event together. Set bufferedAmountLowThreshold to a level below HIGH_WATER. When the buffer crosses above HIGH_WATER, you await the bufferedamountlow event, which fires only once the buffer has drained below the threshold. The gap between the two levels stops you from pausing and resuming on every single chunk.

Advance by byteLength, not by CHUNK_SIZE. The last slice is shorter than a full chunk. Advancing by the actual bytes read keeps the offset exact and the progress count honest.

The send buffer is a hard wall, not a hint

Chrome closes the data channel when bufferedAmount crosses roughly 16 MB. There is no soft warning. The loop above keeps the buffer near 1 MB, two orders of magnitude under the wall, so a missed event or a slow network never reaches it. Treat the threshold as mandatory, not as an optimization.

The schematic shows the same loop as a flowchart: start, read a chunk, check backpressure, pause until bufferedamountlow if the buffer is full, otherwise send, then ask whether more data remains and loop or finish.

Start file transfer Read 16 KB chunk Check backpressure Send chunk Pauseuntil onbufferedamountlow More data? Done bufferedAmount < limit amount > limit resume Yes: next chunk No

The receiver: collect, track, reassemble

The receiver does three things. It reads the metadata. It collects each binary chunk. When the byte count matches the declared size, or the end marker arrives, it assembles the chunks into a Blob and hands it to the user.

Messages arrive on channel.onmessage. JSON metadata and binary chunks share one channel, so the handler branches on the data type: a string is JSON control, an ArrayBuffer is a chunk.

function receiveFile(channel, { onProgress, onComplete }) {
  channel.binaryType = "arraybuffer";

  let meta = null;        // { name, size, mime }
  let chunks = [];        // received ArrayBuffers, in order
  let received = 0;       // bytes collected so far

  channel.onmessage = (event) => {
    // Control messages arrive as strings (JSON).
    if (typeof event.data === "string") {
      const msg = JSON.parse(event.data);

      if (msg.type === "file-meta") {
        meta = msg;
        chunks = [];
        received = 0;
        return;
      }

      if (msg.type === "file-end") {
        finish();
        return;
      }
      return;
    }

    // Binary chunk.
    const buffer = event.data; // ArrayBuffer, because binaryType is "arraybuffer"
    chunks.push(buffer);
    received += buffer.byteLength;

    onProgress(received, meta?.size ?? 0); // bytes received / total

    // Size-based completion: no end marker needed if the count matches.
    if (meta && received >= meta.size) finish();
  };

  function finish() {
    if (!meta) return;
    // Reassemble in order. The Blob constructor concatenates the parts.
    const blob = new Blob(chunks, { type: meta.mime });
    onComplete(blob, meta.name);
    chunks = []; // release references so the parts can be collected
  }
}

The Blob constructor accepts an array of ArrayBuffers and concatenates them in array order. Because the channel is reliable and ordered, chunks is already in the right order: you push as they arrive, no sorting. The constructed Blob carries the original MIME type, so the browser treats a image/png as an image and a application/pdf as a PDF.

To trigger a download, turn the Blob into an object URL and click a synthetic link:

function onComplete(blob, name) {
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = name;
  a.click();
  URL.revokeObjectURL(url); // free the blob once the download starts
}

Two completion signals exist, and using both is deliberate. Size-based completion (received >= meta.size) fires as soon as the last byte lands and needs no extra message. The explicit file-end marker is a backstop and a place to attach a final step such as an integrity check. Keep both. If you send a checksum, send it inside or alongside file-end so the receiver verifies after the last chunk (see integrity).

Progress on both ends

Progress is byte arithmetic. Each side keeps a running count and divides by the total.

The sender increments offset by every chunk's byteLength and reports offset / file.size. Because send() only queues bytes, the sender's progress reflects bytes enqueued, not bytes confirmed on the wire. For most UIs that is close enough, and the backpressure loop keeps the queue small so the gap stays tight. If you need true delivered-byte progress, the sender cannot see it directly over a data channel; report received progress back from the receiver as periodic JSON acknowledgements instead.

The receiver increments received by each chunk's byteLength and reports received / meta.size. This is exact: it counts bytes actually delivered to the application.

function onProgress(done, total) {
  const pct = total ? Math.round((done / total) * 100) : 0;
  progressBar.value = pct;
  label.textContent = `${formatBytes(done)} / ${formatBytes(total)} (${pct}%)`;
}

Throttle UI updates if chunks are small and fast. Updating the DOM on every 16 KiB chunk for a 1 GB file is 65,000 layout passes. Update on a timer or every N chunks, and keep the byte counter exact underneath.

In-memory reassembly and its ceiling

The receiver above holds every chunk in the chunks array, then builds one Blob. That keeps the full file in memory twice during assembly: once as the array of parts, once as the new Blob. For a 50 MB file that is fine. For a multi-gigabyte file it is not: the tab runs out of memory and crashes.

For files that fit comfortably in memory, the array-then-Blob approach is the simplest correct choice and the one shown here. For files that do not, you stream each chunk to disk as it arrives using the File System Access API and createWritable(), so memory stays flat regardless of file size. That technique, plus chunk-size tuning and resumable transfers, lives in large files.

Edge cases worth handling

A few cases break a naive loop. Each has a small, specific fix.

The empty file. A zero-byte file has size === 0. The send loop never enters because offset < file.size is false from the start. The receiver's size-based completion (received >= meta.size, with both zero) fires on the first check, but it only checks inside the binary-chunk branch, which never runs. Handle it on the file-end marker, which always arrives. That is one more reason to keep the explicit end signal even though size-based completion covers the common path.

The last chunk. The final slice is almost always shorter than CHUNK_SIZE. Blob.slice clamps the end to the file size, so file.slice(offset, offset + CHUNK_SIZE) returns the remaining bytes and nothing else: no padding, no error. The only thing you must get right is advancing offset by the returned byteLength, not by CHUNK_SIZE, so the count stays exact.

The channel closing mid-transfer. If the connection drops, send() starts throwing or the channel's readyState leaves "open". The send loop should check readyState and stop rather than throw on every remaining chunk. The receiver should treat a non-completed transfer (connection gone before received reaches size) as a failure and discard the partial chunks, not assemble a truncated Blob. Resuming from where it stopped is a separate problem covered in large files.

A second transfer on the same channel. If you reuse the channel for another file, the receiver must reset meta, chunks, and received when a new file-meta arrives. The handler above does this. Without the reset, the second file's chunks append to the first file's leftovers and both are corrupt.

Backpressure that never relieves. If the network stalls completely, the buffer never drains and bufferedamountlow never fires, so the loop waits forever. That is usually the right behavior (there is nothing to send into a dead link), but pair it with the connection-state handling above so a closed channel breaks the wait instead of hanging the transfer silently.

Recap

A file is one Blob. A data channel sends bounded messages and has a finite send buffer. You bridge the two by chunking.

  • One send() cannot carry a file. SCTP caps message size (64 KiB safe), and the send buffer closes the channel near 16 MB.
  • Blob.slice(start, end) cuts a chunk without copying. slice().arrayBuffer() reads it as an ArrayBuffer.
  • Set channel.binaryType = "arraybuffer" so chunks arrive as ArrayBuffer, and send raw ArrayBuffers (not JSON-wrapped) for the binary payload.
  • 16 KiB is the default chunk size: interoperable, fine backpressure granularity, smooth progress. Raise it only when you control both ends and have measured maxMessageSize.
  • Send a file-meta JSON message first (name, size, mime), then the binary chunks in order, then file-end.
  • The default reliable-ordered channel delivers chunks in order, so the receiver appends without sequence numbers.
  • Pace the send loop: check bufferedAmount before each send, pause above a high-water mark, resume on bufferedamountlow. Advance the offset by actual byteLength.
  • The receiver counts bytes, reassembles with new Blob(chunks, { type }), and triggers a download via an object URL.

Going further

  • Integrity: verify the received file with a SHA-256 checksum so a logic bug or rare corruption never produces a silently wrong file.
  • Large files: stream chunks to disk with the File System Access API, tune chunk size for throughput, and add resumable transfers.
  • Messaging reliability: the full treatment of backpressure, bufferedAmount, ordered vs unordered delivery, and reliability modes.
  • Data channels: how the channel is created and configured, and where binaryType and the reliability options are set.

Troubleshooting

Symptom Likely cause Fix
Channel closes partway through a large file Send buffer overran the ~16 MB cap Add the bufferedAmount check and bufferedamountlow wait to the send loop
send() throws on large chunks Chunk exceeds the peer's maxMessageSize Drop chunk size to 16 KiB, or read RTCSctpTransport.maxMessageSize and stay under it
Received file is corrupt or wrong type Chunks treated as strings, or wrong MIME Set binaryType = "arraybuffer"; pass meta.mime to the Blob constructor
Received file is the right size but garbled Chunks reassembled out of order Keep the channel reliable and ordered; do not switch to unordered without sequence numbers
Transfer hangs near the end Waiting on an end marker that never came Use size-based completion (received >= meta.size) alongside file-end
Tab crashes on multi-GB transfer Whole file held in memory during reassembly Stream to disk instead of buffering (see large files)
Progress bar janks the UI DOM updated on every small chunk Throttle UI updates; keep the byte counter exact underneath
Last chunk reported as full size Offset advanced by CHUNK_SIZE not byteLength Advance by the actual bytes read each iteration