How WebRTC File Transfer Works: Data Channels in Practice
By SRIFT maintainers · Published
Reviewed by SRIFT maintainers · last reviewed against the SRIFT 4.3.2 source · third-party facts checked 8 October 2026.
Short answer
WebRTC file transfer sends a file between two browsers over an RTCDataChannel: a message pipe carried by SCTP, encrypted with DTLS, over a UDP path that ICE discovers (or a TURN relay when no direct path works). The sender reads the file in slices, sends each slice as a message no larger than the negotiated maximum, and pauses whenever bufferedAmount grows too large.
The API is small, but three details decide whether it works for multi-gigabyte files: message size, sender backpressure, and where the receiver puts the bytes. Each is covered below with the numbers from the specifications and from a production implementation.
- Transport
- SCTP over DTLS over UDP (RFC 8831, RFC 8261)
- Default max message
- 64 KB if the peer advertises nothing (RFC 8841)
- Encryption
- DTLS, mandatory, between the two endpoints
- Flow control you write
- bufferedAmount on send, app-level acks on receive
Key takeaways
- An RTCDataChannel is a message pipe: SCTP inside DTLS, over a UDP path that ICE finds. It is reliable and ordered by default.
- Each message must fit the maximum size the peers negotiate in SDP; RFC 8841 sets 64 KB when the peer advertises nothing.
- send() never blocks. Pause above a bufferedAmount high-water mark, or the sender queues the whole file in memory.
- The receiver must write chunks to disk as they arrive instead of assembling one Blob at the end.
- On SRIFT's direct path the only encryption is WebRTC's DTLS, and the signaling server sees the fingerprints and candidates.
This guide covers the data channel and its limits. For the whole flow of a transfer, start with how P2P file transfer works; for connection setup, see STUN vs TURN.
What a data channel is
An RTCPeerConnection is the object that negotiates a connection with one remote peer. Calling createDataChannel() on it asks for a bidirectional channel for arbitrary data, alongside (or instead of) audio and video. Messages keep their boundaries: one send() of 64 KB arrives as one 64 KB message event, unlike a TCP socket where the receiver sees an undivided byte stream.
The browser cannot find the other peer by itself. The application must carry the SDP offer, the answer and the ICE candidates between the two sides over some signaling channel it provides, typically a WebSocket to its own server. Once the peers have each other's descriptions, ICE tests address pairs and DTLS performs its handshake on the winner. Only then does the channel's open event fire.
const pc = new RTCPeerConnection({ iceServers })
const dc = pc.createDataChannel("file", { ordered: true })
dc.binaryType = "arraybuffer"
pc.onicecandidate = (e) => e.candidate && signal({ candidate: e.candidate })
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
signal({ sdp: pc.localDescription }) // your own WebSocket or HTTPS channel
dc.onopen = () => sendFile(file) // defined further downBy default the channel is announced in-band: the opener sends a DATA_CHANNEL_OPEN message defined by the Data Channel Establishment Protocol (RFC 8832), and the other side receives a datachannel event. Passing negotiated: true with an agreed id skips that exchange when both sides already know the channel exists.
The protocol stack: SCTP over DTLS over UDP
SCTP supplies the transport features. One SCTP association runs per peer connection, and each data channel is a stream within it, so many channels share one connection and one congestion controller (RFC 8831, section 5). SCTP frames messages, numbers them, retransmits lost ones and delivers them in order or not, per channel.
DTLS wraps every SCTP packet (RFC 8261). Each browser generates its own certificate, and its fingerprint travels in the SDP. During the handshake each side checks that the certificate presented matches the fingerprint it was given. That is the entire basis of WebRTC's authentication: whoever can alter the SDP in transit, including the signaling server, could substitute a fingerprint and sit in the middle. The encryption is real and mandatory, but it is only as trustworthy as the channel that delivered the fingerprints.
ICE sits below DTLS and chooses the address pair. If the pair runs through a TURN relay, the relay forwards DTLS records it cannot decrypt. The STUN vs TURN guide covers how that choice is made.
Message size: negotiated, not assumed
Each endpoint advertises the largest message it will accept with the SDP attribute a=max-message-size (RFC 8841). When the attribute is absent the default is 64 KB; a value of 0 means “any size, subject to memory”. The browser exposes the result as pc.sctp.maxMessageSize once the remote description is set, and send() throws a TypeError for a message too large for the peer to receive.
Bigger is not automatically better. Without message interleaving (the SCTP extension in RFC 8260), one large message occupies the association until it is fully sent, which delays messages on every other channel. RFC 8831 therefore says a sender without interleaving SHOULD limit messages to 16 KB. MDN's guidance as of this writing is that most modern browsers accept messages of at least 256 KB, with the same head-of-line warning.
A practical rule
- Read
pc.sctp.maxMessageSizeafter negotiation instead of hard-coding a number. - Clamp it to a range you have tested, for example 16 KB to 256 KB, and leave a little headroom for any header you add.
- If the file transfer shares a connection with latency-sensitive messages (chat, control), keep chunks small or give the file its own peer connection.
Ordered, reliable, or neither
createDataChannel() takes three options that change delivery semantics. They come from SCTP's partial reliability extension (RFC 3758) and the limited-retransmission policy of RFC 7496:
| Option | Effect | For files? |
|---|---|---|
ordered: true | Messages arrive in the order sent (the default). | Yes, the simplest correct choice. |
ordered: false | Messages may arrive out of order; a lost message does not stall later ones. | Only if each chunk carries its byte offset and the receiver writes by position. |
maxRetransmits: n | Give up on a message after n retransmissions. | No: a dropped chunk corrupts the file unless you rebuild reliability yourself. |
maxPacketLifeTime: ms | Give up on a message after a time limit. | No, for the same reason. It suits live telemetry or game state. |
maxRetransmits and maxPacketLifeTime cannot both be set (the call throws a SyntaxError). If neither is set the channel is fully reliable. For a file, reliable delivery is what you want; ordering is a convenience. An unordered reliable channel can avoid some head-of-line delay after loss, but it moves the reassembly problem into your code, and the receiver then needs random-access writes.
Reading the file and handling backpressure
send() never blocks. It copies the message into a browser-side queue and increases bufferedAmount by its size. A loop that calls send() as fast as it can read the disk will queue the whole file in memory, because local disks read far faster than most networks carry data. When the browser's buffer has no room left, send() throws an OperationError and the transfer fails.
The fix is a high-water and low-water mark. Above the high mark, stop and wait for the bufferedamountlow event, which fires when the queue falls to bufferedAmountLowThreshold. The gap between the two marks keeps the queue from ever running dry while the sender is waking up.
const HIGH = 8 * 1024 * 1024 // pause above 8 MB queued
const LOW = 2 * 1024 * 1024 // resume at 2 MB
dc.bufferedAmountLowThreshold = LOW
function drained(dc) {
return new Promise((resolve, reject) => {
const ok = () => { dc.removeEventListener("close", fail); resolve() }
const fail = () => { dc.removeEventListener("bufferedamountlow", ok); reject(new Error("channel closed")) }
dc.addEventListener("bufferedamountlow", ok, { once: true })
dc.addEventListener("close", fail, { once: true })
})
}
async function sendFile(file) {
const negotiated = pc.sctp?.maxMessageSize ?? 65536
const chunk = Math.max(16 * 1024, Math.min(256 * 1024, negotiated - 1024))
dc.send(JSON.stringify({ name: file.name, size: file.size, chunk }))
for (let offset = 0; offset < file.size; offset += chunk) {
if (dc.bufferedAmount > HIGH) await drained(dc)
const buf = await file.slice(offset, offset + chunk).arrayBuffer()
dc.send(buf)
}
}Blob.slice() is cheap: it creates a view without reading anything, and arrayBuffer() reads only that range. Reading each 64 KB message separately works but costs one asynchronous disk read per message, so implementations often read a larger slice (several megabytes) and send sub-arrays of it. file.stream() is an alternative, but its chunks have whatever size the browser chooses and must be re-cut to fit the message limit.
Writing on the receiving side
The receiving API has no backpressure at all. Messages are pushed to onmessage as SCTP delivers them, and there is no way to say “not yet”. If the page appends every chunk to an array and builds a Blob at the end, a large file lives entirely in memory, and the tab eventually crashes.
Two things are needed. First, a place to write incrementally: the File System Access API's showSaveFilePicker() where supported (Chromium), the origin private file system, or a stream into the browser's own download handled by a service worker. Second, an application-level flow-control message: when the bytes received but not yet written exceed a limit, the receiver tells the sender to pause, and resumes it once the writer catches up. The SCTP window only covers the browser's internal buffers; it knows nothing about the disk.
Finally, confirm completion explicitly. Tearing down the peer connection right after the last send() can lose data that is still queued, so the sender should wait for bufferedAmount to reach zero and for the receiver to acknowledge the byte count it stored. The large-file reliability guide covers storage limits per browser and how to verify what arrived.
SRIFT's parameters, and what breaks at scale
SRIFT's browser client is open source, so its choices can serve as a tested reference. These values are from its code at version 4.3.2:
| Setting | Value |
|---|---|
| Connections | One RTCPeerConnection per transfer, channel "srift-file", ordered and fully reliable. |
| Message size | Negotiated maxMessageSize minus 1 KB, clamped to 16 KB to 256 KB; 64 KB when the browser reports nothing usable. |
| Disk reads | 4 MB slices of the file, sent as messages of the size above. |
| Sender backpressure | Pause above 8 MB buffered; bufferedAmountLowThreshold of 2 MB. |
| Receiver flow control | Asks the sender to pause when more than 32 MB is waiting to be stored, resumes at 8 MB. |
| Connection loss | ICE "disconnected" gets a 6-second grace period; "failed", "closed" or a closed channel abort immediately. |
| Fallback | If the channel does not open within 3 seconds, or fails mid-transfer, an encrypted WebSocket relay continues from the bytes already received. |
Failure modes these settings address
- Memory growth on either side, handled by the two flow-control loops above.
- Paths that never open. Strict firewalls and some NATs prevent any data channel. Waiting a long time for one wastes the user's time, which is why the fallback is time-boxed. See WebRTC vs WebSockets for the reasoning.
- Mid-transfer drops. A Wi-Fi switch changes the ICE path; a short “disconnected” state often recovers, a “failed” one does not.
- Throughput below the link speed. Data channels run SCTP congestion control inside the browser, and buffer sizes, CPU and the path (especially a TURN relay) all limit speed. The slow transfer guide shows how to find the bottleneck.
Frequently asked questions
Can a WebRTC data channel send a file larger than the browser's memory?
Yes, if the sender reads the file in slices with backpressure and the receiver writes each chunk to disk as it arrives (File System Access API, origin private file system or a service-worker download) and tells the sender to pause when it falls behind. Building a Blob from all chunks at the end does not scale.
Should I use several data channels in parallel to go faster?
Usually not. All channels on one peer connection share one SCTP association and one congestion controller, so parallel channels compete for the same window. Separate peer connections do get separate congestion control, at the cost of separate ICE and DTLS setup.
Is a WebRTC data channel encrypted end to end?
It is encrypted with DTLS between the two endpoints, and a TURN relay cannot read it. The keys are authenticated only by certificate fingerprints exchanged over the signaling channel, so whoever controls signaling could substitute them unless the application verifies fingerprints out of band.
References
- RFC 8831: WebRTC Data Channels (IETF)
- RFC 8832: WebRTC Data Channel Establishment Protocol (IETF)
- RFC 8261: Datagram Transport Layer Security (DTLS) Encapsulation of SCTP Packets (IETF)
- RFC 8841: SDP Offer/Answer Procedures for SCTP over DTLS (IETF)
- WebRTC: Real-Time Communication in Browsers (W3C)
- Using WebRTC data channels (MDN)
- RTCDataChannel: bufferedAmount (MDN)
Try a direct transfer
Open a session in one browser, join it from another device, and send a file. No account, nothing to install on either side.
Related resources
- How Peer-to-Peer File Transfer Works, Step by StepFrom signaling to the last chunk: how two devices find each other, open a direct encrypted channel, stream a file and fall back when the path is blocked.Explainer
- WebRTC vs WebSockets for File TransferDirect WebRTC data channels and server-relayed WebSockets compared for file transfer: latency, throughput, reliability, cost and privacy.Comparison
- STUN vs TURN: How WebRTC Gets Through NATWhat STUN and TURN servers do, how ICE picks between host, server-reflexive and relay candidates, and what a TURN relay can and cannot see.Explainer
- Why Is P2P File Transfer Slow? How to Find the BottleneckUpload bandwidth, TURN relaying, Wi-Fi, buffering and encryption overhead: how to find what is limiting a peer-to-peer transfer and fix it.Troubleshooting
- Large File Transfers: Interruptions, Resume and IntegrityWhy multi-gigabyte transfers fail, how browsers store incoming files, what resume really requires, and how to verify the file that arrived.Troubleshooting
