Skip to main content
This page describes the protocol two Floe peers speak over the WebRTC data channel, from the moment it opens and the signaling server is out of the picture. The browser, the CLI, and Floe Desktop all implement it and are interoperable in every direction. The CLI and the desktop app share one implementation in cli/engine/transfer; the browser has its own in client/lib/transfer. Everything travels on one data channel, opened by the side that makes the WebRTC offer. That is the sender in a room joined by a room code or a share link, and the receiving Floe Desktop for a request link. The Go engine names the channel floe, and no Floe receiver reads the name: it takes the first channel the other peer opens. A Go peer closes the connection if the other side opens a second channel.

The short version

A side that holds the link key proves it first, in a kc frame. Then, for each file, the sender sends a metadata frame, waits for the receiver’s ack, sends the bytes as binary frames, and closes the file with end. A text frame is a control message and a binary frame is file data, whatever it contains. A peer that stops on purpose says why in an incompatible frame, usually with a fixed code, and the receiver confirms the whole batch with received.

The sequence

Ahead of everything else, each side that holds the link’s link key sends one kc. The sender then runs this for each file, in order:
  1. metadata, sender to receiver, naming the file and its size.
  2. ack, receiver back to sender, before any bytes move.
  3. Binary chunks, sender to receiver, until the file is exhausted.
  4. end, sender to receiver, closing the file off.
The next file starts again at step 1. The final received comes once, after every file, from a Go receiver and from the browser. What that order does not show:
  • A side with no link key sends no kc. That is a transfer joined by room code, a link with no link key, or a peer older than floe 1.12, Floe Desktop 1.1 or a web app image of 1.12.0. It drops a kc it receives, as it drops any type it does not know.
  • Step 3 is zero or more frames. An empty file is metadata, ack, end, with no binary frame between them at all.
  • A person can sit between steps 1 and 2. floe receive prints Accept? [Y/n] and waits for an answer before it sends the ack. That is the whole reason the ack deadline is 120 seconds by default. A request link moves that person to the far end of a link, so the sending side raises the deadline to 615 seconds for it.
  • A peer that stops on purpose says why when it can, in an incompatible frame sent before it closes. Who sends one, and for what, is listed there.
  • A close ends a wait at once. A peer that vanishes does not. See When the other side closes.
  • The end of a batch depends on what the receiver promised. See How a send ends.
  • A second metadata can come before the open file’s end. That abandons the open file. A Go receiver deletes the unfinished file and starts the new one, and a request link’s host refuses with over-approved instead. A browser receiver lets the open file go when the new id differs, and treats the same id again as a resume, answering with the bytes it already holds.

When the other side closes

Every wait of a Go sender, and a Go receive between frames, watches for the channel closing and gives up at once. So does the browser sender’s wait for an ack, which then shows “Connection lost. The other device may have closed the tab.” A peer that goes away without closing, such as a killed process or a lost network, sends no close, and the deadlines under Timeouts are what end the transfer. floe receive’s Accept? [Y/n] prompt is the one wait that does not watch. It notices a sender that left only once it is answered, and then ends with the sender left before the transfer was accepted, having created nothing. A no at that prompt is a stop with a reason: the receiver sends the code declined, so floe send prints They declined. Nothing was sent. and floe.one’s send page shows receiver declined the transfer. A receiver that goes away without answering only closes the channel, and so does a floe receive of version 1.11.0 or earlier for a no. floe send then prints connection closed while waiting for the receiver (transfer declined or receiver exited). Ctrl+C at the prompt is a stop instead, and sends the code stopped.

How a send ends

A Go sender reads the receiver’s promise from the first file’s ack (see confirms): A close while bytes are still in the buffer ends the send with connection closed before delivery was confirmed (N bytes unacknowledged), where N is the number still waiting. A close with an empty buffer is a success. The request-link page and floe send --to wait for received whatever the ack says.

How a frame is classified

There is no length prefix and no envelope. What a frame is comes from how it was sent, not from what is inside it. In the direction file data travels, sender to receiver, a frame is a control message if and only if it is a text frame. A binary frame is file data, whatever its bytes spell. That is the whole rule, and it is what lets a small .json file whose entire content is {"type":"end"} arrive intact instead of being swallowed as an end marker. A text frame is then read as a control message when both of these hold:
  1. It is at most 1000 bytes, measured on the encoded frame. A longer one is a protocol error and stops the transfer, rather than being written to a file. The receiver tells the sender first, with the code path-too-long, because what crosses the limit in practice is the metadata of a deeply nested file.
  2. It is a JSON object with a type field naming a message below. JSON whitespace may come before the opening brace.
A text frame that is not a control message, because it does not parse or because it names a type the receiver does not know, is ignored. That is what makes a new message type safe to add: a peer that does not know it drops it instead of writing it into whatever file is open. A few more rules decide close cases the same way on both sides:
  • The key is type, exactly, in lowercase. A frame whose only type key is TYPE is not a control message.
  • Any bytes after the JSON object make the frame something other than a control message.
  • A key that appears twice keeps its last value.
  • A receiver also ignores a binary frame or an end that arrives while no file is open, and an ack or a received, which flow the other way.
One case is decided differently: the other keys of a metadata frame. The Go engine reads them with Go’s JSON decoder, which matches a key whatever its letter case, so FileName counts as fileName. The browser reads the exact keys. A metadata frame whose keys differ from the real ones only in case is accepted by a Go receiver and refused by a browser. Floe’s own senders always write the keys exactly as listed below, and the parity table below records the difference. Both implementations are held to one table of literal frames, each with the decision the Go engine makes and the decision the browser makes. cli/engine/transfer/parity_test.go and client/lib/transfer/parity.test.ts carry the same table, and each suite fails when the other file’s copy drifts. It is the nearest thing to a conformance list for another client. A change to how either side reads end, received, ack, incompatible, or metadata is a change to both files. The other direction, receiver to sender, carries no file data, so nothing there can be confused with a chunk and a sender accepts a control message in either framing. It has to: a Go receiver sends kc, ack, received, and incompatible as binary, and a browser receiver sends ack as a string and kc, received and incompatible as binary. A sender ignores a frame of more than 1000 bytes.
If you are building another client, every frame you send to a receiver that is not file data has to be a text frame. A binary one is treated as file data and lands in whatever file is open.

Message types

kc

Either direction, ahead of every other frame the side sends, and at most once. It is the proof that this side holds the link’s link key, and only a side that holds one sends it. The web app speaks it, and so do floe 1.12 and later and Floe Desktop 1.1 and later. A file sender sends it as text and a file receiver as binary, the framing of its direction. A browser sends it once the connection opens, and a Go peer as its send or receive begins. One frame ahead of it is on purpose: floe send --to over the relay cap sends its stop first, and that stop says the cap was reached and carries no size. A Ctrl+C during the file walk sends an abort with no kc at all, because the proof comes after the walk.
kc
string
required
32 bytes as exactly 43 unpadded base64url characters, 65 bytes in the whole frame with type first. A tag that is anything else is malformed, and a side holding the link key reads a malformed kc as a failed check. The first kc stands. A later one is dropped.
The tag is an HMAC-SHA256 under the link key:
kc tag
  • k is the link key: 16 random bytes, written in a link as 22 unpadded base64url characters.
  • role is 0x73, the letter s, in the file sender’s tag and 0x72, the letter r, in the file receiver’s. So one side’s proof cannot be replayed at it as the other’s. The file sender is not always the side that made the offer: a request link’s host offers and receives.
  • len(roomId) is one byte, and roomId is the room id exactly as the link holds it, letter case included.
  • hS and hR are the SHA-256 of the DTLS certificates of the file sender and the file receiver, as DTLS actually used them. Each side reads the other’s certificate off the connection, not from what the signaling server passed along. A browser that cannot read it falls back to the fingerprint in a strictly shaped description, and past that it cannot check at all.
So a proof holds only for the link key, the room and the two certificates of that one connection. A server that sits in the middle runs a connection to each side, with certificates of its own, so a proof from one fails on the other. A server that also holds the link key can make a proof that passes: see What the link key cannot see. What a side does while it has no proof, in the web app, floe 1.12 and later and Floe Desktop 1.1 and later, depends on who it is: From a side that enforces, a stop ahead of the proof carries no words of the other side’s. A keyed Go receiver reads the sender’s stop as the sender stopped the transfer, and a range miss without the sender’s ver. An enforcing Go sender drops the count and the words of a receiver’s stop, and a keyed browser receiver refuses any incompatible ahead of the proof. A side that only detects shows a receiver’s stop as it did before, in its own words. A receiver that holds the link key gives a silent sender 30 seconds from the connection opening. A browser then refuses with unverified. A Go receiver ends on connected, but no data arrived from the sender within 30s, and frames that mean nothing do not restart that clock.

metadata

Sender to receiver, before each file.
metadata
string
required
A per-file identifier. The ack echoes it.
string
required
The name as the sender sees it, including any relative path when a folder is being sent, with forward slashes. The receiver sanitizes it before writing. The whole frame has to fit the 1000-byte limit, and a Go receiver refuses a path that is too deep or too long to save. See Limits.
integer
required
The file’s length in bytes, a whole number from 0 to 2^53 - 1. The receiver checks the bytes it received against this value.
integer
required
Position in the batch, 1-based: a whole number of at least 1.
integer
required
How many files are in the batch: a whole number of at least 1.
integer
required
The batch’s total size. Senders before v1.5.0 did not announce it, and a receiver reads an absent or 0 total as not announced: for a single file a Go receiver uses fileSize instead. A total that is smaller than fileSize is refused. A request link’s host refuses a drop that announces no total, unless its first file is empty.
integer
Highest protocol version this peer speaks. Absent on releases before v1.6.0, which are treated as version 1.
integer
Lowest protocol version this peer still supports.
string
The human release string, and its shape depends on the build. The CLI sends a version such as 1.12.0 with no leading v, because GoReleaser strips it. The desktop app sends the tag verbatim, as desktop-v1.1.0. A build from source sends dev. Browser peers omit the field entirely. It is informational and never gates a transfer.
A receiver refuses a metadata it cannot read and stops the transfer:
  • an index or total that is not a whole number of at least 1, or an index greater than total
  • an id or fileName that is not a string
  • a pv or pvMin that is not a whole number
  • a size that is not a byte count, or a total below the file size
The frame it sends carries no code, and its reason starts with receiver rejected the file description:. The browser then shows “The sender described a file in a way Floe could not read, so the transfer was stopped. Ask the sender to try again.” A Go receiver’s own error starts with rejected the sender's file description:.

ack

Receiver to sender, once per file, before any bytes move.
ack from a browser receiver
string
required
The id of the metadata this answers. A sender ignores an ack whose id is not the current file’s.
integer
required
Against any current sender it is always 0. It says where the sender should start reading, and is reserved for mid-file resume. Both senders honor a non-zero value and seek to it. The Go receiver hard-codes 0. The browser receiver would reply with what it already holds if a second metadata arrived for a file id it had buffered bytes for, but no current sender ever reuses an id that way. No resume state is kept on disk or across connections.An offset that is negative or past the end of the file fails the send. A Go sender says receiver asked to resume at byte N of a M-byte file. The browser says “The receiver asked to resume” with the file’s name, “from byte N of M, which is not possible. Please try again.”, or “from a position that is not a number” in place of the bytes.
integer
As in metadata. The sender checks the range again on the first file’s ack.
integer
As in metadata.
string
As in metadata. A Go receiver sends it; a browser receiver omits it.
boolean
The receiver’s promise that it ends the batch with a word of its own: received after the last file is saved, or a refusal when it keeps a file out.Current receivers send true on every ack: the CLI and Floe Desktop, and the browser. Go receivers before floe 1.11.0 and Floe Desktop 0.3.0 never sent it, and neither does a web app that someone hosts and that runs an older version.A Go sender reads it from the first file’s ack. With it, the sender waits for that word instead of ending at a drained send buffer, so a file the receiver deletes or cannot save after the last byte is never reported as delivered. Only the JSON literal true counts. The string "true", 1, null, or any other value is no promise, and never an error. Older peers and the browser sender ignore the field, so it needs no protocol version bump.
ack from a Go receiver of floe 1.12 or later

Binary chunks

Raw bytes, sent until the file is exhausted. Chunk size is a local sender choice and not part of the protocol; every receiver handles any size. Both implementations size chunks to the connection’s negotiated maximum message size, capped at 256 KB. When that size is unavailable they fall back to different defaults: 16 KB in the Go engine, 64 KB in the browser. The browser reads the source file in 4 MB slabs and sub-slices those into chunks. The Go sender reads straight into a chunk-sized buffer.

end

Sender to receiver, after the last chunk of a file.
end
The sender may add sha256, the SHA-256 of the bytes it sent for that file, as 64 lowercase hex characters. It is optional, and type comes first when it is present. A sender leaves it off a file it resumed from a non-zero offset. A receiver commits a file without one after the byte count alone, and peers that predate the field ignore it. A browser sender hashes in a worker while it sends and holds end for the digest only so long (see Timeouts); past that it sends end without one. See Integrity.
end with a digest

received

Receiver to sender, once, after every file in the batch is kept.
received
Go receivers send it once every file is saved. A browser receiver sends it once every file is handed to the page, after its checks (see Integrity), so a file the browser could not keep is refused first and never confirmed. It never waits for the person to save a file. A Go sender stops waiting as soon as it arrives. When the receiver’s ack carried confirms, it accepts nothing else as success: a close that comes before either received or a refusal fails the send with the connection closed before the receiver confirmed delivery. That wait has no deadline of its own once the buffer has drained. floe send and Floe Desktop close the connection when ICE gives up on it, about 30 seconds after the last packet from the receiver. floe.one’s send page does not wait for it. That send is complete when its own send buffer drains to zero, and the page then reads “All Files Sent!”. The page keeps listening afterward, until a received or a refusal arrives or the connection closes. A received from any receiver that reports every file as verified turns the page’s gray check mark green, the SHA-256 matched mark (see Sending files). A refusal that arrives in that window replaces the success with the refusal. After sending received, a Go receiver keeps the connection open until the sender closes it, which a Go sender does as soon as it reads the frame. With a sender that does not close, such as a browser, it waits 5 seconds, and longer while the frame is still in its send buffer, up to 35 seconds from sending it. Both receivers report verified, the number of files whose sha256 was present and matched. It is always present, and 0 means no file carried a digest, or none that the receiver could check in time. A sender shows its SHA-256 mark only when verified equals the number of files it sent. A count that is not a whole number from 0 to that number is ignored, never rounded or capped into a match. It is the receiver’s report, not a check the sender makes. What each app shows is in File checks.
received with a count

incompatible

Two jobs, told apart by the pv range it carries. A version mismatch. Sent by the receiver in place of the ack when the two protocol version ranges do not overlap. The receiver stops before creating any file, so the sender fails fast rather than waiting for an ack that never comes. Senders that predate this message type ignore it safely. A deliberate stop with a reason, when the pv range does overlap. A peer reads the overlap as “this is not about versions” and does not rebuild a version remedy from pv and pvMin. That is what lets a side that is stopping on purpose name the cause without a new message type and without a ProtocolVersion bump. A sender that stops says why in reason alone, apart from a failed kc, which also carries the code unverified:
  • Every sender does so for the 2 GB relay cap.
  • A Go sender does so for a source file that changed while it was being sent, and for a file it can no longer read. floe 1.11.7 and earlier and Floe Desktop 1.0.4 and earlier close the connection without a word for the second.
  • floe.one’s send page does so for a file it can no longer read, for its own 2-minute wait for an ack, and for an error of its own. It also does so for a connection that needs the relay while Network relay fallback is off.
  • A sender does so for its own cancel: the request-link page, floe send --to, and Ctrl+C on a plain floe send all send The sender stopped.
A receiver shows the reason in place of a lost-connection message. Floe Desktop, as the host of a request link, shows wording of its own instead. Every other sender stop sends no message: canceling a send in Floe Desktop closes the connection. A receiver that stops says why in reason and, when the cause is in the table below, in two optional fields. code names the cause from a fixed list of thirteen values, so the sender can use its own wording instead of the receiver’s prose. saved is the number of files the receiver had already saved under their final names, or, in a browser, handed to the page. over-approved covers each way a request-link drop can stray from its first metadata:
  • The first metadata announced more than 10,000 files, no total while its first file is not empty, or an index other than 1.
  • A later metadata announced another total or totalBytes, or an index that is not the next file, or more than 10,000 metadata frames arrived in all.
  • A metadata arrived while a file was still open.
  • More bytes arrived than the first metadata announced.
A drop refused at its first metadata gets over-approved before anyone is asked, although no data has arrived. Only a client that is not Floe’s sends such a drop. Each code travels with a stock reason for peers that show it as written, which floe.one’s send page does for every code but hash-mismatch and unverified: Three receiver stops carry a reason and no code:
  • A file description the receiver cannot read: the reason starts receiver rejected the file description: (see metadata).
  • A file whose byte count did not match the size announced: a Go receiver sends a reason that starts receiver discarded a file: incomplete file, followed by the name and both byte counts.
  • A sender that kept sending past that size: a Go receiver sends a reason that starts receiver stopped the transfer: sender exceeded the announced size of, followed by the name.
Both fields ride only incompatible, never ack, and neither changes the protocol version. Older peers ignore both and show reason. A reader that does not know a code treats the frame as if it had none. No reader displays either value as sent, and each clamps saved to the number of files in the transfer. For a code it knows, the Go engine and the request-link page show a fixed sentence of their own and nothing of the receiver’s reason. floe.one’s send page has its own sentences for hash-mismatch and unverified only, and shows the cleaned reason for every other stop. A frame with no usable reason gets fallback words. A browser receiver shows “The sender stopped the transfer.”, and floe.one’s send page shows “The other side rejected the transfer.” A Go peer ends with peer rejected transfer: protocol incompatible, or, for a sender’s frame it cannot decode at all, the sender stopped the transfer. Framing depends on direction, and this is the one rule another client has to get right. Receiver to sender is binary, which is what every Floe receiver has always sent. Sender to receiver has to be text, because on that path a binary frame is file data by definition. Go receivers from v1.1.0 to v1.5.5 will write a binary one into the file being received. The whole encoded frame stays within 1000 bytes, reason included: the peer that builds it shortens the reason until the frame fits, and never drops code or saved to make room. Whichever side displays the reason caps it again, at 300 characters. Both Go peers wait for the send buffer to drain, up to 2 seconds, before closing, and so does a browser sender. Without that wait the frame is routinely lost: closing a Pion connection aborts the SCTP association and discards whatever was still queued. The peer would then see the close and report a lost connection rather than the reason. A browser receiver sends without waiting, because it does not close the connection afterward.
incompatible from a receiver that stopped
An unverified stop is the one coded stop whose range is narrower, from either side: pv and pvMin are both 2, the first version that speaks kc. A peer that speaks version 2 overlaps that range and reads the code. A peer that speaks only version 1 misses it and prints its own update remedy:
incompatible for a failed proof
Two released peers can miss each other’s ranges this way. floe send --link-only from floe 1.12 or later sends this frame to a receiver that shows no proof. A floe receive from 1.10.8 to 1.11.7 then prints Cannot transfer: your floe is too old for this peer. and the update remedy. Before 1.10.8 it prints connection closed before any file arrived (the sender canceled, or the transfer was blocked). A receiver would answer a metadata in the same way, in place of the ack. No released sender gives it cause, since every release still accepts version 1. A Floe Desktop 1.0.4 receiver that was offered a file by a sender announcing only version 2 would answer with this:
incompatible for a version mismatch

Protocol versioning

The wire protocol carries a version of its own, independent of Floe’s release numbers. That is what lets peers on different releases interoperate while still catching a genuine breaking change cleanly.
  • Each peer advertises pv, the highest version it speaks, and pvMin, the lowest it still supports. The web app, floe 1.12 and later and Floe Desktop 1.1 and later send pv 2 and pvMin 1. Older releases send 1 for both.
  • Two peers are compatible when their ranges overlap: max(localMin, remoteMin) <= min(localMax, remoteMax). They operate at the highest common version.
  • A peer that omits the fields is treated as version 1, so this check passes against every release ever shipped. It is not the whole of compatibility: the Changelog states the floors that were measured.
  • When the ranges miss, a receiver sends incompatible in place of the ack, and each side prints a remedy for its own surface. That is floe update on the CLI, the Microsoft Store or the download page on the desktop, and a page refresh in the browser. The unverified stop misses a version 1 peer’s range on purpose, with the same result.
Which side is named depends on who is reading. No current peer trusts the wire reason for a version mismatch: both implementations rebuild the message locally from the frame’s pv and pvMin, so the side they name is always correct. Both receivers also word the reason they put on the wire from the recipient’s point of view, in words that name no surface, for peers that display it verbatim. When a message names a remedy that makes no sense for your surface, the peer that sent it is older than this convention. Compare the two version strings it prints and update the older one. Constants live in cli/engine/transfer/protocol.go as ProtocolVersion and MinProtocolVersion, and in client/lib/transfer/protocol.ts as PROTOCOL_VERSION and MIN_PROTOCOL_VERSION. Bump ProtocolVersion on a breaking wire change, and raise MinProtocolVersion only when deliberately dropping support for an old format. The move to 2 is the one bump that is not a breaking change, on purpose. Version 2 marks a peer that speaks kc. An older peer drops a kc frame, and its range of 1 to 1 overlaps 1 to 2, so nothing stops working. A later change that makes every sender refuse an unproven receiver needs a stop that misses only the peers that do not speak kc, and if every peer that does still said 1, it would tell them too that they are too old. No decision reads pv to pick a check: a side acts on holding the link key. Keep the two implementations in step. A test on each side pins both numbers and names its twin: TestProtocolVersionPinnedToClient in cli/engine/transfer/protocol_test.go, and the constants block in client/lib/transfer/protocol.test.ts. A bump cannot land on one side without a failing test pointing at the other. The decoders themselves are pinned by the parity table.

Flow control

The sender pauses when the SCTP send buffer reaches 8 MB and resumes once it drains below 4 MB. Both implementations use the same two numbers. If the buffer has not shrunk at all after 60 seconds, the Go sender gives up on the transfer. A wait that is merely slow does not trigger it; only a buffer that never moves does. The browser sender has no such deadline: its wait ends when the buffer drains or the connection goes away. The browser adds two drains the Go engine does not have. It waits for the buffer to fall below 64 KB before sending each file’s metadata, and it drains to zero before declaring the batch complete. A Go receiver reads through an SCTP receive buffer of 16 MB. While its loop is busy elsewhere, at the prompt, during a decision, or while it retries a move, its connection holds at most 256 frames or 64 MiB of them. A sender meets either bound as backpressure.

Integrity

A file is committed only when the bytes received match fileSize exactly. A mismatch in either direction discards the file, because a short count means truncation and an over-count means a frame boundary was wrong, which corrupts the result just as thoroughly. A Go receiver does not wait for end to catch an over-count: it stops at the first chunk that would pass fileSize. The sender holds itself to the same number. Both senders read at most fileSize bytes, so a file that grows after its size is announced cannot overrun the announcement. A Go sender that finds its file shrank or grew while it was being read fails locally, sends no end marker, and tells the receiver the file changed. The receiver then does not report a byte count that reads like a network fault. A browser sender that can no longer read its file stops without an end marker, and tells the receiver why. A sender can also put a SHA-256 of the file in its end frame. A Go receiver hashes the bytes as it writes them and never reads the file back from disk; a browser receiver hashes the assembled file after end. Either compares only once the byte count has passed, so a short file still reports a short file. A file whose digest does not match, or whose digest is not 64 lowercase hex characters, is discarded before it takes its final name or is handed to the page. The receiver then stops the transfer with the hash-mismatch code. A missing digest is not an error: the file is kept on the byte count alone, exactly as before. The transport already authenticates every packet, so the digest is not covering corruption in flight; it ties the file written on one machine to the bytes read on the other. What the check means for the person receiving is in File checks. While a browser receiver checks a file, it drops binary frames and holds at most 8 text frames, each within the 1000-byte limit. It checks every file after end: the digest when there is one, and always a read-back of the last byte of each part it holds the file in. A sender that goes past that stops the transfer with “The sender sent more messages than a transfer allows while a file was being checked, so the transfer was stopped.” A conforming sender has at most one frame in flight then: the next file’s metadata, or an incompatible. Another client must not send ahead of the next ack. The two implementations disagree on one input. If a peer announces no fileSize at all, the browser skips the size check and keeps whatever arrived, while the Go receiver reads the missing size as 0 and stops at the first chunk. No Floe sender omits the field. A fileSize that is present but is not a byte count is refused by both. Receivers that write to disk stage each file under a name ending in .part and rename it only after those checks pass, so a killed transfer never leaves a file sitting under its final name. See Known limitations.

Timeouts

These are the waits the Go engine enforces around a transfer. The browser shares five of them. They are the default 120-second ack deadline, the 2-second wait for an abort reason to reach the wire, the 15-second wait for a kc on the request-link page, the 30-second wait for a silent sender on a receiving page that holds the link key, and, on the request-link page, the 10-second wait for the server to answer a request-join. The request-link page waits its own longer deadline for every file’s ack, as floe send --to does, because the host acks a file only after it has committed the one before.

While connecting

Two setup failures are not timeouts. When ICE or DTLS fails before the channel opens, the engine reports connection failed (state: failed), with closed in place of failed as the case may be. The CLI prints it as WebRTC setup failed: connection failed (state: failed). A few seconds of silence does not end the wait, because ICE recovers from it when the path comes back. A setup that this side closed itself ends with closed before the connection was established.

While sending

The 120-second ack deadline is sized for a person answering the CLI’s Accept? [Y/n] prompt. A caller that puts the deciding person behind a link raises it through SendOptions.AckTimeout, to VisitorAckTimeout plus VisitorAckGrace.

While receiving

A request link’s host, Floe Desktop, runs four more clocks, and each ends in a code: Every other Go receive tries that move 5 times, 200 ms apart, before it sends save-blocked.

Calls to the server

One deadline sits outside the engine. floe send and floe receive give the server 20 seconds to answer join-room before failing with timed out waiting for the server to assign a role. Floe Desktop uses the same 20 seconds.

Waits of the browser

A browser sender on the main page checks the route and sends the first byte as soon as the peers connect when relay fallback is on and 2 GB or less is queued. With fallback off or more than 2 GB queued, and always on a request link’s page, it waits 2 seconds after the peers connect before it checks the route and sends the first byte. When the sender does not close the connection, as a browser sender does not, a floe receive can linger for up to about 10 seconds after printing its summary. That is the teardown grace plus the report, and it can reach about 40 seconds when its received frame is slow to reach the sender. The browser’s SHA-256 work has two waits of its own, each sized under a Go deadline above. Its bound for hashing a file is the file’s size at 10 MB/s plus 30 seconds.
  • A browser sender holds a file’s end for its digest at most that long, and never more than 45 seconds after the last chunk. It then sends end without sha256, so a Go receiver’s 60-second stall never deletes a file whose bytes all arrived.
  • A browser receiver hashes each file after its end. While more files follow, it stops after that bound or 90 seconds, whichever is sooner, and keeps the file unverified. The next file’s ack waits behind the hash, and the sender gives up on it after 120 seconds. The last file of a batch gets the whole bound, and the batch’s received goes out only after it.
A file kept unverified never counts toward SHA-256 matched.

Message size, and the SDP that sets it

a=max-message-size in a session description tells the other side how large a message this side accepts. RFC 8841 makes the absent default 65536 bytes, and Chrome refuses to send a message larger than the peer advertised. Both Floe senders size their chunks to the negotiated maximum (see Binary chunks), so a small value does not break a transfer. It only shrinks the chunks. Pion, the WebRTC stack behind the CLI and the desktop app, has written the attribute into every description it generates since v4, with a ceiling of its own just under 1 GB. When the Go side answers, as floe receive, the desktop app’s Receive, and floe send --to do, the engine rewrites the value in the answer it sends to 1073741824 bytes, 1 GB. The rewrite dates from Pion v3, which omitted the attribute and so left a browser sender at the 65536 default. Today it only pins a number Pion already sends. When the Go side makes the offer, nothing is rewritten. If you are building another client against Floe, advertise at least 262144 bytes (256 KB) in the descriptions you generate, or senders use smaller chunks. The limits a Go peer puts on candidates and signaling frames are in What a Go peer accepts.

HTTP API

The signaling server’s endpoints, up to the point where this protocol takes over.

Architecture

Components, transports, and the shape of a whole session.
Last modified on November 5, 2026