-
Notifications
You must be signed in to change notification settings - Fork 1
WebRTC Implementation
This page explains how LinkUp implements WebRTC for peer-to-peer video communication.
WebRTC (Web Real-Time Communication) is a technology that enables:
- Audio and video communication
- Data transfer between browsers
- Direct peer-to-peer connections (without going through a server)
In LinkUp, video and audio streams flow directly between participants, not through the server. This provides:
- ✅ Lower latency
- ✅ Better quality
- ✅ Reduced server bandwidth costs
- ✅ Better privacy (no server sees your video)
LinkUp uses a mesh topology where every participant connects directly to every other participant.
Participant A
/ | \
/ | \
/ | \
/ | \
Participant B | Participant C
\ | /
\ | /
\ | /
\ | /
Participant D
Pros:
- Simple implementation
- No central media server needed
- Low latency between peers
Cons:
- Bandwidth usage increases with participants (N × streams)
- Limited scalability (practical limit: ~10 participants)
- Quality depends on weakest peer's upload bandwidth
For larger meetings: Consider using an SFU (Selective Forwarding Unit) like Janus or mediasoup.
Participant A Server Participant B
│ │ │
│ 1. Connect WebSocket │ │
├─────────────────────────▶│ │
│ │ 2. Connect WebSocket │
│ │◀─────────────────────────┤
│ │ │
│ │ 3. Notify A of B │
│◀─────────────────────────┤ │
│ │ │
│ 4. Create Offer │ │
│ (SDP + ICE) │ │
├─────────────────────────▶│ 5. Forward Offer │
│ ├─────────────────────────▶│
│ │ │
│ │ 6. Create Answer │
│ │ (SDP + ICE) │
│ 7. Forward Answer │◀─────────────────────────┤
│◀─────────────────────────┤ │
│ │ │
│ 8. Exchange ICE Candidates │
│◀────────────────────────────────────────────────────▶
│ │ │
│ 9. P2P Connection Established ✅ │
│═════════════════════════════════════════════════════▶
│ Video/Audio flows directly │
The server facilitates connection setup through WebSocket:
Offer (from Peer A):
{
"type": "offer",
"target": "client-b",
"data": {
"type": "offer",
"sdp": "v=0\r\no=- 123456789 2 IN IP4 127.0.0.1\r\n..."
}
}Answer (from Peer B):
{
"type": "answer",
"target": "client-a",
"data": {
"type": "answer",
"sdp": "v=0\r\no=- 987654321 2 IN IP4 127.0.0.1\r\n..."
}
}ICE (Interactive Connectivity Establishment) finds the best path between peers:
{
"type": "ice-candidate",
"target": "client-b",
"data": {
"candidate": "candidate:1 1 udp 2130706431 192.168.1.100 54321 typ host",
"sdpMid": "0",
"sdpMLineIndex": 0
}
}Once negotiation completes:
- Media streams flow directly between peers
- Server is no longer involved in media transport
- Only signaling still goes through server
const RTC_CONFIG: RTCConfiguration = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
],
};ICE Servers:
- STUN servers: Help discover public IP addresses for NAT traversal
- TURN servers: Relay traffic when direct P2P fails (not configured by default)
For Production: Add TURN servers for better connectivity:
{
urls: 'turn:your-turn-server.com:3478',
username: 'user',
credential: 'pass'
}Frontend (useWebRTC.ts):
const createPeerConnection = (clientId: string): RTCPeerConnection => {
const pc = new RTCPeerConnection(RTC_CONFIG);
// Add local tracks to connection
localStream?.getTracks().forEach((track) => {
pc.addTrack(track, localStream);
});
// Handle incoming remote tracks
pc.ontrack = (event) => {
const remoteStream = event.streams[0];
setRemoteStreams(new Map(remoteStreams.set(clientId, remoteStream)));
};
// Handle ICE candidates
pc.onicecandidate = (event) => {
if (event.candidate) {
sendMessage({
type: 'ice-candidate',
target: clientId,
data: event.candidate,
});
}
};
// Monitor connection state
pc.onconnectionstatechange = () => {
console.log(`Connection state: ${pc.connectionState}`);
if (pc.connectionState === 'failed') {
// Handle reconnection
}
};
peerConnections.current.set(clientId, pc);
return pc;
};Creating an Offer:
const createOffer = async (clientId: string) => {
const pc = peerConnections.current.get(clientId) || createPeerConnection(clientId);
const offer = await pc.createOffer({
offerToReceiveAudio: true,
offerToReceiveVideo: true,
});
await pc.setLocalDescription(offer);
sendMessage({
type: 'offer',
target: clientId,
data: offer,
});
return offer;
};Handling an Offer:
const handleOffer = async (clientId: string, offer: RTCSessionDescription) => {
const pc = peerConnections.current.get(clientId) || createPeerConnection(clientId);
await pc.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
sendMessage({
type: 'answer',
target: clientId,
data: answer,
});
return answer;
};const handleIceCandidate = async (
clientId: string,
candidate: RTCIceCandidate
) => {
const pc = peerConnections.current.get(clientId);
if (pc && pc.remoteDescription) {
await pc.addIceCandidate(new RTCIceCandidate(candidate));
} else {
// Queue candidates if remote description not set yet
candidateQueue.current.push({ clientId, candidate });
}
};Initialize local stream:
const initializeLocalStream = async (): Promise<boolean> => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
frameRate: { ideal: 30 },
},
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
});
setLocalStream(stream);
return true;
} catch (error) {
console.error('Failed to get local stream:', error);
return false;
}
};Toggle audio:
const toggleAudio = (enabled: boolean) => {
localStream?.getAudioTracks().forEach((track) => {
track.enabled = enabled;
});
sendMessage({
type: 'audio-toggle',
enabled,
});
};Toggle video:
const toggleVideo = (enabled: boolean) => {
localStream?.getVideoTracks().forEach((track) => {
track.enabled = enabled;
});
sendMessage({
type: 'video-toggle',
enabled,
});
};Receiving remote streams:
pc.ontrack = (event: RTCTrackEvent) => {
const [remoteStream] = event.streams;
// Store stream in map
remoteStreams.set(clientId, remoteStream);
// Trigger re-render to display video
setRemoteStreams(new Map(remoteStreams));
};Displaying video:
<video
ref={(el) => {
if (el && stream) {
el.srcObject = stream;
}
}}
autoPlay
playsInline
muted={isLocalStream}
/>const startScreenShare = async (): Promise<boolean> => {
try {
// Get screen stream
const screenStream = await navigator.mediaDevices.getDisplayMedia({
video: {
cursor: 'always',
displaySurface: 'monitor',
},
audio: false,
});
const screenTrack = screenStream.getVideoTracks()[0];
// Replace video track in all peer connections
peerConnections.current.forEach((pc) => {
const sender = pc.getSenders().find((s) => s.track?.kind === 'video');
if (sender) {
sender.replaceTrack(screenTrack);
}
});
// Handle screen share stop (user clicks browser's "Stop sharing" button)
screenTrack.onended = () => {
stopScreenShare();
};
setIsScreenSharing(true);
sendMessage({
type: 'screen-share-start',
});
return true;
} catch (error) {
console.error('Failed to start screen share:', error);
return false;
}
};const stopScreenShare = async () => {
if (!localStream) return;
const videoTrack = localStream.getVideoTracks()[0];
// Replace screen track with camera track in all connections
peerConnections.current.forEach((pc) => {
const sender = pc.getSenders().find((s) => s.track?.kind === 'video');
if (sender && videoTrack) {
sender.replaceTrack(videoTrack);
}
});
setIsScreenSharing(false);
sendMessage({
type: 'screen-share-stop',
});
};pc.onconnectionstatechange = () => {
switch (pc.connectionState) {
case 'connected':
console.log('Peer connected');
break;
case 'disconnected':
console.log('Peer disconnected');
// Attempt reconnection
break;
case 'failed':
console.log('Connection failed');
// Remove peer and notify user
removePeer(clientId);
break;
case 'closed':
console.log('Connection closed');
break;
}
};pc.oniceconnectionstatechange = () => {
switch (pc.iceConnectionState) {
case 'checking':
console.log('Checking ICE candidates');
break;
case 'connected':
case 'completed':
console.log('ICE connection established');
break;
case 'failed':
console.log('ICE connection failed');
// Restart ICE
pc.restartIce();
break;
case 'disconnected':
console.log('ICE disconnected');
break;
}
};const handleReconnection = async (clientId: string) => {
const pc = peerConnections.current.get(clientId);
if (!pc) return;
// Restart ICE gathering
pc.restartIce();
// Create new offer
const offer = await createOffer(clientId);
// Send through signaling server
sendMessage({
type: 'offer',
target: clientId,
data: offer,
});
};const removePeer = (clientId: string) => {
const pc = peerConnections.current.get(clientId);
if (pc) {
// Close peer connection
pc.close();
// Remove from map
peerConnections.current.delete(clientId);
// Remove remote stream
const newStreams = new Map(remoteStreams);
newStreams.delete(clientId);
setRemoteStreams(newStreams);
}
};const cleanup = () => {
// Close all peer connections
peerConnections.current.forEach((pc) => {
pc.close();
});
peerConnections.current.clear();
// Stop local stream tracks
localStream?.getTracks().forEach((track) => {
track.stop();
});
// Clear state
setLocalStream(null);
setRemoteStreams(new Map());
setIsScreenSharing(false);
};1. Connection Stuck in "Checking" State
- Cause: ICE candidates not reaching peer
- Solution: Check firewall, ensure STUN servers are reachable
2. Video Not Showing
- Cause: Stream not attached to video element
-
Solution: Verify
srcObjectis set correctly
3. One-Way Audio/Video
- Cause: Asymmetric NAT or firewall
- Solution: Add TURN server for relay
4. Poor Quality or Lag
- Cause: Network bandwidth insufficient
- Solution: Reduce video resolution/framerate
5. Connections Fail After Network Change
- Cause: ICE connection disrupted
- Solution: Implement ICE restart on network change
Enable verbose WebRTC logging:
// Log all peer connection events
pc.addEventListener('icecandidate', (e) => console.log('ICE:', e.candidate));
pc.addEventListener('connectionstatechange', () => console.log('State:', pc.connectionState));
pc.addEventListener('iceconnectionstatechange', () => console.log('ICE State:', pc.iceConnectionState));
pc.addEventListener('signalingstatechange', () => console.log('Signaling:', pc.signalingState));
pc.addEventListener('track', (e) => console.log('Track:', e.track));Chrome:
- Visit
chrome://webrtc-internalsfor detailed connection stats
Firefox:
- Visit
about:webrtcfor connection information
// Adjust video quality based on bandwidth
const applyBandwidthRestriction = (pc: RTCPeerConnection, maxKbps: number) => {
const sender = pc.getSenders().find((s) => s.track?.kind === 'video');
if (sender) {
const parameters = sender.getParameters();
if (!parameters.encodings) {
parameters.encodings = [{}];
}
parameters.encodings[0].maxBitrate = maxKbps * 1000;
sender.setParameters(parameters);
}
};// Monitor connection quality and adjust
const monitorQuality = (pc: RTCPeerConnection) => {
setInterval(async () => {
const stats = await pc.getStats();
stats.forEach((report) => {
if (report.type === 'inbound-rtp' && report.kind === 'video') {
const packetsLost = report.packetsLost;
const packetsReceived = report.packetsReceived;
const lossRate = packetsLost / (packetsLost + packetsReceived);
if (lossRate > 0.05) {
// High packet loss, reduce quality
applyBandwidthRestriction(pc, 500);
}
}
});
}, 5000);
};Next: See Deployment Guide for production deployment instructions.