LOG//ENTRY
Building a Random Video Chat App with WebRTC and Socket.IO
How I built Incogni.tv, an Omegle-style video chat: a Socket.IO matching server, WebRTC offers and answers, queued ICE candidates, STUN vs TURN, and deployment.
By Siva Sundar · · 4 min read
Incogni.tv pairs two strangers for a one-to-one video chat. The video itself is the easy part: browsers can stream camera and microphone to each other directly with WebRTC. The hard parts are helping two browsers find each other, and coping when the network won't let them connect directly. Here's how the pieces fit together.
The architecture in one paragraph
A React client (built with Vite) captures media and runs the WebRTC connection. A Node.js server with Express and Socket.IO does matchmaking and signalling: it queues people, pairs them, and relays connection messages between the two browsers. Once connected, video and audio flow peer-to-peer and never touch the server.
Matching people with a queue
The server keeps a waiting queue of socket IDs and a map of active sessions. Whenever two people are waiting, it pairs them, puts them in a room and tells each one who their partner is:
function tryMatch() {
while (waitingQueue.length >= 2) {
const firstId = waitingQueue.shift();
const secondId = waitingQueue.shift();
// …skip sockets that disconnected while waiting
const roomId = `room:${firstId}:${secondId}`;
sessions.set(firstId, { partnerId: secondId, roomId });
sessions.set(secondId, { partnerId: firstId, roomId });
firstSocket.emit("matched", { roomId, partnerId: secondId, createOffer: true });
secondSocket.emit("matched", { roomId, partnerId: firstId, createOffer: false });
}
}Note the createOffer flag. In WebRTC one side creates an offer and the other answers. If both sides offer at once you get “glare”, and the connection stalls. Letting the server decide removes the race entirely.
Signalling: offer, answer and ICE candidates
WebRTC doesn't specify how peers exchange setup messages; that's your job. Incogni.tv uses the existing Socket.IO connection. The server simply forwards offer, answer and ice_candidate events to the caller's partner:
- The offerer calls
createOffer(), sets it as its local description, and sends it. - The answerer sets it as the remote description, creates an answer, and sends that back.
- Both sides trickle ICE candidates (possible network paths) to each other as they're discovered.
- The browsers test the candidates and pick a working path. Media starts flowing.
The bug everyone hits: candidates arriving too early
ICE candidates can arrive before the remote description has been set, and addIceCandidate fails if there's no remote description yet. The symptom is maddening: both users are “connected” but there's no video. The fix is to buffer early candidates and flush them once the description lands:
async addIceCandidate(candidate) {
if (!candidate || !this.pc) return;
if (!this.pc.remoteDescription) {
this.pendingCandidates.push(candidate);
return;
}
await this.pc.addIceCandidate(new RTCIceCandidate(candidate));
}
async handleAnswer(answer) {
await this.pc.setRemoteDescription(new RTCSessionDescription(answer));
await this.flushCandidates();
}STUN vs TURN, and why you need TURN in production
Most devices sit behind a router doing NAT, so they don't know their own public address. A STUN server tells them. Incogni.tv uses Google's public STUN servers by default, and that's enough on many home networks.
But on strict NATs, corporate firewalls and a lot of mobile networks, a direct path simply doesn't exist. The two users match, signalling succeeds, and media never arrives. The only fix is a TURN server, which relays the media. It costs bandwidth, so the client adds one only when it's configured:
const servers = [{ urls: "stun:stun.l.google.com:19302" } /* + 4 more */];
if (import.meta.env.VITE_TURN_URL) {
servers.push({
urls: import.meta.env.VITE_TURN_URL,
username: import.meta.env.VITE_TURN_USERNAME,
credential: import.meta.env.VITE_TURN_CREDENTIAL,
});
}Skip, disconnect and clean-up
Real users skip, close tabs and lose signal mid-call. The server ends a session from either side, removes both entries from the session map, and tells the remaining partner partner_disconnected so their UI can rematch. On the client, close() detaches every event handler before closing the peer connection, so a stale connection can't fire callbacks into the next chat.
Deployment: static client, stateful server
The React client is static and deploys to Vercel. The Socket.IO server can't: serverless functions don't keep long-lived WebSocket connections open. It runs on Render as a normal web service with a /health endpoint. CORS is locked to the client's domain through an environment variable.
What a public launch would still need
Today the app has an 18+ confirmation, a camera and microphone gate, skip, report (stored in memory) and a 500-character cap on chat messages. A real launch needs persistent moderation storage, rate limiting, bans and automated detection of abuse. Random video chat is a safety problem first and a technical problem second.
Takeaways
- Let the server decide who sends the offer, to avoid glare.
- Buffer ICE candidates until the remote description is set.
- STUN works in testing; TURN is what makes it work for everyone.
- Host the signalling server somewhere that supports long-lived connections.
- Design for disconnects. They're the normal case, not the edge case.