WebRTC Signaling Explained: How Peers Find Each Other

Signaling is the unsung hero of WebRTC. While not part of the WebRTC specification itself, signaling is absolutely essential for establishing peer-to-peer connections. This article explores what signaling is, why it's necessary, and how to implement it effectively.

What is Signaling?

The signaling exchange: offer, answer, and ICE candidates Peer A sends an SDP offer through the signaling server to Peer B. Peer B replies with an SDP answer. Both peers then trickle ICE candidates to each other through the same server until a connection path is found. Peer A Signaling serverWebSocket / HTTP Peer B 1 · offer (SDP) 2 · answer (SDP) 3 · ICE candidates (trickle, both ways)
Figure 1 — Signaling carries three things between peers: the offer, the answer, and a stream of ICE candidates. WebRTC never defines the server in the middle — that part is yours to build.

Signaling is the process of coordinating communication between peers before they can establish a direct connection. It involves exchanging metadata about the session, network information, and media capabilities.

Think of signaling as the handshake before a conversation. Just as you need to greet someone and establish common ground before a meaningful discussion, WebRTC peers need to exchange information before they can communicate directly.

Why Signaling Isn't Standardized

WebRTC intentionally doesn't specify how signaling should work. This flexibility allows developers to use whatever signaling mechanism best fits their application architecture—WebSockets, HTTP long polling, messaging services, or even physical QR codes for local connections.

What Information Gets Signaled?

Signaling exchanges three main types of information:

Session Control Messages

These coordinate the connection establishment process:

  • Offers: One peer proposes a session with specific media capabilities.
  • Answers: The responding peer agrees to the session with compatible capabilities.
  • ICE Candidates: Network addresses where peers can be reached.

Session Description Protocol (SDP)

SDP messages describe:

  • Media types (audio, video, data channels)
  • Codecs supported
  • Bandwidth information
  • Network transport details
  • Security parameters

ICE Candidates

As peers discover ways they can be reached:

  • Local network addresses (host candidates)
  • Public addresses from STUN (reflexive candidates)
  • Relay addresses from TURN (relay candidates)

If candidate gathering isn't producing the addresses you expect, you can inspect what your STUN/TURN servers return with the free ICE server tester.

Common Signaling Architectures

WebSocket-Based Signaling

The most common approach uses WebSockets for bidirectional, real-time communication:

const ws = new WebSocket('wss://signaling.example.com');

ws.onmessage = async (event) => {
  const message = JSON.parse(event.data);

  switch(message.type) {
    case 'offer':
      await pc.setRemoteDescription(message.offer);
      const answer = await pc.createAnswer();
      await pc.setLocalDescription(answer);
      ws.send(JSON.stringify({type: 'answer', answer}));
      break;

    case 'answer':
      await pc.setRemoteDescription(message.answer);
      break;

    case 'ice-candidate':
      await pc.addIceCandidate(message.candidate);
      break;
  }
};

// Send ICE candidates as discovered
pc.onicecandidate = (event) => {
  if (event.candidate) {
    ws.send(JSON.stringify({
      type: 'ice-candidate',
      candidate: event.candidate
    }));
  }
};

HTTP-Based Signaling

For simpler applications, HTTP polling or long-polling can work:

async function sendOffer(offer) {
  const response = await fetch('/api/offer', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({offer})
  });
  return await response.json();
}

async function pollForAnswer() {
  const response = await fetch('/api/answer');
  return await response.json();
}

Peer-to-Peer Signaling

For local applications, peers can exchange signaling information directly through QR codes, NFC, Bluetooth, or manual entry:

// Generate offer as QR code
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const qrCode = generateQR(JSON.stringify(offer));
displayQR(qrCode);

// Scan peer's QR code for answer
const answer = scanQR();
await pc.setRemoteDescription(JSON.parse(answer));

Implementing a Signaling Server

A basic Node.js signaling server using WebSockets:

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

const rooms = new Map();

wss.on('connection', (ws) => {
  let currentRoom = null;

  ws.on('message', (data) => {
    const message = JSON.parse(data);

    switch(message.type) {
      case 'join':
        currentRoom = message.room;
        if (!rooms.has(currentRoom)) {
          rooms.set(currentRoom, []);
        }
        rooms.get(currentRoom).push(ws);
        break;

      case 'offer':
      case 'answer':
      case 'ice-candidate':
        // Relay to other peers in room
        if (currentRoom && rooms.has(currentRoom)) {
          rooms.get(currentRoom).forEach(client => {
            if (client !== ws && client.readyState === WebSocket.OPEN) {
              client.send(JSON.stringify(message));
            }
          });
        }
        break;
    }
  });

  ws.on('close', () => {
    if (currentRoom && rooms.has(currentRoom)) {
      const clients = rooms.get(currentRoom);
      const index = clients.indexOf(ws);
      if (index > -1) {
        clients.splice(index, 1);
      }
    }
  });
});

Signaling Security

Secure your signaling channel to prevent attacks:

Authentication

Verify user identity before allowing signaling:

ws.on('connection', (ws, request) => {
  const token = request.headers['authorization'];
  if (!verifyToken(token)) {
    ws.close(1008, 'Unauthorized');
    return;
  }
  // Continue with signaling
});

Encryption

Always use TLS/SSL for signaling:

  • WSS (WebSocket Secure) instead of WS
  • HTTPS instead of HTTP
  • Verify certificates

Message Validation

Validate all signaling messages:

function validateMessage(message) {
  if (!message.type || !['offer', 'answer', 'ice-candidate'].includes(message.type)) {
    return false;
  }

  if (message.type === 'offer' || message.type === 'answer') {
    return validateSDP(message.sdp);
  }

  if (message.type === 'ice-candidate') {
    return validateICECandidate(message.candidate);
  }

  return true;
}

Signaling Patterns

Different application types require different signaling patterns:

One-to-One Calling

Direct signaling between two specific peers:

  1. Caller creates offer.
  2. Signaling server routes offer to callee.
  3. Callee creates answer.
  4. Answer returned to caller.
  5. ICE candidates exchanged.

Multiparty Conferencing

More complex signaling for multiple participants:

  • Mesh topology: Each peer signals with every other peer.
  • SFU topology: Peers signal only with the server.
  • MCU topology: Server handles all signaling.

Broadcasting

One-to-many streaming requires specialized signaling:

  • Broadcaster signals once.
  • Server distributes to viewers.
  • Viewers don't signal back to the broadcaster.

Signaling State Management

Track connection state through the signaling process:

const SignalingState = {
  IDLE: 'idle',
  CALLING: 'calling',
  ANSWERING: 'answering',
  CONNECTED: 'connected',
  FAILED: 'failed'
};

class SignalingManager {
  constructor() {
    this.state = SignalingState.IDLE;
  }

  async sendOffer(peer) {
    this.state = SignalingState.CALLING;
    try {
      const offer = await this.createOffer();
      await this.sendSignal('offer', peer, offer);
    } catch (error) {
      this.state = SignalingState.FAILED;
      throw error;
    }
  }

  async handleOffer(offer, peer) {
    this.state = SignalingState.ANSWERING;
    try {
      const answer = await this.createAnswer(offer);
      await this.sendSignal('answer', peer, answer);
      this.state = SignalingState.CONNECTED;
    } catch (error) {
      this.state = SignalingState.FAILED;
      throw error;
    }
  }
}

Error Handling

Robust signaling requires comprehensive error handling:

async function establishConnection(peer) {
  try {
    const offer = await createOffer();
    await sendOffer(peer, offer);

    const answer = await waitForAnswer(30000); // 30 second timeout
    await processAnswer(answer);

  } catch (error) {
    if (error.type === 'timeout') {
      console.error('Signaling timeout - peer not responding');
    } else if (error.type === 'network') {
      console.error('Network error during signaling');
    } else {
      console.error('Unexpected signaling error:', error);
    }

    // Cleanup and retry logic
    await cleanup();
    throw error;
  }
}

Signaling Best Practices

Follow these practices for reliable signaling:

  • Use Trickle ICE: Send ICE candidates as they're discovered rather than waiting for all candidates.
  • Implement Timeouts: Don't wait indefinitely for responses. Implement reasonable timeouts.
  • Handle Reconnection: Signaling servers can disconnect. Implement automatic reconnection with exponential backoff.
  • Validate Messages: Always validate signaling messages before processing.
  • Monitor Health: Track signaling server health and availability.
  • Use Binary When Possible: For high-throughput scenarios, binary protocols can be more efficient than JSON.

Conclusion

Signaling is the foundation of WebRTC connections. While the WebRTC API handles media and data transmission, signaling coordinates the initial connection setup. Understanding signaling patterns, implementing secure and reliable signaling servers, and following best practices ensures your WebRTC applications can establish connections quickly and reliably.

Check Your Own Servers

Signaling moves SDP and ICE candidates between peers, but those candidates are only useful if your STUN and TURN servers actually respond. Verify the relay and reflexive candidates your servers produce before you debug the signaling layer.

Open the Free ICE Server Tester →