Skip to main content

Overview

MeetMates implements real-time video chat using WebRTC (Web Real-Time Communication) for peer-to-peer connections. The system uses Socket.io for signaling and supports dynamic video/audio toggling, connection recovery, and graceful fallback to text-only chat.

Architecture

Client A                    Server (Socket.io)                Client B
   |                               |                              |
   |--- ready-to-connect --------->|                              |
   |                               |<------- ready-to-connect ----|
   |                               |                              |
   |<--- create-offer -------------|                              |
   |                               |                              |
   |--- webrtc-offer ------------->|                              |
   |                               |------- webrtc-offer -------->|
   |                               |                              |
   |                               |<------ webrtc-answer --------|
   |<--- webrtc-answer ------------|                              |
   |                               |                              |
   |--- ice-candidate ------------>|                              |
   |                               |------ ice-candidate -------->|
   |                               |                              |
   |<==========  Direct P2P Connection Established  =============>|

WebRTC Signaling (Server)

The server acts as a signaling intermediary to establish peer connections. Located in server.js:224-267:

Socket Events

// Track users ready for WebRTC
let rtcReadyUsers = new Set();

socket.on("ready-to-connect", () => {
  rtcReadyUsers.add(socket.id);

  if (chatPairs[socket.id]) {
    const partnerId = chatPairs[socket.id].partner;

    // Both users ready - initiate offer from lower socket ID
    if (rtcReadyUsers.has(partnerId)) {
      if (socket.id < partnerId) {
        socket.emit("create-offer");
      }
    }
  }
});
The server uses deterministic offer creation (lower socket.id creates the offer) to prevent offer/answer collisions.

Client Implementation

The WebRTC client is implemented in VideoChat.jsx with comprehensive connection management.

ICE Configuration

VideoChat.jsx:27-33
const iceServers = {
  iceServers: [
    { urls: "stun:stun.l.google.com:19302" },
    { urls: "stun:stun1.l.google.com:19302" },
    { urls: "stun:stun2.l.google.com:19302" },
  ],
};
STUN (Session Traversal Utilities for NAT) servers help establish connections through NAT/firewalls by discovering public IP addresses.

Peer Connection Setup

const createPeerConnection = () => {
  // Close existing connection
  if (peerConnectionRef.current) {
    peerConnectionRef.current.close();
  }

  const pc = new RTCPeerConnection(iceServers);
  peerConnectionRef.current = pc;

  // Add local media tracks
  if (localStreamRef.current) {
    localStreamRef.current.getTracks().forEach((track) => {
      pc.addTrack(track, localStreamRef.current);
    });
  }

  // Handle remote tracks
  pc.ontrack = (event) => {
    console.log("Remote track received", event.streams[0]);
    if (remoteVideoRef.current && event.streams[0]) {
      remoteVideoRef.current.srcObject = event.streams[0];
      setIsConnected(true);
    }
  };

  // ... connection state handlers
};

Connection State Management

VideoChat.jsx:66-92
pc.onconnectionstatechange = () => {
  const state = pc.connectionState;
  console.log("Connection state:", state);
  setConnectionState(state);

  if (state === "connected") {
    setIsConnected(true);
  } else if (state === "failed" || state === "disconnected" || state === "closed") {
    setIsConnected(false);

    // Retry connection (max 3 attempts)
    if (state === "failed" && connectionAttempts < 3) {
      console.log(`Retrying... (Attempt ${connectionAttempts + 1})`);
      setTimeout(() => {
        setConnectionAttempts((prev) => prev + 1);
        createPeerConnection();
        socket.emit("ready-to-connect");
      }, 1000);
    }
  }
};
  • new: Connection object created, no networking yet
  • connecting: ICE agent is gathering candidates or establishing connection
  • connected: Connection successfully established
  • disconnected: At least one transport has failed (may recover)
  • failed: One or more transports have failed permanently
  • closed: Connection has been closed

Offer/Answer Exchange

const handleOffer = async (data) => {
  const pc = peerConnectionRef.current;
  if (!pc) return;

  // Prevent duplicate processing
  const offerId = JSON.stringify(data.offer);
  if (processedSignalsRef.current.has(offerId)) {
    console.log("Duplicate offer - ignoring");
    return;
  }
  processedSignalsRef.current.add(offerId);

  // Handle signaling state conflicts
  if (pc.signalingState === "have-local-offer") {
    console.log("Rolling back local offer");
    await pc.setLocalDescription({ type: "rollback" });
  }

  // Set remote offer
  await pc.setRemoteDescription(new RTCSessionDescription(data.offer));

  // Create and send answer
  const answer = await pc.createAnswer();
  await pc.setLocalDescription(answer);
  socket.emit("webrtc-answer", { answer });
};

ICE Candidate Exchange

VideoChat.jsx:94-100
// Local ICE candidate generation
pc.onicecandidate = (event) => {
  if (event.candidate) {
    socket.emit("ice-candidate", {
      candidate: event.candidate,
    });
  }
};

// Remote ICE candidate handling
const handleIceCandidate = async (data) => {
  if (data.candidate && peerConnectionRef.current) {
    await peerConnectionRef.current.addIceCandidate(
      new RTCIceCandidate(data.candidate)
    );
  }
};
ICE candidates must be added after remote description is set. Queue candidates if received early.

Media Controls

The client provides real-time media track controls:
const toggleMute = () => {
  if (localStreamRef.current) {
    const audioTracks = localStreamRef.current.getAudioTracks();
    audioTracks.forEach((track) => {
      track.enabled = !track.enabled;
    });
    setIsMuted(!isMuted);
  }
};
Disabling tracks with track.enabled = false preserves the stream but stops transmission, reducing bandwidth while maintaining the connection.

Connection Recovery

The implementation includes automatic reconnection logic:
VideoChat.jsx:81-90
if (state === "failed" && connectionAttempts < 3) {
  console.log(`Connection failed. Retrying... (Attempt ${connectionAttempts + 1})`);
  setTimeout(() => {
    setConnectionAttempts((prev) => prev + 1);
    createPeerConnection();
    socket.emit("ready-to-connect");
  }, 1000);
}
Manual reconnection is also available:
VideoChat.jsx:331-335
const reconnect = () => {
  setConnectionAttempts(0);
  createPeerConnection();
  socket.emit("ready-to-connect");
};

Cleanup and Resource Management

VideoChat.jsx:290-307
return () => {
  isComponentMounted = false;

  // Remove socket listeners
  socket.off("webrtc-offer");
  socket.off("webrtc-answer");
  socket.off("ice-candidate");
  socket.off("create-offer");
  socket.off("partnerLeft");

  // Stop all media tracks
  if (localStreamRef.current) {
    localStreamRef.current.getTracks().forEach((track) => track.stop());
  }

  // Close peer connection
  if (peerConnectionRef.current) {
    peerConnectionRef.current.close();
  }
};
Proper cleanup is critical to prevent memory leaks and camera/microphone permission issues.

Integration with Chat Flow

Video chat integrates seamlessly with the matching system:
App.jsx:74-87
socket.on("chatStart", (data) => {
  const { withVideo } = data || {};
  setCurrentScreen("chat");
  setIsVideoChat(withVideo);
  console.log("✅ Chat started, Video:", withVideo);
  
  setMessages([{
    type: "system",
    text: "Connected to chat partner"
  }]);
  
  // Signal readiness for WebRTC
  socket.emit("ready-to-connect");
});

Key Features

Peer-to-Peer

Direct browser-to-browser connections reduce latency and server costs

Auto Recovery

Automatic reconnection with up to 3 retry attempts

Dynamic Controls

Real-time mute/unmute and camera toggle without reconnection

NAT Traversal

STUN server integration for connections across networks

Technical Considerations

Using socket.id < partnerId ensures only one peer creates the offer, preventing offer/answer collisions when both users signal readiness simultaneously.
Socket.io can deliver duplicate messages. Tracking processed offers/answers prevents state machine errors in RTCPeerConnection.
If a peer receives an offer while in have-local-offer state, it must rollback to stable before processing the remote offer, following the WebRTC specification.
Currently using only STUN. For production, add TURN servers for users behind restrictive firewalls:
{ urls: "turn:turn.example.com", username: "user", credential: "pass" }

Build docs developers (and LLMs) love