"So, you've learned all about WebRTC. Now what?"

Throughout this series, we've explored the various components and concepts that make up WebRTC. We've dissected protocols, examined architectures, and debugged common issues. Now it's time to put all that knowledge into practice by building something real: a complete video conferencing system.

I've built numerous video conferencing applications throughout my career, from simple one-to-one chat systems to complex multi-party platforms supporting thousands of concurrent users. Each project taught me valuable lessons about what works, what doesn't, and how to balance technical constraints with user experience.

In this article, I'll guide you through the process of building a practical video conferencing system using WebRTC. We'll cover architecture design, signaling implementation, media handling, user interface considerations, and deployment strategies. By the end, you'll have a roadmap for creating your own WebRTC-based communication platform.

Defining Our Video Conferencing System

Before writing any code, let's define what we're building:

Requirements:

  • Support for multi-party video calls (up to 8 participants)
  • Screen sharing capability
  • Text chat alongside video
  • Room-based system (users join specific "rooms")
  • Works across modern browsers
  • Reasonable quality on typical home internet connections

Non-Requirements (for simplicity):

  • Mobile app support (we'll focus on browser-based implementation)
  • Recording functionality
  • Custom layouts or virtual backgrounds
  • End-to-end encryption (we'll use standard WebRTC security)

With these requirements in mind, let's design our system architecture.

Choosing the Right Architecture

For our video conferencing system, we need to select an appropriate architecture. As we discussed in our article on scaling WebRTC, there are several options:

  1. Mesh Network: Each participant connects directly to every other participant
  2. Selective Forwarding Unit (SFU): Participants send their media to a server, which forwards it to other participants
  3. Multipoint Control Unit (MCU): Server receives, decodes, combines, and re-encodes media

Given our requirement to support up to 8 participants, a mesh network would be pushing the limits of what's practical for typical devices and connections. An MCU would be overkill for our needs and more complex to implement. An SFU provides the best balance of scalability and implementation complexity for our use case.

Here's a diagram of our chosen SFU architecture:

                ┌─────────────┐
                │             │
                │  Signaling  │
                │   Server    │
                │             │
                └─────────────┘
                       │
                       │ (WebSocket)
                       ▼
┌─────────┐      ┌─────────────┐      ┌─────────┐
│         │◄────►│             │◄────►│         │
│ Browser │      │     SFU     │      │ Browser │
│    A    │◄────►│   Server    │◄────►│    B    │
│         │      │             │      │         │
└─────────┘      └─────────────┘      └─────────┘
                       ▲
                       │
                       ▼
                  ┌─────────┐
                  │         │
                  │ Browser │
                  │    C    │
                  │         │
                  └─────────┘

In this architecture:

  • Each browser establishes a WebRTC connection to the SFU server
  • The SFU server forwards media streams between participants
  • The signaling server facilitates the initial connection setup

Building the Signaling Server

Let's start by implementing our signaling server using Node.js and WebSocket:

// server.js
const WebSocket = require('ws');
const http = require('http');
const express = require('express');
const { v4: uuidv4 } = require('uuid');

const app = express();
const server = http.createServer(app);
const wss = new WebSocket.Server({ server });

// Serve static files
app.use(express.static('public'));

// Store active rooms and participants
const rooms = new Map();

wss.on('connection', (ws) => {
  // Assign a unique ID to this connection
  const clientId = uuidv4();
  let roomId = null;
  
  console.log(`Client connected: ${clientId}`);
  
  // Send the client their ID
  ws.send(JSON.stringify({
    type: 'connect',
    clientId: clientId
  }));
  
  ws.on('message', (message) => {
    try {
      const data = JSON.parse(message);
      
      switch (data.type) {
        case 'join-room':
          handleJoinRoom(clientId, data.roomId, ws);
          roomId = data.roomId;
          break;
          
        case 'leave-room':
          handleLeaveRoom(clientId, roomId);
          roomId = null;
          break;
          
        case 'signal':
          handleSignal(clientId, roomId, data.target, data.signal);
          break;
          
        case 'chat':
          handleChatMessage(clientId, roomId, data.message);
          break;
      }
    } catch (error) {
      console.error('Error processing message:', error);
    }
  });
  
  ws.on('close', () => {
    console.log(`Client disconnected: ${clientId}`);
    if (roomId) {
      handleLeaveRoom(clientId, roomId);
    }
  });
  
  // Handle a client joining a room
  function handleJoinRoom(clientId, roomId, ws) {
    // Create room if it doesn't exist
    if (!rooms.has(roomId)) {
      rooms.set(roomId, new Map());
    }
    
    const room = rooms.get(roomId);
    
    // Add this client to the room
    room.set(clientId, { ws, name: data.name || `User ${clientId.substr(0, 5)}` });
    
    // Notify everyone in the room about the new participant
    room.forEach((participant, id) => {
      if (id !== clientId) {
        // Tell existing participant about the new one
        participant.ws.send(JSON.stringify({
          type: 'user-joined',
          userId: clientId,
          name: room.get(clientId).name
        }));
        
        // Tell the new participant about existing ones
        ws.send(JSON.stringify({
          type: 'user-joined',
          userId: id,
          name: participant.name
        }));
      }
    });
    
    console.log(`Client ${clientId} joined room ${roomId}`);
  }
  
  // Handle a client leaving a room
  function handleLeaveRoom(clientId, roomId) {
    if (!rooms.has(roomId)) return;
    
    const room = rooms.get(roomId);
    
    // Remove client from room
    room.delete(clientId);
    
    // Notify others that this client left
    room.forEach((participant) => {
      participant.ws.send(JSON.stringify({
        type: 'user-left',
        userId: clientId
      }));
    });
    
    // Delete room if empty
    if (room.size === 0) {
      rooms.delete(roomId);
    }
    
    console.log(`Client ${clientId} left room ${roomId}`);
  }
  
  // Handle signaling messages
  function handleSignal(clientId, roomId, targetId, signal) {
    if (!rooms.has(roomId)) return;
    
    const room = rooms.get(roomId);
    const target = room.get(targetId);
    
    if (target) {
      target.ws.send(JSON.stringify({
        type: 'signal',
        userId: clientId,
        signal: signal
      }));
    }
  }
  
  // Handle chat messages
  function handleChatMessage(clientId, roomId, message) {
    if (!rooms.has(roomId)) return;
    
    const room = rooms.get(roomId);
    const sender = room.get(clientId);
    
    if (!sender) return;
    
    // Broadcast chat message to all participants in the room
    room.forEach((participant) => {
      participant.ws.send(JSON.stringify({
        type: 'chat',
        userId: clientId,
        name: sender.name,
        message: message,
        timestamp: Date.now()
      }));
    });
  }
});

const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

This signaling server handles:

  • Client connections and disconnections
  • Room management (joining, leaving)
  • Forwarding signaling messages between clients
  • Chat message broadcasting

Client-Side Implementation

Now, let's implement the client-side application that will connect to our signaling server and establish WebRTC connections:

// client.js
// Global variables
let localStream;
let localScreenStream;
let signalingSocket;
let peerConnections = {};
let roomId;
let userId;
let userName;
let isScreenSharing = false;

// Configuration for RTCPeerConnection
const peerConfig = {
  iceServers: [
    { urls: 'stun:stun.l.google.com:19302' },
    {
      urls: 'turn:turn.example.com:3478',
      username: 'username',
      credential: 'password'
    }
  ]
};

// Initialize the application
async function init() {
  // Set up UI event listeners
  document.getElementById('join-button').addEventListener('click', joinRoom);
  document.getElementById('leave-button').addEventListener('click', leaveRoom);
  document.getElementById('mic-button').addEventListener('click', toggleMicrophone);
  document.getElementById('camera-button').addEventListener('click', toggleCamera);
  document.getElementById('screen-button').addEventListener('click', toggleScreenShare);
  document.getElementById('chat-button').addEventListener('click', toggleChat);
  document.getElementById('chat-send').addEventListener('click', sendChatMessage);
  
  // Connect to signaling server
  connectToSignalingServer();
}

// Connect to the signaling server
function connectToSignalingServer() {
  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
  const wsUrl = `${protocol}//${window.location.host}/ws`;
  
  signalingSocket = new WebSocket(wsUrl);
  
  signalingSocket.onopen = () => {
    console.log('Connected to signaling server');
  };
  
  signalingSocket.onmessage = (event) => {
    const data = JSON.parse(event.data);
    handleSignalingMessage(data);
  };
  
  signalingSocket.onclose = () => {
    console.log('Disconnected from signaling server');
  };
}

// Handle incoming signaling messages
function handleSignalingMessage(data) {
  switch (data.type) {
    case 'connect':
      userId = data.clientId;
      break;
      
    case 'user-joined':
      handleUserJoined(data.userId, data.name);
      break;
      
    case 'user-left':
      handleUserLeft(data.userId);
      break;
      
    case 'signal':
      handleSignal(data.userId, data.signal);
      break;
      
    case 'chat':
      handleChatMessage(data.userId, data.name, data.message, data.timestamp);
      break;
  }
}

// Join a room
async function joinRoom() {
  // Get user inputs
  userName = document.getElementById('name-input').value || 'Anonymous';
  roomId = document.getElementById('room-input').value || generateRandomRoomId();
  
  try {
    // Get user media
    localStream = await navigator.mediaDevices.getUserMedia({
      audio: true,
      video: {
        width: { ideal: 1280 },
        height: { ideal: 720 },
        frameRate: { max: 30 }
      }
    });
    
    // Display local video
    const localVideo = document.getElementById('local-video');
    localVideo.srcObject = localStream;
    
    // Send join room message to signaling server
    signalingSocket.send(JSON.stringify({
      type: 'join-room',
      roomId: roomId,
      name: userName
    }));
    
    // Switch to conference screen
    document.getElementById('join-screen').classList.add('hidden');
    document.getElementById('conference-screen').classList.remove('hidden');
    
    // Update URL with room ID for sharing
    window.history.pushState(null, '', `?room=${roomId}`);
    
  } catch (error) {
    console.error('Error joining room:', error);
    alert(`Could not join room: ${error.message}`);
  }
}

// Handle a new user joining the room
function handleUserJoined(userId, name) {
  console.log(`User joined: ${userId} (${name})`);
  
  // Create a new peer connection for this user
  const peerConnection = new RTCPeerConnection(peerConfig);
  peerConnections[userId] = peerConnection;
  
  // Add our local stream to the connection
  localStream.getTracks().forEach(track => {
    peerConnection.addTrack(track, localStream);
  });
  
  // Handle ICE candidates
  peerConnection.onicecandidate = (event) => {
    if (event.candidate) {
      signalingSocket.send(JSON.stringify({
        type: 'signal',
        target: userId,
        signal: {
          type: 'candidate',
          candidate: event.candidate
        }
      }));
    }
  };
  
  // Handle incoming tracks
  peerConnection.ontrack = (event) => {
    // Create video element for this user
    const remoteVideo = document.createElement('video');
    remoteVideo.id = `video-${userId}`;
    remoteVideo.autoplay = true;
    remoteVideo.playsInline = true;
    
    // Create wrapper div
    const videoWrapper = document.createElement('div');
    videoWrapper.id = `user-${userId}`;
    videoWrapper.className = 'video-wrapper';
    videoWrapper.appendChild(remoteVideo);
    
    // Add label with user name
    const videoLabel = document.createElement('div');
    videoLabel.className = 'video-label';
    videoLabel.textContent = name;
    videoWrapper.appendChild(videoLabel);
    
    // Add to the remote videos container
    document.getElementById('remote-videos').appendChild(videoWrapper);
    
    // Set the remote stream as source
    remoteVideo.srcObject = event.streams[0];
  };
  
  // Create and send offer if we're the initiator (the one who joined later)
  if (userId > userId) {
    peerConnection.createOffer()
      .then(offer => peerConnection.setLocalDescription(offer))
      .then(() => {
        signalingSocket.send(JSON.stringify({
          type: 'signal',
          target: userId,
          signal: {
            type: 'offer',
            sdp: peerConnection.localDescription
          }
        }));
      })
      .catch(error => console.error('Error creating offer:', error));
  }
}

// Handle a user leaving the room
function handleUserLeft(userId) {
  console.log(`User left: ${userId}`);
  
  // Close and remove the peer connection
  if (peerConnections[userId]) {
    peerConnections[userId].close();
    delete peerConnections[userId];
  }
  
  // Remove the video element
  const videoWrapper = document.getElementById(`user-${userId}`);
  if (videoWrapper) {
    videoWrapper.remove();
  }
}

// Handle incoming signaling messages
function handleSignal(userId, signal) {
  const peerConnection = peerConnections[userId];
  
  if (!peerConnection) return;
  
  switch (signal.type) {
    case 'offer':
      peerConnection.setRemoteDescription(new RTCSessionDescription(signal.sdp))
        .then(() => peerConnection.createAnswer())
        .then(answer => peerConnection.setLocalDescription(answer))
        .then(() => {
          signalingSocket.send(JSON.stringify({
            type: 'signal',
            target: userId,
            signal: {
              type: 'answer',
              sdp: peerConnection.localDescription
            }
          }));
        })
        .catch(error => console.error('Error handling offer:', error));
      break;
      
    case 'answer':
      peerConnection.setRemoteDescription(new RTCSessionDescription(signal.sdp))
        .catch(error => console.error('Error handling answer:', error));
      break;
      
    case 'candidate':
      peerConnection.addIceCandidate(new RTCIceCandidate(signal.candidate))
        .catch(error => console.error('Error adding ICE candidate:', error));
      break;
  }
}

// Leave the current room
function leaveRoom() {
  // Stop local streams
  if (localStream) {
    localStream.getTracks().forEach(track => track.stop());
  }
  
  if (localScreenStream) {
    localScreenStream.getTracks().forEach(track => track.stop());
  }
  
  // Close all peer connections
  Object.values(peerConnections).forEach(pc => pc.close());
  peerConnections = {};
  
  // Send leave room message
  if (signalingSocket && signalingSocket.readyState === WebSocket.OPEN) {
    signalingSocket.send(JSON.stringify({
      type: 'leave-room'
    }));
  }
  
  // Reset state
  roomId = null;
  isScreenSharing = false;
  
  // Switch back to join screen
  document.getElementById('conference-screen').classList.add('hidden');
  document.getElementById('join-screen').classList.remove('hidden');
  document.getElementById('chat-panel').classList.add('hidden');
  
  // Clear remote videos
  document.getElementById('remote-videos').innerHTML = '';
  
  // Reset URL
  window.history.pushState(null, '', window.location.pathname);
}

// Toggle microphone mute state
function toggleMicrophone() {
  const micButton = document.getElementById('mic-button');
  const audioTracks = localStream.getAudioTracks();
  
  if (audioTracks.length > 0) {
    const enabled = !audioTracks[0].enabled;
    audioTracks[0].enabled = enabled;
    
    // Update button state
    micButton.classList.toggle('active', enabled);
    micButton.querySelector('.icon').textContent = enabled ? '🎤' : '🔇';
  }
}

// Toggle camera on/off state
function toggleCamera() {
  const cameraButton = document.getElementById('camera-button');
  const videoTracks = localStream.getVideoTracks();
  
  if (videoTracks.length > 0) {
    const enabled = !videoTracks[0].enabled;
    videoTracks[0].enabled = enabled;
    
    // Update button state
    cameraButton.classList.toggle('active', enabled);
    cameraButton.querySelector('.icon').textContent = enabled ? '📹' : '🚫';
  }
}

// Toggle screen sharing
async function toggleScreenShare() {
  const screenButton = document.getElementById('screen-button');
  
  if (!isScreenSharing) {
    try {
      // Get screen sharing stream
      localScreenStream = await navigator.mediaDevices.getDisplayMedia({
        video: true
      });
      
      // Replace video track in all peer connections
      const videoTrack = localScreenStream.getVideoTracks()[0];
      
      Object.values(peerConnections).forEach(pc => {
        const sender = pc.getSenders().find(s => 
          s.track && s.track.kind === 'video'
        );
        
        if (sender) {
          sender.replaceTrack(videoTrack);
        }
      });
      
      // Update local video
      const localVideo = document.getElementById('local-video');
      localVideo.srcObject = localScreenStream;
      
      // Listen for the end of screen sharing
      videoTrack.addEventListener('ended', () => {
        toggleScreenShare();
      });
      
      isScreenSharing = true;
      screenButton.classList.add('active');
      
    } catch (error) {
      console.error('Error sharing screen:', error);
    }
  } else {
    // Stop screen sharing
    if (localScreenStream) {
      localScreenStream.getTracks().forEach(track => track.stop());
    }
    
    // Replace with camera track
    const videoTrack = localStream.getVideoTracks()[0];
    
    Object.values(peerConnections).forEach(pc => {
      const sender = pc.getSenders().find(s => 
        s.track && s.track.kind === 'video'
      );
      
      if (sender) {
        sender.replaceTrack(videoTrack);
      }
    });
    
    // Update local video
    const localVideo = document.getElementById('local-video');
    localVideo.srcObject = localStream;
    
    isScreenSharing = false;
    screenButton.classList.remove('active');
  }
}

// Toggle chat panel visibility
function toggleChat() {
  const chatPanel = document.getElementById('chat-panel');
  const chatButton = document.getElementById('chat-button');
  
  chatPanel.classList.toggle('hidden');
  chatButton.classList.toggle('active');
  
  if (!chatPanel.classList.contains('hidden')) {
    document.getElementById('chat-input').focus();
  }
}

// Send a chat message
function sendChatMessage() {
  const chatInput = document.getElementById('chat-input');
  const message = chatInput.value.trim();
  
  if (message && signalingSocket && signalingSocket.readyState === WebSocket.OPEN) {
    signalingSocket.send(JSON.stringify({
      type: 'chat',
      message: message
    }));
    
    chatInput.value = '';
  }
}

// Handle incoming chat message
function handleChatMessage(userId, name, message, timestamp) {
  const chatMessages = document.getElementById('chat-messages');
  const messageElement = document.createElement('div');
  
  messageElement.className = `chat-message ${userId === userId ? 'sent' : 'received'}`;
  
  const senderElement = document.createElement('div');
  senderElement.className = 'sender';
  senderElement.textContent = userId === userId ? 'You' : name;
  
  const contentElement = document.createElement('div');
  contentElement.className = 'content';
  contentElement.textContent = message;
  
  const timeElement = document.createElement('div');
  timeElement.className = 'time';
  timeElement.textContent = new Date(timestamp).toLocaleTimeString();
  
  messageElement.appendChild(senderElement);
  messageElement.appendChild(contentElement);
  messageElement.appendChild(timeElement);
  
  chatMessages.appendChild(messageElement);
  chatMessages.scrollTop = chatMessages.scrollHeight;
}

// Generate a random room ID
function generateRandomRoomId() {
  return Math.random().toString(36).substring(2, 8);
}

// Initialize when the page loads
window.addEventListener('DOMContentLoaded', init);

// Check URL for room ID on page load
window.addEventListener('load', () => {
  const urlParams = new URLSearchParams(window.location.search);
  const roomParam = urlParams.get('room');
  
  if (roomParam) {
    document.getElementById('room-input').value = roomParam;
  }
});

Deployment Considerations

When deploying a WebRTC video conferencing system to production, consider these important factors:

1. TURN Server Infrastructure

For reliable connectivity across diverse network environments, deploy TURN servers in multiple geographic regions. Consider using a managed TURN service or set up your own using software like coturn.

2. Scaling the Signaling Server

As your user base grows, you'll need to scale your signaling server. Options include:

  • Horizontal scaling with a load balancer
  • Using Redis or another pub/sub system for cross-instance communication
  • WebSocket clustering

3. Security Considerations

Implement proper security measures:

  • Use HTTPS for your web application
  • Secure WebSocket connections (WSS)
  • Implement authentication and authorization
  • Consider room access controls (passwords, expiration)

4. Monitoring and Analytics

Set up monitoring for:

  • Connection success rates
  • Media quality metrics
  • Server resource usage
  • User experience metrics

5. Fallback Mechanisms

Implement fallbacks for when WebRTC connections fail:

  • TURN over TCP when UDP is blocked
  • Reduced quality options for limited bandwidth
  • Clear error messages and recovery options

Enhancing the User Experience

A successful video conferencing application isn't just about the technical implementation—it's also about creating a good user experience:

1. Visual Feedback

Provide clear visual indicators for:

  • Connection status
  • Audio levels (to show who's speaking)
  • Network quality
  • Mute/camera states

2. Layout Options

Consider implementing different layout options:

  • Grid view for equal-sized participants
  • Speaker view that highlights the active speaker
  • Presentation mode that emphasizes screen sharing

3. Accessibility

Make your application accessible:

  • Keyboard navigation
  • Screen reader support
  • Captions or transcription options
  • High contrast mode

4. Mobile Responsiveness

Even though we're focusing on desktop browsers, ensure your UI works reasonably well on mobile devices:

  • Responsive design
  • Touch-friendly controls
  • Simplified layout for small screens

Lessons from Real-World Implementations

Throughout my career building WebRTC applications, I've learned several valuable lessons:

1. Start Simple, Then Scale

Begin with a minimal implementation that works reliably, then add features incrementally. This approach helps identify and resolve issues early.

2. Test Across Diverse Environments

WebRTC behavior varies significantly across different browsers, devices, and network conditions. Comprehensive testing is essential.

3. Focus on Recovery, Not Just Prevention

No matter how well you design your system, some connections will fail. Implement robust recovery mechanisms and clear user guidance when issues occur.

4. Monitor Real User Metrics

Collect and analyze data from real users to identify patterns and improve your implementation. What works in testing may not work in the real world.

5. Balance Quality and Reliability

Sometimes, reducing quality to ensure reliability provides a better overall experience than attempting to maintain high quality at the cost of stability.

The Future of WebRTC Video Conferencing

As WebRTC continues to evolve, several trends are shaping the future of video conferencing:

1. AI-Enhanced Features

Machine learning is enabling features like:

  • Background replacement without green screens
  • Noise suppression and echo cancellation
  • Automatic framing and lighting adjustment
  • Real-time translation and transcription

2. WebAssembly Processing

WebAssembly is enabling more efficient client-side processing, allowing for:

  • Custom video filters and effects
  • Advanced compression techniques
  • Real-time analytics

3. Low-Latency Streaming at Scale

Emerging technologies like WebTransport and WHIP/WHEP are enabling new approaches to large-scale streaming with WebRTC-level latency.

Real-time systems demand layered tradeoffs

Building a WebRTC video conferencing system requires understanding multiple technologies and making thoughtful architectural decisions. By starting with a solid foundation—a reliable signaling server, appropriate architecture, and clean client implementation—you can create a system that provides high-quality real-time communication.

Remember that WebRTC is designed to work across an incredibly diverse range of devices, browsers, and network conditions. Perfect reliability is an aspiration rather than an expectation. The goal is to create applications that gracefully handle the inevitable edge cases and provide users with the best possible experience given their constraints.

In our next article, we'll explore how WebRTC is being used beyond traditional video conferencing in IoT and embedded systems, opening up new possibilities for real-time communication.

---

This article is part of our WebRTC Essentials series, where we explore the technologies that power modern real-time communication. Join us in the next installment as we dive into WebRTC in IoT and Embedded Systems.