Intermediate
20 min
Full Guide

WebRTC Signaling

Learn how peers discover and connect to each other using signaling servers

What is Signaling?

Signaling is the process of coordinating communication between WebRTC peers. Before two browsers can talk directly, they need to exchange information about how to connect—this happens through a signaling server.

WebRTC intentionally doesn't define a signaling protocol. You can use WebSockets, HTTP, Socket.io, or any other method to exchange signaling messages. This flexibility lets you integrate WebRTC into existing infrastructure.

📡 What Gets Exchanged

SDP (Session Description)

  • Offer - Caller's capabilities
  • Answer - Callee's response
  • • Codecs, encryption, bandwidth
  • • Media types (audio/video)

ICE Candidates

  • • Host candidates (local IPs)
  • • Server-reflexive (STUN)
  • • Relay candidates (TURN)
  • • Connection paths to try

Building a Signaling Server

// Signaling server with Socket.io

const { Server } = require('socket.io');
const http = require('http');

const server = http.createServer();
const io = new Server(server, {
  cors: { origin: '*' }
});

// Store connected users
const users = new Map();

io.on('connection', (socket) => {
  console.log('User connected:', socket.id);
  
  // User registers with a username
  socket.on('register', (username) => {
    users.set(username, socket.id);
    socket.username = username;
    
    // Broadcast user list
    io.emit('users', Array.from(users.keys()));
  });
  
  // Forward offer to specific user
  socket.on('offer', ({ to, offer }) => {
    const targetSocketId = users.get(to);
    if (targetSocketId) {
      io.to(targetSocketId).emit('offer', {
        from: socket.username,
        offer: offer
      });
    }
  });
  
  // Forward answer to caller
  socket.on('answer', ({ to, answer }) => {
    const targetSocketId = users.get(to);
    if (targetSocketId) {
      io.to(targetSocketId).emit('answer', {
        from: socket.username,
        answer: answer
      });
    }
  });
  
  // Forward ICE candidates
  socket.on('ice-candidate', ({ to, candidate }) => {
    const targetSocketId = users.get(to);
    if (targetSocketId) {
      io.to(targetSocketId).emit('ice-candidate', {
        from: socket.username,
        candidate: candidate
      });
    }
  });
  
  // Handle call request
  socket.on('call-request', ({ to }) => {
    const targetSocketId = users.get(to);
    if (targetSocketId) {
      io.to(targetSocketId).emit('incoming-call', {
        from: socket.username
      });
    } else {
      socket.emit('call-failed', { reason: 'User not found' });
    }
  });
  
  // Handle call response
  socket.on('call-response', ({ to, accepted }) => {
    const targetSocketId = users.get(to);
    if (targetSocketId) {
      io.to(targetSocketId).emit('call-response', {
        from: socket.username,
        accepted: accepted
      });
    }
  });
  
  // Handle disconnect
  socket.on('disconnect', () => {
    if (socket.username) {
      users.delete(socket.username);
      io.emit('users', Array.from(users.keys()));
    }
  });
});

server.listen(3001, () => {
  console.log('Signaling server running on port 3001');
});

Client-Side Signaling Handler

// Complete WebRTC client with signaling

class WebRTCClient {
  constructor(signalingUrl) {
    this.socket = io(signalingUrl);
    this.peerConnection = null;
    this.localStream = null;
    this.remoteStream = null;
    this.currentPeer = null;
    
    // ICE servers configuration
    this.config = {
      iceServers: [
        { urls: 'stun:stun.l.google.com:19302' },
        { urls: 'stun:stun1.l.google.com:19302' }
      ]
    };
    
    this.setupSocketListeners();
  }
  
  setupSocketListeners() {
    // Receive incoming call request
    this.socket.on('incoming-call', async ({ from }) => {
      const accepted = confirm(`Incoming call from ${from}. Accept?`);
      this.socket.emit('call-response', { to: from, accepted });
      
      if (accepted) {
        this.currentPeer = from;
        await this.initializePeerConnection();
        // Wait for offer
      }
    });
    
    // Receive call response
    this.socket.on('call-response', async ({ from, accepted }) => {
      if (accepted) {
        console.log('Call accepted, sending offer...');
        await this.initializePeerConnection();
        await this.createAndSendOffer(from);
      } else {
        console.log('Call rejected');
        this.cleanup();
      }
    });
    
    // Receive offer
    this.socket.on('offer', async ({ from, offer }) => {
      console.log('Received offer from:', from);
      this.currentPeer = from;
      
      if (!this.peerConnection) {
        await this.initializePeerConnection();
      }
      
      await this.peerConnection.setRemoteDescription(
        new RTCSessionDescription(offer)
      );
      
      const answer = await this.peerConnection.createAnswer();
      await this.peerConnection.setLocalDescription(answer);
      
      this.socket.emit('answer', {
        to: from,
        answer: answer
      });
    });
    
    // Receive answer
    this.socket.on('answer', async ({ from, answer }) => {
      console.log('Received answer from:', from);
      await this.peerConnection.setRemoteDescription(
        new RTCSessionDescription(answer)
      );
    });
    
    // Receive ICE candidate
    this.socket.on('ice-candidate', async ({ from, candidate }) => {
      console.log('Received ICE candidate from:', from);
      if (candidate && this.peerConnection) {
        await this.peerConnection.addIceCandidate(
          new RTCIceCandidate(candidate)
        );
      }
    });
  }
  
  async initializePeerConnection() {
    this.peerConnection = new RTCPeerConnection(this.config);
    
    // Handle ICE candidates
    this.peerConnection.onicecandidate = (event) => {
      if (event.candidate) {
        this.socket.emit('ice-candidate', {
          to: this.currentPeer,
          candidate: event.candidate
        });
      }
    };
    
    // Handle connection state
    this.peerConnection.onconnectionstatechange = () => {
      console.log('Connection state:', this.peerConnection.connectionState);
      if (this.peerConnection.connectionState === 'connected') {
        this.onConnected && this.onConnected();
      } else if (this.peerConnection.connectionState === 'failed') {
        this.onFailed && this.onFailed();
        this.cleanup();
      }
    };
    
    // Handle remote stream
    this.peerConnection.ontrack = (event) => {
      console.log('Received remote track');
      this.remoteStream = event.streams[0];
      this.onRemoteStream && this.onRemoteStream(this.remoteStream);
    };
    
    // Add local stream if available
    if (this.localStream) {
      this.localStream.getTracks().forEach(track => {
        this.peerConnection.addTrack(track, this.localStream);
      });
    }
  }
  
  async getLocalStream(video = true, audio = true) {
    this.localStream = await navigator.mediaDevices.getUserMedia({
      video: video,
      audio: audio
    });
    return this.localStream;
  }
  
  async createAndSendOffer(to) {
    const offer = await this.peerConnection.createOffer();
    await this.peerConnection.setLocalDescription(offer);
    
    this.socket.emit('offer', {
      to: to,
      offer: offer
    });
  }
  
  // Public API
  register(username) {
    this.socket.emit('register', username);
  }
  
  async callUser(username) {
    this.currentPeer = username;
    this.socket.emit('call-request', { to: username });
  }
  
  hangUp() {
    this.socket.emit('hang-up', { to: this.currentPeer });
    this.cleanup();
  }
  
  cleanup() {
    if (this.peerConnection) {
      this.peerConnection.close();
      this.peerConnection = null;
    }
    if (this.localStream) {
      this.localStream.getTracks().forEach(track => track.stop());
      this.localStream = null;
    }
    this.currentPeer = null;
  }
}

// Usage
const client = new WebRTCClient('http://localhost:3001');

client.onRemoteStream = (stream) => {
  document.getElementById('remoteVideo').srcObject = stream;
};

client.onConnected = () => {
  console.log('Call connected!');
};

// Register and start call
client.register('Alice');
const localStream = await client.getLocalStream();
document.getElementById('localVideo').srcObject = localStream;
await client.callUser('Bob');

The Offer/Answer Exchange

// Detailed look at the offer/answer dance

// STEP 1: Caller creates offer
async function createOffer(pc) {
  // Options for the offer
  const offerOptions = {
    offerToReceiveAudio: true,
    offerToReceiveVideo: true,
    iceRestart: false  // Set true to restart ICE
  };
  
  const offer = await pc.createOffer(offerOptions);
  
  // SDP contains session description
  console.log('Offer SDP:', offer.sdp);
  /*
  v=0
  o=- 4611731400430051336 2 IN IP4 127.0.0.1
  s=-
  t=0 0
  a=group:BUNDLE 0 1
  m=audio 9 UDP/TLS/RTP/SAVPF 111 103 104
  ...
  m=video 9 UDP/TLS/RTP/SAVPF 96 97 98
  ...
  */
  
  // Set as local description
  await pc.setLocalDescription(offer);
  
  return offer;
}

// STEP 2: Callee receives offer, creates answer
async function handleOffer(pc, offer) {
  // Set received offer as remote description
  await pc.setRemoteDescription(new RTCSessionDescription(offer));
  
  // Create answer
  const answer = await pc.createAnswer();
  
  // Set as local description
  await pc.setLocalDescription(answer);
  
  return answer;
}

// STEP 3: Caller receives answer
async function handleAnswer(pc, answer) {
  await pc.setRemoteDescription(new RTCSessionDescription(answer));
  // Connection negotiation complete!
}

ICE Candidate Exchange

// ICE candidates are gathered after setLocalDescription

const pc = new RTCPeerConnection(config);

// Candidates trickle in as they're discovered
pc.onicecandidate = (event) => {
  if (event.candidate) {
    // Candidate found - send to peer
    console.log('New ICE candidate:', event.candidate);
    /*
    {
      candidate: "candidate:842163049 1 udp 1677729535 192.168.1.100 54321 typ srflx...",
      sdpMid: "0",
      sdpMLineIndex: 0,
      usernameFragment: "sdfg"
    }
    */
    
    signalingChannel.send({
      type: 'ice-candidate',
      candidate: event.candidate,
      to: remotePeer
    });
  } else {
    // null candidate means gathering is complete
    console.log('ICE gathering complete');
  }
};

// Alternative: Wait for all candidates (not recommended)
pc.onicegatheringstatechange = () => {
  if (pc.iceGatheringState === 'complete') {
    // All candidates included in localDescription.sdp
    const offerWithCandidates = pc.localDescription;
    // Send complete offer (slower startup but simpler)
  }
};

// Receiving candidates from peer
async function handleIceCandidate(candidateData) {
  try {
    await pc.addIceCandidate(new RTCIceCandidate(candidateData));
    console.log('Added ICE candidate successfully');
  } catch (error) {
    // Can fail if added before remote description is set
    console.error('Failed to add ICE candidate:', error);
  }
}

// Handle ICE candidate buffering (common issue)
const pendingCandidates = [];

async function handleIceCandidateSafe(candidate) {
  if (pc.remoteDescription) {
    await pc.addIceCandidate(candidate);
  } else {
    // Buffer until remote description is set
    pendingCandidates.push(candidate);
  }
}

async function handleRemoteDescription(description) {
  await pc.setRemoteDescription(description);
  
  // Process buffered candidates
  for (const candidate of pendingCandidates) {
    await pc.addIceCandidate(candidate);
  }
  pendingCandidates.length = 0;
}

Perfect Negotiation Pattern

// The "perfect negotiation" pattern handles edge cases

// Problem: What if both peers try to create offers simultaneously?
// Solution: Designate one as "polite" (yields) and one as "impolite"

class PerfectNegotiation {
  constructor(pc, signalingChannel, polite) {
    this.pc = pc;
    this.signaling = signalingChannel;
    this.polite = polite; // true = yield on collisions
    this.makingOffer = false;
    this.ignoreOffer = false;
    
    this.setup();
  }
  
  setup() {
    // Handle negotiation needed (renegotiation)
    this.pc.onnegotiationneeded = async () => {
      try {
        this.makingOffer = true;
        await this.pc.setLocalDescription();
        this.signaling.send({
          type: 'description',
          description: this.pc.localDescription
        });
      } catch (err) {
        console.error('Negotiation failed:', err);
      } finally {
        this.makingOffer = false;
      }
    };
    
    this.pc.onicecandidate = ({ candidate }) => {
      this.signaling.send({ type: 'candidate', candidate });
    };
    
    // Handle incoming messages
    this.signaling.on('message', async (msg) => {
      try {
        if (msg.type === 'description') {
          await this.handleDescription(msg.description);
        } else if (msg.type === 'candidate') {
          await this.pc.addIceCandidate(msg.candidate);
        }
      } catch (err) {
        console.error('Signaling error:', err);
      }
    });
  }
  
  async handleDescription(description) {
    const offerCollision = 
      description.type === 'offer' &&
      (this.makingOffer || this.pc.signalingState !== 'stable');
    
    this.ignoreOffer = !this.polite && offerCollision;
    
    if (this.ignoreOffer) {
      console.log('Ignoring offer (collision, impolite peer)');
      return;
    }
    
    await this.pc.setRemoteDescription(description);
    
    if (description.type === 'offer') {
      await this.pc.setLocalDescription();
      this.signaling.send({
        type: 'description',
        description: this.pc.localDescription
      });
    }
  }
}

// Usage
const pc = new RTCPeerConnection(config);
const polite = determineIfPolite(); // e.g., based on user ID comparison
const negotiation = new PerfectNegotiation(pc, signalingChannel, polite);

💡 Signaling Best Practices

  • Use WebSocket for signaling - Low latency, bidirectional
  • Buffer ICE candidates - Wait for remote description
  • Handle trickle ICE - Don't wait for all candidates
  • Implement perfect negotiation - Handle offer collisions
  • Authenticate users - Verify identity before connecting
  • Keep signaling minimal - Only exchange necessary data