WebRTC
How WebRTC sends a file straight between two browsers
Open the same page on two devices sharing a network and they can hand files to each other directly, with nothing uploaded in between. This is how drpl.co does it, following the real path a file takes: finding the other device, negotiating a connection, keeping a fast sender from drowning a slow receiver, and falling back when the network refuses to cooperate.
How two browsers find each other
A browser cannot see its local network. There is no API for enumerating neighbours, no mDNS, nothing that would let a page ask who else is on this WiFi, and for good reason given what a malicious page would do with it. So the only way two browsers can discover each other is for something outside both of them to make the introduction.
The server does that by grouping every open connection into a room keyed on the public IP address the connection arrives from, on the reasoning that two devices behind one router present the same address to the outside world. Devices on different networks never appear in each other's lists, and a room is capped at 48 peers so a single address cannot exhaust the process.
IPv6 breaks that assumption, because every device on a v6 network carries its own public address rather than sharing one, which would put each device in a room by itself. Those clients are grouped by their /64 prefix instead, that being the standard allocation for a single LAN. A useful side effect is that phones on cellular data stay isolated, since each one gets its own /64 from the carrier.
If two devices refuse to see each other, a VPN on one of them is almost always the reason. The tunnel changes the public address that device presents, so it lands in a different room, and from the application's point of view the two are on separate networks.
Keying rooms on IP has a consequence worth stating plainly, because it shapes everything downstream: on a coffee shop network, strangers share your public address and therefore appear in your device list. That is not a bug in the grouping, it is the unavoidable cost of the only discovery mechanism available to a web page, and it is the reason consent has to be enforced properly rather than politely. More on that below.
The introduction, and the exit
When a device joins a room the convention is that the newcomer makes the WebRTC offer, which avoids both sides trying to lead at once. The server relays that offer, the answer, and the ICE candidates between the two browsers without understanding any of it, and once the data channel opens its part in the conversation is finished. It will not hear from either browser again unless something breaks.
The ICE configuration lists exactly two STUN servers, one at Google and one at Cloudflare, and no TURN server at all. That distinction matters more than it looks. STUN only tells a device what its own public address looks like from outside, so it never carries traffic, whereas a TURN server forwards the actual bytes on your behalf. With no TURN configured, ICE has no way to hand a transfer to a third party even if it wanted to.
Encryption comes free and is worth being precise about: data channels run over DTLS, and that is a property of WebRTC rather than anything implemented here. Every transfer is encrypted in transit whether or not anyone remembered to think about it.
The server introduces the two devices and then gets out of the way.
Two water marks and a fast sender
Once the channel is open the naive approach is to loop over the file
and call
send() for every slice, which works on a fast local link
and fails badly everywhere else. The call does not wait for anything.
It appends to an internal buffer that the browser drains at whatever
rate the network allows, and a laptop reading from an SSD fills that
buffer far faster than a phone on the other end of a mediocre WiFi
link can accept it. Let it grow unchecked and the browser eventually
closes the channel, killing the transfer partway through with no
useful error.
The fix is to treat the send buffer as a level to be kept between two marks rather than a queue to be filled:
// Transfer tuning. 64 KiB chunks are the safe cross-browser data channel
// message size. Backpressure: pause when the send buffer passes HIGH_WATER,
// resume when the browser drains it below LOW_WATER (bufferedamountlow).
const CHUNK_SIZE = 64 * 1024;
const HIGH_WATER = 4 * 1024 * 1024;
const LOW_WATER = 512 * 1024;
Reading stops once four megabytes are outstanding and starts again only when the browser reports the buffer has drained below half a megabyte. Those two numbers are what let a machine stream a file of any size to a phone while neither side holds more than a few megabytes at once, and the gap between them matters: set them too close together and you thrash between pausing and resuming, hundreds of times a second, for no gain.
The chunk size deserves a note, because 64 KiB is a compromise rather
than a limit. Data channel messages travel over SCTP, which negotiates
a maximum message size that browsers expose as
maxMessageSize. Historically 16 KiB was the only value
safe across every browser, and 64 KiB became reliable between Chromium
and Firefox once Firefox 57 shipped. Rather than settle for the
conservative number, the sender asks what the connection actually
negotiated and takes the larger size when it is on offer:
// Chromium peers negotiate 256 KiB SCTP messages; use them when offered.
// Falls back to the universally safe 64 KiB.
_chunkSize() {
if (this._isOpen() && !this._useRelay && this._conn && this._conn.sctp) {
const max = this._conn.sctp.maxMessageSize;
if (Number.isFinite(max) && max >= CHUNK_SIZE) {
return Math.min(max, 256 * 1024);
}
}
return CHUNK_SIZE;
}
Quadrupling the message size cuts the number of round trips through
the send path by the same factor, and it is measurably faster on
connections that support it. The _useRelay check matters
too, since the fallback path described below has nothing to do with
SCTP and no such negotiation to consult.
Progress reporting follows the same discipline. Bytes handed to
send() are not bytes delivered, so counting them produces
a bar that races to a hundred percent while the file is still
arriving. The sender subtracts whatever remains in its own buffer
before reporting a figure, and the receiver reports what it has
actually written, which means the number reflects delivery rather than
intent.
Consent enforced in the protocol, not the interface
Nothing is read from disk, let alone transmitted, until the receiving device accepts a request listing every file and its size. Given that strangers on a shared network land in the same room, this is the control that makes the discovery model tolerable, and putting it in the interface alone would make it decorative.
_onTransferStart(msg) {
// Consent is enforced here, not just in the UI: a peer that skips the
// handshake and sends transfer-start directly gets nothing.
if (this._acceptedTransferId !== msg.id) {
this._out({ type: "transfer-cancel", id: msg.id, reason: "declined" });
return;
}
// ... accepted, begin receiving
}
Because the check lives in the message handler, a peer that skips the
handshake and fires transfer-start directly is answered
with a cancel and receives nothing. The default answer is no, and it
takes a matching accepted identifier to change that.
Unanswered requests decline themselves after sixty seconds, so a sender always learns the outcome instead of waiting indefinitely on a device that went to sleep. The sender's own timer runs five seconds longer than the receiver's, which reads like an off by one until you picture a decline already in flight when the deadline passes. Without the grace period both sides would time out independently and the real answer would arrive to nobody.
When the network blocks WebRTC outright
Corporate firewalls and some VPNs block WebRTC entirely, and with no TURN server in the configuration there is no standard path left. The fallback is to relay chunks through the signalling server as base64 inside the existing WebSocket, which works everywhere a normal web page works and costs about a third more bytes on the wire, base64 being a four for three encoding.
| Transport | Route | Encoding | Overhead |
|---|---|---|---|
| Data channel | device to device | binary, 64 to 256 KiB messages | none worth measuring |
| Relay fallback | through the signalling server | base64 inside JSON frames | about 1.33x the file size |
Falling back changes the privacy story as much as the speed, because the file now passes through a machine I run, so the interface says so rather than hiding it. The transport row flips from WebRTC P2P to a server relay and a notice appears the moment a connection downgrades. A fallback that quietly reroutes your data through someone else's server is worse than one that refuses.
Relayed traffic is also the only thing that costs the server real bandwidth, so it drops any client sustaining more than 128 MiB per second through the relay. No genuine transfer comes close to that, which makes the limit a runaway guard rather than a throttle.
Sleeping phones and lying sockets
Everything above assumes two browsers that stay awake, which is not what phones do. A socket can be dead for minutes while the API cheerfully reports it as open, usually because the device slept and nothing informed the page, so the connection layer treats liveness as something to be measured rather than trusted.
A watchdog inspects the signalling socket every ten seconds, and anything silent for more than sixty five seconds is torn down and rebuilt rather than believed. The data channel gets a ten second heartbeat that skips itself while a transfer is in flight, on the grounds that flowing data is already proof of life and there is no sense competing with it for the buffer. A device whose direct connection drops is dimmed in the list within seconds, and the server removes it entirely in under forty five seconds.
The point of all of it is that transfers fail loudly. A progress bar that stops moving and stays that way is worse than an error, because the person watching it has no idea whether to wait or start again.
What it costs
The approach buys a transfer tool with no account, no storage bill and nothing to install, where the file genuinely does not touch my server on the normal path. What it costs is a hard requirement that both devices land in the same room, which means the same network and no VPN in the way, plus the obligation to be honest about the one case where the bytes do come through me.
It is live at drpl.co, and the rest of what I build is on my projects page. If you want a different flavour of the same instinct, the background removal post covers running a machine learning model with no server behind it at all.