Skip to content

WebRTC Implementation

Martian edited this page Nov 9, 2025 · 1 revision

WebRTC Implementation

This page explains how LinkUp implements WebRTC for peer-to-peer video communication.

What is WebRTC?

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)

Architecture: Mesh Topology

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.


WebRTC Connection Flow

Step-by-Step Process

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                  │

1. Signaling Phase

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..."
  }
}

2. ICE Candidate Exchange

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
  }
}

3. P2P Connection Established

Once negotiation completes:

  • Media streams flow directly between peers
  • Server is no longer involved in media transport
  • Only signaling still goes through server

Implementation Details

RTCPeerConnection Configuration

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'
}

Creating Peer Connections

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;
};

SDP Offer/Answer Exchange

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;
};

ICE Candidate Handling

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 });
  }
};

Media Stream Management

Local Stream (Camera & Microphone)

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,
  });
};

Remote Streams

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}
/>

Screen Sharing

Starting Screen Share

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;
  }
};

Stopping Screen Share

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',
  });
};

Connection State Management

Monitoring Connection Health

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;
  }
};

ICE Connection States

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;
  }
};

Handling Reconnection

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,
  });
};

Cleanup and Resource Management

Removing a Peer

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);
  }
};

Complete Cleanup

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);
};

Troubleshooting WebRTC Issues

Common Problems

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 srcObject is 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

Debug Logging

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));

Browser Internals

Chrome:

  • Visit chrome://webrtc-internals for detailed connection stats

Firefox:

  • Visit about:webrtc for connection information

Performance Optimization

Bandwidth Management

// 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);
  }
};

Adaptive Quality

// 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.