Skip to main content

WebRTC: Real-Time Communication for the Web

Created: February 27, 2026 Larry Qu 9 min read

WebRTC (Web Real-Time Communication) is the only browser API that enables real-time peer-to-peer video, audio, and data transfer without plugins or third-party software. It powers Google Meet, Discord, and countless other applications — all running directly in the browser with built-in encryption and NAT traversal.

Unlike traditional communication protocols that require server relays for media, WebRTC establishes direct connections between peers using ICE, STUN, and TURN, minimizing latency and bandwidth costs.

Prerequisites

  • Basic knowledge of JavaScript and asynchronous programming (Promises, async/await)
  • Node.js 18+ installed (for the signaling server example)
  • A modern browser (Chrome, Firefox, Safari, or Edge)
  • Familiarity with WebSockets is helpful but not required

What is WebRTC?

// Basic WebRTC - peer-to-peer video
const pc = new RTCPeerConnection(iceServers);

pc.ontrack = (event) => {
  document.getElementById('video').srcObject = event.streams[0];
};

navigator.mediaDevices.getUserMedia({ video: true, audio: true })
  .then(stream => stream.getTracks().forEach(track => pc.addTrack(track, stream)));
sequenceDiagram
    participant A as Browser A
    participant S as Signaling Server
    participant B as Browser B
    participant STUN as STUN Server

    A->>S: 1. Join room (WebSocket)
    S->>B: 2. Notify: user joined
    A->>A: 3. Create RTCPeerConnection
    A->>STUN: 4. STUN request (get public IP)
    STUN->>A: 5. Return public IP:port
    A->>S: 6. Send SDP offer
    S->>B: 7. Forward offer
    B->>B: 8. Create RTCPeerConnection
    B->>S: 9. Send SDP answer
    S->>A: 10. Forward answer
    A->>S: 11. Send ICE candidates
    S->>B: 12. Forward ICE candidates
    B->>S: 13. Send ICE candidates
    S->>A: 14. Forward ICE candidates
    Note over A,B: 15. Direct P2P media & data
    A->>B: encrypted media stream (SRTP)
    B->>A: encrypted media stream (SRTP)

Key Features

  • Video/Audio - Real-time media streaming
  • P2P - Direct peer-to-peer connections
  • Data Channels - Arbitrary data transfer
  • NAT Traversal - ICE, STUN, TURN
  • Encryption - DTLS/SRTP built-in
  • Standards-based - W3C/IETF standard

Core Components

RTCPeerConnection

const config = {
  iceServers: [
    { urls: 'stun:stun.l.google.com:19302' },
    { 
      urls: 'turn:turn.example.com:3478',
      username: 'user',
      credential: 'pass'
    }
  ]
};

const pc = new RTCPeerConnection(config);

// Handle remote tracks
pc.ontrack = (event) => {
  console.log('Received remote track:', event.track.kind);
  const stream = event.streams[0];
  // Add stream to video element
};

// Handle ICE candidates
pc.onicecandidate = (event) => {
  if (event.candidate) {
    // Send candidate to peer via signaling
    sendSignalingMessage({ type: 'candidate', candidate: event.candidate });
  }
};

// Connection state
pc.onconnectionstatechange = () => {
  console.log('Connection state:', pc.connectionState);
};

// ICE connection state
pc.oniceconnectionstatechange = () => {
  console.log('ICE state:', pc.iceConnectionState);
};

Media Streams

// Get user media
async function getUserMedia() {
  try {
    const stream = await navigator.mediaDevices.getUserMedia({
      video: {
        width: { ideal: 1280 },
        height: { ideal: 720 },
        frameRate: { ideal: 30 }
      },
      audio: {
        echoCancellation: true,
        noiseSuppression: true,
        autoGainControl: true
      }
    });
    
    return stream;
  } catch (error) {
    console.error('Error accessing media devices:', error);
  }
}

// Display local video
const localVideo = document.getElementById('localVideo');
localVideo.srcObject = localStream;
localVideo.muted = true; // Mute local playback

RTCSessionDescription

// Create offer
async function createOffer(pc) {
  const offer = await pc.createOffer({
    offerToReceiveAudio: true,
    offerToReceiveVideo: true
  });
  
  await pc.setLocalDescription(offer);
  return offer;
}

// Create answer
async function createAnswer(pc) {
  const answer = await pc.createAnswer();
  await pc.setLocalDescription(answer);
  return answer;
}

// Set remote description
async function setRemoteDescription(pc, description) {
  await pc.setRemoteDescription(new RTCSessionDescription(description));
}

WebRTC relies on a signaling channel — typically WebSocket — to exchange session descriptions and ICE candidates before a direct peer-to-peer connection can be established. The signaling server itself does not carry media data; it only facilitates the initial handshake.

Signaling

Server Setup (Node.js)

const WebSocket = require('ws');

const wss = new WebSocket.Server({ port: 8080 });

const rooms = new Map();

wss.on('connection', (ws) => {
  let roomId = null;
  let clientId = null;
  
  ws.on('message', (message) => {
    const data = JSON.parse(message);
    
    switch (data.type) {
      case 'join':
        roomId = data.roomId;
        clientId = Date.now().toString();
        
        if (!rooms.has(roomId)) {
          rooms.set(roomId, new Set());
        }
        
        rooms.get(roomId).add({ ws, clientId });
        
        // Notify others in room
        broadcastToRoom(roomId, {
          type: 'user-joined',
          clientId
        }, clientId);
        
        // Send existing users to new client
        const existingClients = Array.from(rooms.get(roomId))
          .filter(c => c.clientId !== clientId)
          .map(c => c.clientId);
        
        ws.send(JSON.stringify({
          type: 'room-users',
          users: existingClients
        }));
        break;
        
      case 'offer':
      case 'answer':
      case 'candidate':
        // Forward to specific client
        sendToClient(roomId, data.targetClientId, data);
        break;
        
      case 'leave':
        removeFromRoom(roomId, clientId);
        break;
    }
  });
  
  ws.on('close', () => {
    if (roomId && clientId) {
      removeFromRoom(roomId, clientId);
    }
  });
});

function broadcastToRoom(roomId, message, excludeClientId) {
  if (!rooms.has(roomId)) return;
  
  const data = JSON.stringify(message);
  rooms.get(roomId).forEach(client => {
    if (client.clientId !== excludeClientId && client.ws.readyState === WebSocket.OPEN) {
      client.ws.send(data);
    }
  });
}

function sendToClient(roomId, targetClientId, message) {
  if (!rooms.has(roomId)) return;
  
  const data = JSON.stringify(message);
  rooms.get(roomId).forEach(client => {
    if (client.clientId === targetClientId && client.ws.readyState === WebSocket.OPEN) {
      client.ws.send(data);
    }
  });
}

function removeFromRoom(roomId, clientId) {
  if (!rooms.has(roomId)) return;
  
  rooms.get(roomId).forEach(client => {
    if (client.clientId === clientId) {
      client.ws.send(JSON.stringify({ type: 'user-left', clientId }));
      rooms.get(roomId).delete(client);
    }
  });
}

Client Implementation

class SignalingClient {
  constructor(url, roomId) {
    this.ws = new WebSocket(url);
    this.roomId = roomId;
    this.pc = null;
    this.clientId = null;
    
    this.setupWebSocket();
  }
  
  setupWebSocket() {
    this.ws.onopen = () => {
      this.send({ type: 'join', roomId: this.roomId });
    };
    
    this.ws.onmessage = async (event) => {
      const data = JSON.parse(event.data);
      await this.handleSignalingMessage(data);
    };
  }
  
  async handleSignalingMessage(data) {
    switch (data.type) {
      case 'room-users':
        // Handle existing users
        data.users.forEach(userId => this.createPeerConnection(userId, true));
        break;
        
      case 'user-joined':
        this.createPeerConnection(data.clientId, false);
        break;
        
      case 'offer':
        await this.handleOffer(data);
        break;
        
      case 'answer':
        await this.handleAnswer(data);
        break;
        
      case 'candidate':
        await this.handleCandidate(data);
        break;
        
      case 'user-left':
        this.removePeerConnection(data.clientId);
        break;
    }
  }
  
  async createPeerConnection(peerId, isInitiator) {
    this.pc = new RTCPeerConnection(iceServers);
    
    this.pc.ontrack = (event) => {
      // Display remote stream
      const remoteVideo = document.getElementById('remoteVideo');
      remoteVideo.srcObject = event.streams[0];
    };
    
    // Add local tracks
    const localStream = await getUserMedia();
    localStream.getTracks().forEach(track => {
      this.pc.addTrack(track, localStream);
    });
    
    if (isInitiator) {
      const offer = await this.pc.createOffer();
      await this.pc.setLocalDescription(offer);
      this.send({ type: 'offer', offer, targetClientId: peerId });
    }
  }
  
  async handleOffer(data) {
    await this.pc.setRemoteDescription(data.offer);
    const answer = await this.pc.createAnswer();
    await this.pc.setLocalDescription(answer);
    this.send({ type: 'answer', answer, targetClientId: data.clientId });
  }
  
  async handleAnswer(data) {
    await this.pc.setRemoteDescription(data.answer);
  }
  
  async handleCandidate(data) {
    await this.pc.addIceCandidate(new RTCIceCandidate(data.candidate));
  }
  
  send(message) {
    this.ws.send(JSON.stringify(message));
  }
}

Data Channels

Sending Data

const pc = new RTCPeerConnection(config);

// Create data channel
const dataChannel = pc.createDataChannel('chat', {
  ordered: true, // Guarantee order
  maxRetransmits: 30
});

dataChannel.onopen = () => {
  console.log('Data channel opened');
};

dataChannel.onmessage = (event) => {
  console.log('Received:', event.data);
};

// Send message
dataChannel.send(JSON.stringify({
  type: 'message',
  text: 'Hello!',
  timestamp: Date.now()
}));

Receiving Data

pc.ondatachannel = (event) => {
  const receiveChannel = event.channel;
  
  receiveChannel.onmessage = (event) => {
    const data = JSON.parse(event.data);
    console.log('Received:', data);
  };
  
  receiveChannel.onopen = () => {
    console.log('Receive channel opened');
  };
};

Advanced Features

Screen Sharing

async function shareScreen() {
  try {
    const screenStream = await navigator.mediaDevices.getDisplayMedia({
      video: {
        cursor: 'always'
      },
      audio: false
    });
    
    // Replace video track
    const videoTrack = screenStream.getVideoTracks()[0];
    const sender = pc.getSenders().find(s => s.track?.kind === 'video');
    
    sender.replaceTrack(videoTrack);
    
    // Handle stop sharing
    videoTrack.onended = () => {
      // Switch back to camera
      navigator.mediaDevices.getUserMedia({ video: true })
        .then(stream => {
          const cameraTrack = stream.getVideoTracks()[0];
          sender.replaceTrack(cameraTrack);
        });
    };
    
  } catch (error) {
    console.error('Error sharing screen:', error);
  }
}

Connection Statistics

async function getStats(pc) {
  const stats = await pc.getStats();
  
  stats.forEach(report => {
    if (report.type === 'inbound-rtp' && report.kind === 'video') {
      console.log('Video bytes received:', report.bytesReceived);
      console.log('Packets lost:', report.packetsLost);
      console.log('Jitter:', report.jitter);
    }
    
    if (report.type === 'outbound-rtp' && report.kind === 'video') {
      console.log('Video bytes sent:', report.bytesSent);
      console.log('Round trip time:', report.roundTripTime);
    }
  });
}

// Monitor every 2 seconds
setInterval(() => getStats(pc), 2000);

Audio/Video Controls

// Mute/unmute
function toggleAudio(enabled) {
  const audioTrack = localStream.getAudioTracks()[0];
  audioTrack.enabled = enabled;
}

function toggleVideo(enabled) {
  const videoTrack = localStream.getVideoTracks()[0];
  videoTrack.enabled = enabled;
}

// Adjust bitrate
async function setVideoBitrate(pc, bitrate) {
  const sender = pc.getSenders().find(s => s.track?.kind === 'video');
  
  const params = sender.getParameters();
  if (!params.encodings) {
    params.encodings = [{}];
  }
  
  params.encodings[0].maxBitrate = bitrate;
  await sender.setParameters(params);
}

Production Considerations

STUN/TURN Server Configuration

For production deployments, always use authenticated TURN servers as a fallback:

const config = {
  iceServers: [
    { urls: 'stun:stun.l.google.com:19302' },
    {
      urls: 'turn:turn.example.com:3478',
      username: process.env.TURN_USERNAME,
      credential: process.env.TURN_CREDENTIAL
    }
  ],
  iceCandidatePoolSize: 10  // Pre-fetch candidates for faster connections
};

Connection Quality Monitoring

Track key metrics to detect degradation early:

Metric Good Range Warning Critical
Round-trip time <150ms 150-300ms >300ms
Packet loss <1% 1-5% >5%
Jitter <30ms 30-50ms >50ms
Video bitrate >1Mbps 500K-1Mbps <500Kbps

Scalability

WebRTC is peer-to-peer, so bandwidth scales with participants:

  • 1:1 calls: Direct P2P — no server bandwidth needed for media
  • Small groups (3-6): Full mesh — each peer sends N-1 streams
  • Large groups (7+): Use a Selective Forwarding Unit (SFU) or Multipoint Control Unit (MCU)

For group calls beyond 4 participants, consider integrating an SFU server to relay media streams rather than relying on full mesh.

Security

  • Always enforce DTLS-SRTP encryption (mandatory in WebRTC)
  • Validate SDP offers/answers on your signaling server
  • Use short-lived TURN credentials (time-limited tokens)
  • Implement user authentication in the signaling layer

Error Handling

pc.oniceconnectionstatechange = () => {
  switch (pc.iceConnectionState) {
    case 'failed':
      console.error('ICE connection failed');
      // Try restarting ICE
      pc.restartIce();
      break;
      
    case 'disconnected':
      console.log('ICE disconnected');
      // Attempt to reconnect
      break;
      
    case 'closed':
      console.log('ICE closed');
      break;
  }
};

pc.oniceerror = (event) => {
  console.error('ICE error:', event);
};

pc.ontrackerror = (event) => {
  console.error('Track error:', event);
};

Troubleshooting

Connection Never Establishes

Problem:  PeerConnection stays in "connecting" state
Cause:    ICE candidates not exchanged or STUN/TURN unreachable
Fix:      1. Check signaling server logs for message forwarding
          2. Verify STUN server is reachable: `nc -vu stun.l.google.com 19302`
          3. Add a TURN server as fallback for symmetric NATs
          4. Ensure both peers set remote descriptions before adding ICE candidates

No Audio/Video

Problem:  Remote video element shows black, no audio heard
Cause:    getUserMedia permission denied or track not added to PC
Fix:      1. Check browser console for MediaDevice errors
          2. Verify `getUserMedia` resolved successfully
          3. Confirm `pc.addTrack()` was called for each track
          4. Check that `ontrack` handler sets `srcObject` on the correct video element

Data Channel Not Opening

Problem:  DataChannel.onopen never fires
Cause:    Channel not negotiated before signaling
Fix:      1. Create data channel BEFORE creating the offer
          2. For receiving side, implement `ondatachannel` handler
          3. Verify `maxRetransmits` and `ordered` settings match on both sides

Poor Video Quality

Problem:  Blurry or pixelated video
Cause:    Bandwidth constraints or encoder settings
Fix:      1. Use `getStats()` to monitor bitrate and packet loss
          2. Reduce resolution: `width: { ideal: 640 }, height: { ideal: 480 }`
          3. Set maxBitrate via sender parameters
          4. Consider using a TURN server if packet loss >5%

Browser Support

WebRTC is supported in all modern browsers:

  • Chrome 56+
  • Firefox 22+
  • Safari 11+
  • Edge 12+
  • Opera 43+

Conclusion

WebRTC is the standard for real-time peer-to-peer communication in the browser. Key takeaways:

  • RTCPeerConnection handles media and data channels with built-in encryption
  • A signaling server (typically WebSocket-based) is required to bootstrap connections
  • ICE with STUN/TURN provides NAT traversal — always configure TURN for production
  • Connection monitoring via getStats() helps detect and diagnose quality issues
  • Screen sharing and data channels extend WebRTC beyond simple video calls

For a deeper dive into the underlying protocols, see WebRTC Protocols. If you’re building real-time features beyond video chat, explore WebSocket for signaling and WebTransport for low-latency data streaming.

Resources

Comments

Share this article

Scan to read on mobile

👍 Was this article helpful?