Minitooler

WebRTC file transfers on Chromium can hit a hidden 16 MB buffer limit

Large files are not the problem. An unchecked send queue is. Here is what Chromium actually limits, why the failure often looks like a dropped peer, and the backpressure pattern that keeps long transfers alive.

Published October 4, 2026 · 8 minute read

A small WebRTC file transfer is easy to prototype. Read a file, split it into pieces, call RTCDataChannel.send() until there is nothing left, and rebuild the file on the other side. The same code can look solid in testing and then fail 10 or 20 MB into a large video.

The error message often points in the wrong direction: “the other device disconnected”, “data channel closed”, or simply OperationError. It is tempting to blame Wi-Fi, NAT traversal, or the receiving device. In Chromium, however, one common cause is much simpler: the sender filled an internal queue faster than the connection could empty it.

The 16 MB limit is real, but the usual description is wrong

Chromium's open-source WebRTC implementation contains a 16 × 1024 × 1024-byte limit for queued data-channel data. That is 16 MiB, or 16,777,216 bytes. The number is visible in Chromium's underlying libwebrtc data-channel source.

But this is not a 16 MB file-size limit. It is not a rule in the WebRTC specification, either. It is an implementation ceiling on data waiting to be sent. A 100 GB file can theoretically pass through the channel if it is streamed gradually. A 20 MB file can fail almost immediately if all of it is pushed into the queue at once.

That distinction matters because it changes the fix. Smaller files, a faster connection, or a larger JavaScript heap may hide the bug, but none of them gives the sender flow control.

What bufferedAmount tells you

Every RTCDataChannel exposes a read-only bufferedAmountproperty. It reports how many bytes the browser has accepted from your code but has not yet sent onto the network. If that number keeps rising, your producer is outrunning the transport.

The API also provides bufferedAmountLowThreshold. Set it to a lower watermark and listen for bufferedamountlow. This gives the sender a signal to resume without polling continuously.

const HIGH_WATER = 8 * 1024 * 1024;
const LOW_WATER = 2 * 1024 * 1024;

channel.bufferedAmountLowThreshold = LOW_WATER;

function waitForRoom() {
  if (channel.readyState !== "open") {
    throw new Error("Data channel closed");
  }
  if (channel.bufferedAmount <= HIGH_WATER) return Promise.resolve();

  return new Promise((resolve, reject) => {
    const onLow = () => {
      cleanup();
      resolve();
    };
    const onClose = () => {
      cleanup();
      reject(new Error("Data channel closed"));
    };
    const cleanup = () => {
      channel.removeEventListener("bufferedamountlow", onLow);
      channel.removeEventListener("close", onClose);
    };
    channel.addEventListener("bufferedamountlow", onLow, { once: true });
    channel.addEventListener("close", onClose, { once: true });
  });
}

for (const chunk of chunks) {
  await waitForRoom();
  channel.send(chunk);
}

Eight and two MiB are deliberately conservative example values, not web standards. Keeping the high-water mark below Chromium's current ceiling leaves space for another chunk and for data already moving through lower layers. Production code should also catch exceptions from send(): the channel can close after the readiness check, and browser behavior can change between versions.

Chunk size and queue size are separate limits

A second common mistake is treating the send queue and the individual message as the same thing. They are separate. Large messages can face SCTP or browser-specific limits even when the queue is nearly empty. Small messages can still fill the 16 MiB queue when sent in a tight loop.

For broad compatibility, transfer the file as bounded binary chunks rather than one enormous message. The negotiated pc.sctp.maxMessageSizecan help choose a ceiling, but a practical application should cap chunks to a conservative range and test the browsers it supports. Chunk metadata should identify the file and sequence so the receiver can detect missing or out-of-order application data.

A reliable sending loop needs four safeguards

  1. Bound each message. Read and send modest chunks instead of loading the entire file into the channel.
  2. Apply backpressure. Stop before the outgoing queue reaches the browser's ceiling, then resume on bufferedamountlow.
  3. Recheck the channel. Verify readyState === "open" before each send and handle close/error events while waiting.
  4. Recover from a full queue. Catch send() failures, wait for room, and retry the unsent chunk rather than skipping it.

This is the pattern we ended up using in Wifi File Dropafter large transfers repeatedly died around the same point. Its sender reads larger file slices for efficiency, breaks them into smaller WebRTC messages, stops at an 8 MiB high-water mark, and resumes below 2 MiB. That is one implementation, not a universal recipe, but it shows why queue control matters more than the total file size.

Why the failure can look like a peer disconnect

According to MDN, send() can throw OperationErrorwhen the browser cannot buffer the data. What happens next is up to the application. If it treats that exception as fatal, closes the connection, or stops sending required protocol messages, the receiver only sees that its peer disappeared. The user-facing diagnosis is therefore often a symptom rather than the cause.

Log the channel's bufferedAmount, readyState, chunk size and exception name when a transfer fails. A value near the queue ceiling immediately before OperationError is much stronger evidence than the generic “disconnected” message shown to the recipient.

Do not assume every browser behaves like Chromium

The WebRTC standard defines the relevant APIs but does not require every engine to expose the same buffer capacity or failure behavior. Firefox, Safari and Chromium have differed in queue sizes, exception timing and large-message handling. Those details can also change as implementations evolve.

Build around the standard flow-control signals rather than coding directly to 16 MiB. Test sustained transfers in every browser you claim to support, on slower networks as well as fast local Wi-Fi. Slow connections expose queue bugs sooner because they drain buffered data less quickly.

The receiver can become the next bottleneck

Fixing the outgoing queue does not make the whole transfer memory-safe. If the receiver stores every chunk in an array and creates one finalBlob, it may hold roughly the entire file in memory. That can be acceptable for moderate files on a desktop and risky for multi-gigabyte transfers on a phone.

Where available, stream received bytes to persistent storage instead of assembling everything in RAM. If browser support prevents that, state the limitation clearly, release finished file buffers promptly, and avoid promising an unlimited file size merely because the WebRTC channel itself can keep streaming.

Practical checklist

  • Use binary chunks with explicit file and sequence metadata.
  • Keep message size below the negotiated SCTP maximum and your tested browser ceiling.
  • Choose a high-water mark with headroom rather than queueing right up to 16 MiB.
  • Resume with bufferedamountlow, not a tight polling loop.
  • Catch and retry full-queue sends without advancing the file offset.
  • Abort waits when the channel closes and show the actual failure stage.
  • Test large files on throttled connections and lower-memory phones.
  • Measure receiver memory as well as sender throughput.

References

Frequently asked questions

Does WebRTC have a 16 MB file-size limit?

No. Chromium has an internal 16 MiB ceiling for queued data-channel bytes, not for the total file. A transfer can be many gigabytes if the sender pauses while the queue drains.

Why does RTCDataChannel.send() fail during a large transfer?

The application is usually calling send() faster than the network can carry the data. Once Chromium's outgoing queue runs out of room, send() throws an OperationError. The peer may then appear to disconnect if the application closes or abandons the channel.

What is a safe RTCDataChannel buffer limit?

There is no universal safe number for every browser. A conservative design uses a high-water mark well below Chromium's 16 MiB ceiling, such as 8 MiB, and resumes only after bufferedAmount falls to a lower threshold. Test lower limits for Safari and Firefox.

Should I poll bufferedAmount?

Usually not. Set bufferedAmountLowThreshold and wait for the bufferedamountlow event. Check readyState again before every send, and catch send() errors because the channel can close between the check and the call.