Peer-to-peer file transfer in the browser with WebRTC

How WebRTC peer-to-peer file transfer works in the browser: WebSocket signaling, data channels, chunking with backpressure, and keeping files off the server.

By Hamza Ghouri

Sending a file from a laptop to a phone on the same WiFi should be trivial. In practice most people email it to themselves, upload it to a cloud drive, or plug in a cable. LocalPigeon is my open source answer: peer-to-peer file transfer in the browser over local WiFi using WebRTC and WebSockets.

The rules I set for it were simple: no cloud, no account, no size limit. Files stream between devices in chunks and are never stored anywhere along the way. This post covers how WebRTC makes that possible and the parts that took the most care.

Why WebRTC fits

WebRTC is best known for video calls, but it also gives you RTCDataChannel: a direct, encrypted, browser-to-browser channel for arbitrary data. Once two browsers are connected, bytes flow straight between them. On a local network that path stays inside the network, so transfers are as fast as the WiFi allows and nothing touches a third-party server.

Every major browser supports it with no plugins or installs. For a file transfer tool, that means the receiving device only needs to open a page.

Signaling: the one job a server still does

Two browsers cannot find each other on their own. Before a data channel opens, they need to exchange a session description (an offer and an answer) and network candidates. This exchange is called signaling, and WebRTC deliberately leaves the transport for it up to you.

LocalPigeon uses WebSockets for signaling. The flow looks like this:

  1. Both devices open the page and connect to the signaling socket.
  2. The sender creates an RTCPeerConnection, a data channel, and an offer, and sends the offer over the socket.
  3. The receiver sets it as the remote description, creates an answer, and sends it back.
  4. Both sides trade ICE candidates as they are discovered.
  5. The data channel opens, and from then on the file moves peer to peer.

WebPigeon, the hosted sibling of LocalPigeon, follows the same model. Its server only does signaling. It relays the offer, answer and candidates, and never sees the bytes of the file. That is a strong privacy property, and it also keeps hosting cheap, because the server only carries small text messages.

Chunking with backpressure

A file cannot go over a data channel as one message. You slice it into chunks and send them in order. The detail that matters is backpressure: send() queues data in the browser, and if you loop over a large file without waiting, the queue grows until memory runs out or the channel errors.

The data channel exposes bufferedAmount and a bufferedamountlow event for exactly this. Here is the general pattern:

const CHUNK_SIZE = 16 * 1024;
const HIGH_WATER = 1024 * 1024;

async function sendFile(channel, file) {
  channel.bufferedAmountLowThreshold = HIGH_WATER / 2;
  channel.send(JSON.stringify({ type: "meta", name: file.name, size: file.size }));

  for (let offset = 0; offset < file.size; offset += CHUNK_SIZE) {
    if (channel.bufferedAmount > HIGH_WATER) {
      await new Promise((resolve) =>
        channel.addEventListener("bufferedamountlow", resolve, { once: true })
      );
    }
    const chunk = await file.slice(offset, offset + CHUNK_SIZE).arrayBuffer();
    channel.send(chunk);
  }

  channel.send(JSON.stringify({ type: "done" }));
}

A few notes on the choices:

  • 16 KB chunks are a conservative size that works reliably across browsers. Larger messages can work but behave differently between implementations.
  • file.slice() reads only the current chunk from disk, so the sender never loads the whole file into memory.
  • Metadata first, then binary. The receiver learns the name and size up front, which lets it show progress and know when the transfer is complete.

The receiving side

The receiver listens for messages, tracks bytes received against the expected size, and assembles the file. For moderate files, collecting the chunks and building a Blob at the end is the simplest approach, followed by a download link.

For very large files, holding every chunk in memory stops being practical. The fix is to write chunks to disk as they arrive, for example through the File System Access API in browsers that support it. Either way, the bytes go from one device to the other, and no server ever holds a copy.

Local network details

A few things are specific to running this on local WiFi:

  • ICE candidates. On the same network, host candidates usually connect directly. Modern browsers often hide local IP addresses behind mDNS hostnames (ending in .local) for privacy, and resolution generally works on a home or office network.
  • Network isolation. Some guest and public WiFi networks block devices from talking to each other. When that happens the data channel cannot open, and the tool should say so clearly instead of hanging.
  • Encryption. WebRTC data channels are encrypted by default with DTLS, so even on a shared network the file contents are protected in transit.
  • Sleep and tab switching. Phones aggressively suspend background tabs. Asking the user to keep the page visible during a transfer helps avoid stalls.

Why build it this way

Most file sharing products are built around a server that stores your files. That design makes sense for sharing across the internet and over time. For moving a file across a room, it adds an upload, a download, an account and a copy of your data on someone else's disk.

Browser-native APIs cover more of this than people expect. I took a similar approach on an offline-capable point of sale, where IndexedDB let the app keep working without a connection; I wrote about that in building an offline-first POS with IndexedDB and Dexie. For the opposite case, where files do need to live on a server, see image processing pipelines with Sharp and S3.

The short version

WebRTC data channels let two browsers send files directly to each other, with a server needed only for signaling. Chunk the file, respect bufferedAmount for backpressure, send metadata first, and write to disk on the receiver for large files. The full source for LocalPigeon is on GitHub, and you can see it alongside my other work on the projects page.

Want this kind of work on your product?

Get in touch