I built Warp so two people can transfer a file without uploading it to my infrastructure. The sender keeps the source file open. The receiver accepts the offer. Their browsers exchange the bytes through an encrypted WebRTC DataChannel.
That choice removes the hosted file store, its retention policy, and its bandwidth bill. It also leaves me with engineering work that an upload service would hide: NAT traversal, browser send queues, receiver storage quotas, and incomplete writes.
The MIT-licensed repository contains a React 19 and TypeScript frontend in web/, plus a Cloudflare Worker and Durable Object in server/. The production app uses Cloudflare's free tier. File size does not determine the signaling server's bandwidth use because the browsers carry the file traffic themselves.
The bill follows the file path
When you send a large archive through WeTransfer, Dropbox Transfer, or SendGB, you use a hosted transfer product. Those products offer different quotas, prices, and retention rules. I would not infer their internal storage providers from those limits or claim that they all use S3.
For a store-and-forward design, though, the cost model is concrete: the operator accepts the upload, retains the object, and serves downloads. An S3-style implementation needs object storage, upload handling, and a delivery path. If three recipients download a 10 GiB archive, the operator delivers 30 GiB of payload, apart from the sender's initial upload. Pricing depends on the provider, caching, region, and plan.
I chose a direct transfer path for Warp. The sender's connection carries the outbound traffic, and each recipient needs a live connection to the sender. I give up the ability to close the sending laptop and let someone download tomorrow.
Store-and-forward:
sender -> hosted object -> recipient
|
+-> retention, quota, delivery costs
Warp:
sender =================> recipient
encrypted file bytes
\ /
+---- signaling -----+
SDP and ICE
This drawing describes an architecture comparison, not measured traffic from a benchmark.
Separate rendezvous from file transport
I split the system into a signaling plane and a data plane.
In the signaling plane, the browsers open WebSockets to the Cloudflare Worker. The Durable Object assigns a room code, tracks connected peers, and relays WebRTC offers, answers, and ICE candidates. The browsers need this rendezvous before they know how to reach each other.
In the data plane, each pair of browsers opens an RTCDataChannel. WebRTC carries SCTP messages over DTLS. The endpoints encrypt the traffic; the signaling service receives no file payload in this transfer path.
sequenceDiagram
participant A as Sender browser
participant W as Cloudflare Worker
participant D as Signaling Durable Object
participant B as Receiver browser
participant S as Google STUN
rect rgb(238, 242, 255)
Note over A,B: Signaling phase over WebSockets
A->>W: Create room
W->>D: Upgrade and register socket
D-->>A: Room code
B->>W: Join room code
W->>D: Register second socket
A->>S: Discover public address
B->>S: Discover public address
A->>D: SDP offer and ICE candidates
D->>B: Relay signaling
B->>D: SDP answer and ICE candidates
D->>A: Relay signaling
end
rect rgb(236, 253, 245)
Note over A,B: Direct transfer phase: DTLS and SCTP
A->>B: File offer over DataChannel
B->>A: Accept
A->>B: Binary file chunks
B->>B: Write accepted bytes to receive sink
A->>B: File end
end
I keep the signaling socket available after connection setup. Warp uses it for membership changes, recovery, and an out-of-band cancel message. Calling the service an introducer does not mean the browser closes that socket once the channel opens.
The source boundaries help contributors trace a transfer:
| Responsibility | Source path |
|---|---|
| Worker routing and room membership | server/src/index.js |
| Browser WebSocket client | web/src/lib/warp/signaling.ts |
| Peer connection and send pump | web/src/lib/warp/peer.ts |
| Control-frame types | web/src/lib/warp/transfer.ts |
| Receive sink accounting | web/src/lib/warp/receiveController.ts |
| OPFS worker and staging | web/src/lib/warp/opfsStage.ts |
| React orchestration | web/src/lib/warp/useWarpTransfer.ts |
Hibernation changes how I keep room state
A connected WebSocket does not need to keep a Durable Object's JavaScript instance resident. With Cloudflare's WebSocket Hibernation API, I accept sockets through the Durable Object context. Cloudflare can evict an idle instance while retaining its connections, then reconstruct the instance when a message arrives.
That makes an ordinary in-memory Map a poor source of room membership. The constructor runs again after hibernation; the previous map no longer exists.
Warp attaches { ip, peerId, room } to each socket with serializeAttachment(). The server reconstructs membership from getWebSockets() and the socket attachments. The current implementation routes connections to one global Durable Object, which fits the project's present scale. Sharding would require another routing design.
I also need to distinguish zero cloud file storage from zero persistent metadata. Warp stores no file bytes on the server. The current server does write a reclaim:<code> record when the last socket leaves a room. That record reserves the rendezvous code for three minutes so disconnected peers can rejoin. It contains no transfer payload, and the server checks its expiry before reclaiming the room.
The rooms remain session-oriented. I have no object store of uploaded files and no long-lived download archive. Describing this version as having no persistent server state at all would conceal the reclaim record.
Hibernation avoids idle duration charges; it does not eliminate the cost accounting for incoming messages, execution, or storage operations. Warp runs at $0 within the free plan's allowances. Readers who self-host should check Cloudflare's current Durable Objects pricing before treating a hobby deployment as an unlimited service.
STUN-only gives me a defined failure mode
Warp configures public Google STUN endpoints:
const pc = new RTCPeerConnection({
iceServers: [
{ urls: "stun:stun.l.google.com:19302" },
{ urls: "stun:stun1.l.google.com:19302" },
],
});
const channel = pc.createDataChannel("warp", { ordered: true });
channel.binaryType = "arraybuffer";
STUN lets the browsers discover server-reflexive addresses. ICE tests candidate pairs and attempts to establish a direct path. The STUN server handles address-discovery traffic, not the file stream.
I configure no TURN server. A TURN relay could help when direct traversal fails, but its operator would carry the file bytes and pay for that bandwidth. Adding one would change both Warp's economics and its file-path guarantee.
Some symmetric NAT combinations, UDP restrictions, firewalls, and network isolation policies prevent the browsers from connecting. Warp reports nat-failed when initial traversal fails. I do not promise that sharing a Wi-Fi network guarantees success: guest isolation or endpoint policy can still block the path.
The user can try another network. I accept that loss of connectivity coverage instead of adding a paid relay fallback.
Chunk size and queue size solve different problems
In peer.ts, Warp targets 256 KiB messages, meaning 256 * 1024 bytes. A DataChannel message is not an IP packet; the WebRTC stack can fragment it below the application layer.
I cap each message against the negotiated pc.sctp.maxMessageSize. Sending 256 KiB without checking the peer's limit risks a failed send(). The target also exceeds the 16 KB recommendation in RFC 8831, section 6.6, which addresses monopolization when message interleaving is unavailable. Warp's bulk-transfer workload favors fewer messages, but compatibility still constrains the final size.
The production sender reads the source in 4 MiB blocks and splits each block into smaller sends. That reduces asynchronous file-read calls compared with reading once per 256 KiB message.
Neither constant bounds the browser's send queue. For that, I inspect channel.bufferedAmount, which counts application bytes queued for transmission. Warp uses an 8 MiB high-water mark and a 1 MiB low-water mark. Before sending a chunk, I include its length in the high-water comparison.
Without that check, a fast disk can outpace the network. The browser queue grows until send() throws or memory pressure disrupts the tab. An arbitrary multi-gigabyte source should not require a multi-gigabyte JavaScript buffer.
A TypeScript sender loop for a React 19 app
The following example isolates the sender mechanism. It is explanatory code, not a copy of Warp's full engine: the production implementation also handles offers, cancellation, resume, and other protocol features. Call it from a React event handler after receiver acceptance and channel negotiation. Keep the connection object outside render state.
const TARGET_CHUNK = 256 * 1024;
const READ_BLOCK = 4 * 1024 * 1024;
const HIGH_WATER = 8 * 1024 * 1024;
const LOW_WATER = 1024 * 1024;
function waitForDrain(
channel: RTCDataChannel,
signal: AbortSignal,
): Promise<void> {
return new Promise((resolve, reject) => {
const cleanup = () => {
channel.removeEventListener("bufferedamountlow", check);
channel.removeEventListener("close", closed);
channel.removeEventListener("error", closed);
signal.removeEventListener("abort", aborted);
};
const closed = () => {
cleanup();
reject(new Error("DataChannel closed while draining"));
};
const aborted = () => {
cleanup();
reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
};
const check = () => {
if (signal.aborted) return aborted();
if (channel.readyState !== "open") return closed();
if (channel.bufferedAmount <= LOW_WATER) {
cleanup();
resolve();
}
};
channel.addEventListener("bufferedamountlow", check);
channel.addEventListener("close", closed);
channel.addEventListener("error", closed);
signal.addEventListener("abort", aborted, { once: true });
check();
});
}
export async function sendFileBytes(
file: File,
pc: RTCPeerConnection,
channel: RTCDataChannel,
signal: AbortSignal,
onQueued: (bytes: number) => void,
): Promise<void> {
if (channel.readyState !== "open" || !pc.sctp) {
throw new Error("Negotiate an open SCTP channel first");
}
// A negotiated zero means no advertised message-size limit.
const negotiated = pc.sctp.maxMessageSize;
const chunkSize = negotiated === 0
? TARGET_CHUNK
: Math.min(TARGET_CHUNK, negotiated);
if (chunkSize < 1) throw new Error("Invalid SCTP message limit");
channel.bufferedAmountLowThreshold = LOW_WATER;
let queued = 0;
for (let offset = 0; offset < file.size; offset += READ_BLOCK) {
signal.throwIfAborted();
const block = await file.slice(offset, offset + READ_BLOCK).arrayBuffer();
for (let start = 0; start < block.byteLength; start += chunkSize) {
const chunk = new Uint8Array(
block, start, Math.min(chunkSize, block.byteLength - start),
);
while (channel.bufferedAmount + chunk.byteLength > HIGH_WATER) {
await waitForDrain(channel, signal);
}
signal.throwIfAborted();
if (channel.readyState !== "open") {
throw new Error("DataChannel closed before send");
}
channel.send(chunk);
queued += chunk.byteLength;
}
onQueued(queued);
}
}
I register the event listeners before rechecking the buffer so a drain that happens near listener setup cannot strand the promise. I also reject on channel closure and abort, then remove all listeners. Waiting only for bufferedamountlow can leave a dead transfer suspended forever.
onQueued reports bytes handed to the channel, not bytes saved by the recipient. Finishing this loop does not prove receiver completion. The file protocol must finish its framing and the receiver must settle its writes before the UI claims success.
In React, I update progress once per read block rather than once per message. Keeping networking in TypeScript modules also lets me change queue policy without coupling it to component rendering.
Receive into a sink, then account for the write
A receiver that collects all chunks in an array and constructs one giant Blob can still exhaust memory even when the sender respects its own queue limit. I select a sink before accepting the file.
Warp's current browser support matrix describes three large-file paths. A batch reaches the large-file threshold when its total size or one file reaches 256 MiB.
| Receiver capability | Large-file path |
|---|---|
| Matching native save picker | Stream to the user-selected file or folder |
| No selected native target, OPFS available | Stage in an origin-private file |
| OPFS unavailable | Stage Blob records in IndexedDB, subject to guards |
For smaller transfers, Warp uses the in-memory tray. For origin storage, I check the browser's quota estimate before accepting. OPFS stores bytes on the receiver's device; it does not give the sender or my server a cloud copy.
Receiver backpressure has another boundary
The Streams API gives an application a way to await sink writes and propagate pressure through a pipeline. A DataChannel's message event does not provide the same interface as a ReadableStream: an async event listener does not stop later messages from arriving.
The diagram separates transport flow control from application write ordering. It does not claim that awaiting an OPFS write alone pauses a remote sender.
flowchart TD
A[Sender file blocks] --> B[Check bufferedAmount plus next chunk]
B --> C[RTCDataChannel over SCTP and DTLS]
C --> D[Receiver message events]
D --> E[Ordered receive sink queue]
E --> F[Transfer ArrayBuffer to OPFS worker]
F --> G[Write through sync access handle]
G --> H[Acknowledge completed write]
H --> I[Advance live-session bytesWritten]
G --> J[Flush every 16 MiB and on close]
J --> K[File-backed completed result]
C -. SCTP transport flow control .-> B
H -. Release current sink operation .-> E
Warp serializes sink operations and waits for worker acknowledgments. The OPFS worker receives an ArrayBuffer through a transfer list, writes through a FileSystemSyncAccessHandle, and handles short writes by continuing at the correct offset. I keep this work off the React thread.
A serialized write chain preserves order; it does not establish a bounded application backlog. If the receiver's disk stays slower than the connection, queued message buffers can still accumulate. Strict end-to-end memory bounds would require receiver credits, acknowledged write windows, or an equivalent bounded ingress mechanism. Sender bufferedAmount alone cannot promise that bound.
I also distinguish write acknowledgment from crash durability. Warp's OPFS worker flushes in 16 MiB batches and on close, rather than flushing each 256 KiB message. That avoids paying the flush cost for each chunk. After an unclean reload, I must reconcile the saved ledger offset with the actual surviving file length before resuming.
Within the running session, I advance bytesWritten after a successful write acknowledgment. If opening, writing, or closing fails, I poison the sink and report the failure. At file completion, I wait for pending writes and require the received byte count to equal the declared file size. Disk-full errors must not produce a completed transfer badge.
Encryption still has a trust boundary
DTLS protects the direct channel against network observers. In the intended deployment, the signaling service relays connection metadata and stays outside the payload path.
That leaves metadata exposure: the service can see connecting IP addresses, room codes, peer identifiers, timing, and SDP/ICE signaling. STUN operators also see the discovery requests. “No cloud file storage” does not mean anonymous communication.
I treat the short room code as a rendezvous token, not a durable password. Recipients should verify who joined and accept files they expect. Transport encryption also cannot protect a browser after someone compromises its JavaScript, or authenticate a human identity without another verification mechanism. A malicious signaling operator has a different threat model from a passive network observer.
The repository's threat model documents these boundaries. Keeping them visible matters more than attaching an unqualified privacy claim to the landing page.
Running Warp and reading the engine
For local development, start the frontend and signaling service in separate terminals:
pnpm install
pnpm dev:server
pnpm dev
The documented development ports are 8787 for signaling and 5173 for the web app. Configure the frontend's VITE_SIGNALING_URL for your signaling deployment; Vite embeds it at build time, so changing the deployed URL requires rebuilding the frontend.
I recommend reading peer.ts alongside opfsStage.ts. The sender has to respect a negotiated message limit and a local queue ceiling. The receiver has to preserve write order, survive storage failures, and distinguish acknowledged bytes from crash-persistent bytes. Those constraints remain whether the UI shows a single progress bar or a multi-device transfer tray.
Try Warp, or inspect the source on GitHub. For this design, the important trade-off is concrete: both peers stay online, direct ICE traversal must succeed, and the receiver needs enough local storage. In return, I do not operate a file warehouse or a relay carrying users' archives.


Top comments (0)