Skip to main content
A map of the moving parts, for contributors and for self-hosters who want to know what they are running. For the plain-language version, see How a transfer works.

Components

The CLI and the desktop app share one Go transfer engine in cli/engine/, joined by the go.work workspace file, so they behave identically on the wire. File data never passes through the signaling server, and only end-to-end-encrypted packets ever transit the relay. Every byte of every file travels over a WebRTC data channel between the two peers.

The server runs as one process

Rooms, room codes, and all four rate limiters live in that process’s memory. Nothing is persisted except the global byte counter, and only when a Redis database is configured. This has one consequence worth stating plainly: running two replicas behind a load balancer does not scale Floe out. It breaks pairing, because the two peers can land on different replicas and never see each other. Run one instance. A single small server goes a long way, since no file data passes through it.

Two signaling transports, one room registry

Both share the same in-memory rooms registry, Map<roomId, [peer, peer]>, which is what makes browser-to-CLI and browser-to-desktop transfers work with no translation layer. /ws is matched by exact string equality. A trailing slash or a path prefix does not match, and the connection is dropped with no error, which matters when you put Floe behind a proxy.

Socket.IO events

Client to server: Server to client:

WebSocket messages

Every message is a JSON object with a type field. Client to server: Server to client: Both transports cap a single message at 1 MB. An oversized WebSocket frame is closed with code 1009. Signaling messages are SDP and ICE candidates, so this is a guard rather than a limit anyone meets. A room outlives a one-sided drop. When a peer disconnects the server removes only that peer and keeps the room for the other one, which is what lets a browser rejoin the same room after its socket reconnects. The room is deleted when the last peer leaves. The connection is kept alive below this layer. Every 30 seconds the server sends a WebSocket protocol-level ping to each /ws client and terminates any that did not answer the previous one. The Go client answers automatically, so nothing in Floe’s own message set is involved. A reverse proxy therefore needs an idle read timeout longer than 30 seconds, or it cuts healthy connections.

A session, end to end

  1. The sender generates a UUID room id. All three surfaces put it in the fragment of a share link, and browsers never send a fragment to a server, so opening the link keeps the room id out of request URLs, Referer headers, and analytics. The CLI and the desktop app additionally register it with POST /api/code to get a three-word phrase; the web app has no code UI. The server therefore learns the room id from that registration if there is one, and otherwise only when a peer joins.
  2. The sender joins the room. The server assigns the role sender.
  3. The sender displays the code, link, or QR code and waits.
  4. The receiver joins the same room. The server assigns receiver and tells the sender someone arrived.
  5. The sender creates a WebRTC offer and sends it through the server.
  6. The receiver answers.
  7. Both exchange ICE candidates as they are gathered, rather than in one batch.
  8. The data channel opens and the signaling server plays no further part.

Rate limiting

Counters live in plain Maps and are swept every 60 seconds. The three HTTP limiters answer 429. The connection limiter is not an HTTP response: a Socket.IO handshake over the limit is rejected in middleware, which the browser sees as a connect_error, and a WebSocket over the limit is closed with code 1008. Every limiter records only what it admits. A refused connection attempt leaves no timestamp, just as the three HTTP limiters return the 429 before recording anything, so a client that keeps retrying gets back in as soon as its admitted connections age out of the 60-second window. One behavior does surprise people: setting any MAX_* variable to 0 does not disable a limit; it silently restores the default. The active-code cap is global rather than per-IP, because it guards the size of the in-memory registry. It counts stored entries rather than genuinely live ones, so the number can briefly include codes that have already expired but that the once-a-minute sweeper has not cleared.

Room codes

Three random words joined by hyphens, drawn from a 1247-word list in server/words.json (the EFF short word list without its hyphenated entry and without words that read badly in a shared code) using crypto.randomInt, a cryptographically secure source rather than Math.random. That matters because the phrase is the only secret guarding the transfer. On a collision with a code that is still live, the server draws again, up to ten times, and only if all ten collide does it fall back to a four-word phrase. An expired code counts as free. Codes expire ten minutes after registration, and resolving one does not delete it.

Global statistics counter

The homepage shows a public, all-time total of bytes transferred by everyone. Because file data never reaches the server, the counter is fed out of band: after a transfer completes the receiver reports the number of bytes it received. Only the receiver reports, so each transfer is counted once, and the data-channel protocol is unchanged. The total is viewable only in the browser. The CLI and desktop receivers contribute to it and never fetch or display it. The server keeps the running total in two places. An in-memory value answers every read, so homepage polling never touches Redis. Each accepted report additionally fires an atomic increment against Upstash Redis over its REST API, and the in-memory value is seeded from that key on startup, so the total survives restarts. Without Redis configured the counter is memory-only and resets to zero on restart. Guardrails are deliberately light: a per-IP rate limit and a per-report size cap. The figure is an honest best-effort metric rather than an audited count, and the stored value is a single anonymous integer with no file names, no per-transfer rows, and no link to any user or IP. Every receiving surface can opt out, and the opt-out happens on the client before any request is made; the server has no role in it.

Where the details are

HTTP API

Every endpoint, with request and response shapes, status codes, and the CORS rules.

Transfer protocol

The data-channel wire format, protocol versioning, flow control, and every timeout.