Ever tried to send a video from your phone to your laptop? The usual options are pretty terrible. You can upload it to WeChat or email, but then it gets compressed, sits on someone else's server, and takes forever if the file is large. Cloud storage works, but you need accounts, logins, and patience.
We wanted something simpler: open a web page, see your devices, send a file directly. No installs, no signups, no server touching your data. That's exactly what we built with Monkeymore Drop, and the entire thing runs inside your browser.
In this post, I'll walk you through how we pulled it off — the WebRTC tricks, the file chunking strategy, and why every file gets its own dedicated data channel.
Why Keep Everything in the Browser?
Before diving into the code, let's talk about why we insisted on a pure frontend implementation.
Your files never leave your network. When you use a traditional file sharing service, your data gets uploaded to a server somewhere, then downloaded again by the recipient. With our approach, the file travels directly from your browser to the other browser. The only thing a server does is help the two browsers find each other — like a digital introduction.
No installation required. It works on anything with a modern browser: iPhone, Android, Windows, Mac, Linux. Just open the URL and you're ready.
No compression or size limits. Since the data goes peer-to-peer, you're not fighting against upload quotas or watching your video get re-encoded into potato quality.
It works offline on LAN. As long as both devices are on the same WiFi, the transfer happens over your local network even if the internet is down.
The Big Picture
At a high level, the system has three moving parts:
- A signaling server (WebSocket) — This just tells devices about each other. It never sees your files.
- WebRTC peer connections — These create direct encrypted tunnels between browsers.
- File transfer channels — Each file gets its own WebRTC data channel, so multiple files can fly in parallel.
Here's how the pieces fit together:
How the Peers Actually Connect
The trickiest part of WebRTC is that two browsers can't just call each other directly — they need to exchange "session descriptions" (SDP offers and answers) and ICE candidates (network addresses) first. That's where the WebSocket server comes in.
When you open the page, the browser connects to the signaling server and sends a join message:
// From src/useroom.ts
ws.current = new WebSocket("wss://dropshare.monkeymore.app/ws?name=" + name);
ws.current.onopen = () => {
ws.current?.send(JSON.stringify({ type: "open", displayName: name }));
};
The server replies with your randomly generated name and a list of other peers in the same room. When a new peer shows up, you get a peer-joined event, and that's when the real magic starts.
The peer who receives the peer-joined message becomes the initiator — it creates a WebRTC offer:
// From src/webrtcpeer.ts
const peer = new SimplePeer({
initiator: true,
config: {
iceServers: [
{ urls: "stun:stun1.l.google.com:19302" },
{ urls: "stun:global.stun.twilio.com:3478" },
{
urls: "turn:openrelay.metered.ca:80",
username: "openrelayproject",
credential: "openrelayproject",
},
],
iceTransportPolicy: "all",
},
});
Notice those stun and turn servers. STUN helps a browser figure out its own public IP address behind a router. TURN is the fallback relay for when direct connection isn't possible (like when both sides are behind strict corporate firewalls). Without these, two devices on different networks would just stare at each other confused.
When the initiator's SimplePeer fires a signal event, it packages that up and sends it through the WebSocket to the other peer:
// From src/webrtcpeer.ts
peer.on("signal", (data) => {
const message = JSON.stringify({
type: "signal",
to: peerId.id,
roomType: peerId.roomType,
roomId: peerId.roomId,
data,
});
ws.send(message);
});
The other side receives this signal, feeds it to its own SimplePeer instance, and generates an answer. That answer gets sent back the same way. After a couple of ICE candidate exchanges, the browsers finally shake hands and the direct data channel opens.
Here's the full connection dance:
The Data Structures That Make It Work
We defined a few core types to keep the chaos organized. A peer isn't just an ID — it carries device info, room context, and methods to actually send things:
// From types/peer.ts
export type Peer = {
id: string;
ip: string;
roomType: string;
roomId: string;
name: string;
ua: {
type: string;
os: string;
browser: string;
deviceName: string;
displayName: string;
};
};
export type MonkeyDropPeer = Peer & {
sendFiles: (files: FileWithId[]) => void;
sendMessage: (messageId: string, message: string) => void;
close: () => void;
};
And for tracking file transfers, we have a FileState object that lives in the React state:
// From types/peer.ts
export type FileState = {
id: string;
name: string;
size: number;
speed: number;
percent: number;
status: "NOT START" | "TRANSFERING" | "DONE";
src?: string;
file?: File;
// For large files (> 5MB)
totalChunks?: number;
completedChunks?: number;
chunkPercents?: number[];
chunkBlobs?: Blob[];
};
The Messsage type wraps both text messages and file transfers so the chat UI can treat them uniformly:
// From types/peer.ts
export type Messsage = {
type: "file" | "message";
id: string;
sendType: "send" | "receive";
peerId: string;
content: FileState | string;
};
Every File Gets Its Own Highway
Here's a design decision that might seem wasteful at first: instead of cramming all files through the single control data channel, we spin up a separate WebRTC peer connection for every single file.
Why? Two reasons:
- Parallel transfers. If you drop five photos at once, they all transfer simultaneously instead of waiting in line.
- Clean streaming. The control channel handles JSON messages and signal forwarding. Mixing binary file chunks with control messages would turn the stream into a mess.
The file channel's signals don't go through the WebSocket server — they get forwarded through the already-established control peer:
// From src/filetransfer.ts
peer.on("signal", (signal) => {
// File channel signals go through the control peer, not WebSocket
controlPeer.send(
JSON.stringify({
fileID: fileId,
start: 0,
signal,
})
);
});
And on the receiving side, the control peer listens for these forwarded signals and feeds them to the right file channel:
// From src/filetransfer.ts
controlPeer.addListener("data", (data) => {
const dataJSON = JSON.parse(data);
if (dataJSON.signal && dataJSON.fileID == fileId) {
peer.signal(dataJSON.signal);
}
});
Streaming Files with Backpressure
Once a file channel opens, we use Node.js-style streams to move the data without exploding memory. The sender side reads the file in chunks using filereader-stream, pipes it through a custom SendStream that adds protocol headers, and finally pipes it into the SimplePeer instance:
// From simple-peer-files/PeerFileSend.ts
const stream = read(this.file, {
offset: this.offset,
chunkSize: CHUNK_SIZE, // 64KB chunks
});
this.ss = new SendStream(this.file.size, this.offset);
stream.pipe(this.ss).pipe(this.peer);
The SendStream class extends Duplex and prepends a single-byte header to every chunk so the receiver knows what it's looking at:
// From simple-peer-files/PeerFileSend.ts
function pMsg(header: number, data: Uint8Array | null = null) {
let resp: Uint8Array;
if (data) {
resp = new Uint8Array(1 + data.length);
resp.set(data, 1);
} else {
resp = new Uint8Array(1);
}
resp[0] = header;
return resp;
}
The headers are defined in Meta.ts:
// From simple-peer-files/Meta.ts
export const ControlHeaders = {
FILE_START: 0,
FILE_CHUNK: 1,
FILE_CHUNK_ACK: 2,
FILE_END: 3,
TRANSFER_PAUSE: 4,
TRANSFER_RESUME: 5,
TRANSFER_CANCEL: 6,
};
On the receiving end, a ReceiveStream watches these headers, accumulates chunks, and emits a done event with a proper File object when everything arrives:
// From simple-peer-files/PeerFileReceive.ts
this.rs.on("chunk", (chunk) => {
this.fileData.push(chunk);
this.bytesReceived += chunk.byteLength;
if (this.bytesReceived === this.fileSize) {
this.sendPeer(ControlHeaders.FILE_END);
const file = new window.File(this.fileData, this.fileName!, {
type: this.fileType,
});
this.emit("done", file);
}
});
The Chunking Strategy for Large Files
A single WebRTC data channel has throughput limits, and sending a 2GB file through one pipe is asking for trouble. We split anything larger than 5MB into chunks, each getting its own data channel, with up to 8 chunks in flight at once:
// From src/webrtcpeer.ts
const CHUNK_THRESHOLD = 5 * 1024 * 1024; // 5MB
const CHUNK_SIZE = 5 * 1024 * 1024; // each chunk is 5MB
const MAX_CONCURRENT_CHUNKS = 8; // max 8 parallel channels
The sender maintains a queue for each large file:
// From src/webrtcpeer.ts
const sendChunkQueues = {
[fileId: string]: {
file: File;
totalChunks: number;
chunks: { index: number; offset: number; length: number; done: boolean; started: boolean }[];
};
};
When a chunk finishes, the scheduler grabs the next pending chunk and opens another channel:
// From src/webrtcpeer.ts
const startNextChunk = () => {
const activeCount = queue.chunks.filter((c) => c.started && !c.done).length;
if (activeCount >= MAX_CONCURRENT_CHUNKS) return;
const pending = queue.chunks.find((c) => !c.started && !c.done);
if (!pending) return;
pending.started = true;
const chunkId = `chunk:${f.id}:${pending.index}`;
const chunkBlob = queue.file.slice(pending.offset, pending.offset + pending.length);
// ... create channel and send
};
On the receiving side, useroom.ts collects chunks and merges them back together when everything lands:
// From src/useroom.ts
if (fs.completedChunks >= fs.totalChunks!) {
const mergedBlob = new Blob(fs.chunkBlobs, { type: file.type });
const mergedFile = new File([mergedBlob], fs.name, { type: file.type });
fs.file = mergedFile;
fs.percent = 100;
fs.status = "DONE";
fs.chunkBlobs = undefined; // free memory
}
Tracking Progress and Speed
Nobody likes a progress bar that lies. We track transfer speed by comparing bytes received over time:
// From src/filetransfer.ts
let lastStatus = { time: 0, bytesReceived: 0 };
pfr.on("progress", (percentage, bytesReceived) => {
const now = Date.now();
let speed;
if (lastStatus.time > 0) {
const elapsed = now - lastStatus.time;
speed = Math.round((bytesReceived - lastStatus.bytesReceived) / elapsed);
}
lastStatus = { time: now, bytesReceived };
ft(peerId, fileId, percentage, bytesReceived, speed);
});
For chunked files, useroom.ts computes an overall percentage by averaging individual chunk progress:
// From src/useroom.ts
if (!fs.chunkPercents) fs.chunkPercents = new Array(fs.totalChunks).fill(0);
fs.chunkPercents[chunkIndex] = progress;
fs.percent = Math.round(
fs.chunkPercents.reduce((a, b) => a + b, 0) / fs.totalChunks!
);
The UI then renders this in real-time using a chat-style interface built on @chatui/core, showing file cards with progress bars and live speed readouts.
The Complete File Transfer Flow
Here's what happens from the moment you hit "send" to when the file lands on the other side:
What About the Room?
You might be wondering how devices find each other in the first place. The server groups peers by room. By default, devices with the same public IP get dropped into the same room automatically — handy for your home WiFi. We also support public rooms with shareable codes, so you can send files to a friend across the city.
The useRoom hook manages all of this. It holds the WebSocket reference, maintains the peer list, and wires up all the file transfer callbacks:
// From src/useroom.ts
export const useRoom = (name: string, roomId: string) => {
const [peers, setPeers] = useState<MonkeyDropPeer[]>([]);
const [messages, setMessages] = useState<Messsage[]>([]);
const ws = useRef<WebSocket | null>(null);
// ...
};
When the component unmounts or the user refreshes, it politely tells the server to disconnect:
// From src/useroom.ts
window.addEventListener("beforeunload", close);
window.addEventListener("pagehide", close); // for iOS Safari
const close = () => {
if (ws.current) {
ws.current.send(JSON.stringify({ type: "disconnect" }));
ws.current.close();
}
};
Wrapping Up
Building a browser-based file transfer tool isn't magic — it's just WebRTC, a bit of stream plumbing, and some careful state management. The key insight is that browsers are perfectly capable of speaking to each other directly. The only thing they need help with is the introduction.
If you're tired of compressing videos for email or waiting for cloud uploads, give it a try. Open Monkeymore Drop on two devices, and you'll see them pop up in the device list instantly. Select a file, hit send, and watch it travel directly from one browser to the other. No accounts, no uploads, no waiting.



Top comments (0)