Connection lifecycle
- You create a transfer and share the link or short code with the recipient.
- Both devices join the same room on the signaling server.
- The server assigns roles: the first peer to join is the sender, the second is the receiver.
- Peers exchange a WebRTC offer, answer, and ICE candidates through the server.
- Once a usable network path is found, the data channel opens between the two devices. On most networks that path is direct. When a strict firewall or carrier-grade NAT blocks a direct path, the channel runs through a TURN relay instead.
- The signaling server drops out of the transfer path. Every byte of file data flows over the data channel and never returns to the server. The only later contact about the transfer itself is optional: the receiver can report the transfer’s byte count for the public counter, and that can be turned off.
What the signaling server does
- Assigns roles: the first peer to join a room becomes the sender, the second becomes the receiver. A room holds exactly two peers, so a third device trying to join is turned away with
room-full. A share link cannot be opened by two recipients at once. - Relays WebRTC negotiation messages: the initial offer, the answer, and ICE candidates.
- Issues short-lived TURN credentials when a TURN server is configured.
- Registers short human-readable codes (e.g.
olive-tiger-castle) that map to room IDs. - Adds the size of each completed transfer to one anonymous global byte total, for the public homepage counter.
What the signaling server never does
- Handle file data of any kind.
- Store files, file names, transfer content, or logs of what you send or receive.
- Link the byte total to you. The counter is a single shared number with no per-user or per-transfer records.
- Retain peer information after the session ends. The room entry is dropped as soon as both peers disconnect. A short code stays resolvable until its 10-minute TTL expires, but it maps to nothing except a random room UUID.
Technical details
Technical details
Transports: Browser clients connect via Socket.IO. The CLI and the desktop app connect via WebSocket at
/ws, using the same signaling client. All of them share a single in-memory rooms registry (Map<roomId, [peer, peer]>), so any surface can transfer to any other: browser to CLI, browser to desktop, CLI to desktop.WebRTC negotiation flow:- Sender joins the room (
join-roomevent) and receives thesenderrole. - When the receiver joins, the server emits
user-connectedto the sender. - Sender creates an SDP offer and sends it via the
signalevent. - Receiver sets the remote description, creates an SDP answer, and sends it back.
- Both peers exchange ICE candidates via
signalevents (trickle ICE). - Once ICE negotiation completes, the WebRTC data channel opens over the nominated candidate pair, either directly between the peers or through a TURN relay.
POST /api/code with a room UUID. The server picks three random words from a 288-word list using a cryptographically secure random source (crypto.randomInt, not Math.random), because the code phrase is the only secret guarding a transfer, and maps them to the room ID with a 10-minute TTL. If a generated phrase collides with a code that is still live, the server retries, and in the very unlikely event that ten attempts all collide it issues a four-word phrase instead. GET /api/code/:code resolves a code back to its room UUID.TURN credentials: Issued via GET /api/turn-credentials. On floe.one the server mints short-lived credentials (up to 24 hours) from Cloudflare’s Realtime TURN service. Self-hosted instances can instead use coturn with HMAC-SHA1 credentials, which requires both TURN_SECRET and TURN_DOMAIN to be set (setting only one falls through to STUN with no error), or skip TURN entirely, in which case the endpoint returns public STUN servers only.Rate limiting: 30 connections per IP per 60 seconds for Socket.IO and WebSocket; 20 requests per IP per 60 seconds for the TURN credential endpoint; 60 requests per IP per 60 seconds shared across POST /api/code and GET /api/code/:code; 60 reports per IP per 60 seconds for the stats endpoint. The signaling server also caps the number of registered room codes held in memory (10000 by default), a count that includes expired codes the 60-second sweeper has not cleared yet; POST /api/code returns 503 when that cap is hit.Global stats counter: After a transfer completes, the receiver sends POST /api/stats/report with just the byte count. The sender never reports. The server keeps a single anonymous running total (cached in memory, optionally persisted to Upstash Redis) and serves it from GET /api/stats for the homepage counter. No file names, contents, or per-transfer records are stored. The total is displayed only in the browser; the CLI and desktop receivers contribute without ever showing it. Every receiver can opt out: the browser has a “Contribute to global stats” toggle, the CLI takes --no-report or FLOE_NO_STATS=1, and the desktop app has a Contribute to global stats setting that is remembered between runs. When a receiver opts out, no report is sent at all. See the architecture reference for details.Data-channel transfer protocol: Once the WebRTC data channel opens, the sender runs this sequence for each file:- Sends a JSON
metadatamessage with a per-file id, the filename, the size in bytes, the file’s index and total count in the batch, the batch’s total byte count, and the sender’s protocol version range and release string. - Waits for the receiver to reply with a JSON
ackmessage. The ack carries a byte offset, so a transfer can resume mid-file, plus the receiver’s own protocol version range. If the two ranges do not overlap, the receiver sends anincompatiblemessage instead of an ack, and both sides stop before any file bytes move with a prompt to update or refresh. - Streams the file as binary chunks until the whole file is sent. Every sender (the browser, and the shared Go engine behind the CLI and the desktop app) sizes chunks adaptively to what the connection negotiates, up to 256 KB, and applies backpressure-based flow control, pausing when the data channel’s send buffer fills.
- Sends a JSON
endmessage to signal completion.
received message so its sender knows delivery is confirmed and can close cleanly. Browser receivers never send it.