File Transfer

Implementation

Peer-to-peer file transfer in the browser, explained

Every browser already ships a NAT-traversing, congestion-controlled, encrypted transport: WebRTC data channels. What is missing is a way for two strangers to find each other. That is the only job the server here has — and once it is done, it steps out of the path entirely.

Short answer

How does peer-to-peer file transfer work in a browser?

A signalling server introduces the two browsers so they can exchange connection descriptions and candidate addresses. They then punch through their NATs and open a direct WebRTC data channel. The file is chunked, compressed, encrypted and pushed over that channel — the server never sees a byte of it.

See it for yourself

or

Share the code; the other person enters it to connect

Open the console during a transfer if you want to watch the negotiation.

How it works

  1. 1

    Rendezvous

    One side asks for a 6-digit code; the room lives in Redis with a 15-minute TTL. The other side submits it, and the creator gets an explicit approval prompt showing a 4-character tag — the code alone never opens a connection.

  2. 2

    Negotiation

    The peers exchange SDP offers, answers and ICE candidates through the signalling channel until a working path is found: a host candidate on a shared LAN, or a server-reflexive one punched through both NATs.

  3. 3

    Handover

    Once the data channels open, an ECDH P-256 exchange runs over the control channel and both sides derive the session key with HKDF. From that point signalling and Redis play no further part.

One control channel, four data channels

A single data channel is a single SCTP stream, and a lost packet at the front blocks everything queued behind it. The transfer therefore runs across four bulk channels that are unordered but reliable: each chunk carries its own index, so out-of-order arrival is meaningless and a retransmit only stalls its own chunk.

A fifth, ordered channel carries control messages — file manifests, pull requests, pause and cancel. Keeping control traffic off the bulk streams means a manifest never waits behind eight megabytes of queued payload.

  • Chunk size adapts to the negotiated sctp.maxMessageSize, starting at 64 KiB and capping at 256 KiB, so each send fills a message rather than fragmenting.
  • Backpressure is driven by bufferedAmount with an 8 MB high-water mark and a 2 MB low-water mark, plus a cap of 24 chunks in flight.
  • Each chunk is independently deflated before encryption — sampled on the first few chunks, and skipped entirely when the data turns out to be incompressible, as most video and archives are.
  • Then AES-256-GCM, with its own nonce and authentication tag per chunk. Self-contained chunks are what make parallel channels and resume-from-gap possible at all.

ICE, and the deliberate absence of TURN

ICE tries candidate pairs in order of preference. Host candidates win when both devices sit on the same LAN — the file then never leaves the local network. Otherwise STUN reveals each side’s public mapping and the two sides punch through simultaneously, producing a server-reflexive pair that crosses the internet directly.

The third option in a standard WebRTC deployment is TURN: a relay that forwards the traffic when hole-punching fails. It is deliberately not offered here. The ICE list handed to the client is filtered server-side to stun: URLs only, so a relay cannot be configured in, and a failed connection stays a failed connection.

What travels, and when

  • Adding a file broadcasts a manifest entry — name and size only. The file is not opened and not read.
  • The receiving side sends a pull request that includes the byte ranges it already holds, recorded in IndexedDB from an earlier attempt.
  • Only then does the sender read from disk, in chunks, compressing and encrypting each one on a worker before it hits the wire.
  • On arrival, each chunk is authenticated and written to IndexedDB; the ranges it fills are recorded before the next one is acknowledged.
  • When the range set is complete, the blob is assembled and handed to the browser as a download. A failed authentication tag aborts the transfer instead of writing suspect data.

DTLS already encrypts a data channel, so the AES-256-GCM layer on top is redundant against a passive network observer. It is not redundant against the signalling server: the browser-derived key is what makes a malicious or compromised signalling path unable to insert itself as a peer. The safety words shown on both screens are the human-verifiable end of that same key.

Frequently asked questions

Is a signalling server still needed for P2P transfer?

Yes. Two browsers behind NAT cannot discover each other unaided, so something has to relay the offer, answer and candidates. That is all it does here — it holds room state with a short TTL and never touches file data.

Why WebRTC rather than WebSockets or WebTransport?

WebRTC data channels are the only browser API that establishes a direct peer connection with NAT traversal built in. WebSockets and WebTransport both terminate at a server, which would put your file back on someone else’s machine.

Why unordered data channels?

Ordering would reintroduce head-of-line blocking across four parallel streams. Since every chunk carries its index and its own authentication, order on the wire carries no information — the receiver reassembles from the index.

What happens when hole-punching fails?

The connection reports failure with the reason, and suggests the workarounds that actually help: join the same network, use a phone hotspot, disable a VPN. Nothing is relayed, so there is no silent degradation.

Can a transfer resume after the connection drops?

Yes. Received ranges live in IndexedDB on the receiving side, and the next pull request declares them, so the sender only re-reads the gaps.

Is the file compressed before sending?

Each chunk is deflated independently, but only if sampling the first few chunks shows it is worth it. Already-compressed formats skip it, so you do not pay CPU time for a rounding error.

Try a transfer

The interesting part takes about two seconds — after that it is just bytes moving between two machines.

Start a transfer