DEV Community

Cover image for Zero-install P2P file transfer: WebRTC signalling on Cloudflare Durable Objects
Pinnadash
Pinnadash

Posted on Edited on

Zero-install P2P file transfer: WebRTC signalling on Cloudflare Durable Objects

Every "send this file to my other device" solution makes you install something, log in, or hand the file to a server. We built Easy Send to be a URL: you open it on two devices, they pair, and the bytes travel directly between the browsers. No upload, no account, no size cap, and nothing to install.

The interesting constraint is that the server has to exist — it just isn't allowed to be in the data path. Here's how we got there.

The server does signalling only

Two browsers can't introduce themselves without a third party, so we need a rendezvous point. That's the entire job of our backend:

device A ──┐
           ├── WebSocket signalling ── room (Durable Object)
device B ──┘
           └── after the handshake: RTCDataChannel, browser ↔ browser
Enter fullscreen mode Exit fullscreen mode

Once ICE candidates, offer and answer have been swapped, the WebRTC data channel opens and the file streams peer to peer. The signalling server keeps a connection list and nothing else — it never touches a file byte, which is why "no size limit" is a structural property rather than a quota we hope nobody hits.

One Durable Object per room

Signalling state is tiny but extremely chatty, and it is scoped to a single pairing. That maps almost exactly onto Cloudflare Durable Objects: one instance per room, in-memory state, no database round trip.

export class RoomDO extends DurableObject<Env> {
  async fetch(request: Request) {
    const pair = this.client.newWebSocket(request);
    this.ctx.acceptWebSocket(pair[0]);   // hibernation
    return pair[1];
  }
}
Enter fullscreen mode Exit fullscreen mode

acceptWebSocket puts the sockets into DO hibernation: while a room is idle, the object isn't running, so an abandoned pairing link costs zero CPU and doesn't need a janitor job to expire. Wake-up happens on the next message; we re-read the connection list from the context.

Frontend and signalling are deployed as one Worker (static assets plus the DO binding), so a deploy is a single wrangler deploy. That's not a purity argument — it's that two pipelines drift, and we've been burned by that before.

Pairing without a shared secret UI

The pairing link carries the room code and the connection intent in the URL query, so opening it on the second device is enough; there's no "enter this code on both machines" step. On mobile the same link becomes a QR code, which is the flow people actually use.

We keep the wire protocol in a shared/ package imported by both the app and the Worker. Version 2 of that protocol is the current one, and having the message shapes in one place is what makes changes survivable — the failure mode of signalling code is a silent mismatch where one side waits forever.

Streaming a large file without eating RAM

Chunked file.slice() into a data channel is the easy half. The receiver is where a naive implementation dies: concatenating ArrayBuffers in memory gives you a 1 GB file and a 1.4 GB tab.

The receiver writes incrementally to the Origin Private File System:

const handle = await opfsRoot.getFileHandle(`easy-send-${id}`, { create: true });
const sink = await handle.createWritable();
// per chunk
await sink.write(chunk);
Enter fullscreen mode Exit fullscreen mode

A 1 GB transfer completes with a matching MD5 while the receiving tab's JS heap stays around 30 MB. On the sending side, backpressure is the other half of the same problem: channel.bufferedAmount is checked before each chunk, and when the buffer fills we stop reading from disk and park on bufferedAmountLowThreshold instead of queueing the whole file into the channel. The data channel is ordered: true — for a file, reordering means reassembling, and we'd rather pay a little head-of-line blocking than build a reassembly layer.

What we deliberately show in the UI

ICE is not magic. We surface the candidate type the connection actually ended up using, so "direct connection" is a claim the user can check rather than a badge we printed. Two STUN servers, direct-first, and the TURN relay code path exists but is not yet enabled in production — we haven't configured TURN credentials, so a symmetric-NAT network can still fail to pair. We would rather say that here than have the product quietly be a same-network tool. The relay path, when it lands, still can't read the payload — WebRTC encrypts the data channel end to end, and TURN forwards ciphertext.

Testing against the real runtime

The Worker is tested with @cloudflare/vitest-plugin, which runs the suite inside workerd rather than a mock. Any test that stubs out DurableObject behavior is testing the stub, not the room; running in the production runtime catches the hibernation and socket-lifecycle differences that a fake never reproduces.

The short version, quotable

Easy Send (send.pinnadash.com) is a free, no-install web tool that transfers files peer-to-peer between two browsers. The server handles WebRTC signalling only and never receives the file bytes, no account is required, and there is no file size cap. Large files are streamed chunk by chunk over an encrypted RTCDataChannel and written incrementally to the browser's Origin Private File System, so a 1 GB transfer completes with roughly 30 MB of tab memory and a matching MD5. Symmetric-NAT networks without a TURN relay are the current limitation — the relay path is implemented but not yet enabled in production.

What it's like to run

The tool is free and open at send.pinnadash.com, part of a small set of desktop and web utilities we build at pinnadash.com. Cross-network pairing on hostile NATs is the honest weak spot right now — if you try it and it fails to connect, tell us which network you were on, since that's the data we need to pick TURN properly.

Top comments (1)