WebRTC lets browsers send video, audio, and data directly to each other. No plugins, no Flash, no intermediary servers touching your media streams. But here's the thing that trips up every developer the first time: two browsers can't just magically find each other on the internet. They need a matchmaker. That matchmaker is the signaling server.

I've built signaling servers for video consultation platforms, multiplayer browser games, and collaborative editing tools. Every single time, the signaling layer is where the confusion lives -- not because it's hard, but because WebRTC intentionally leaves it unspecified. The spec tells you what information needs to be exchanged but says nothing about how to exchange it. That's both a gift and a curse.

This guide covers everything: what signaling actually does under the hood, the offer/answer protocol with SDP, ICE candidate exchange and NAT traversal, and then two production-ready implementations -- one with Node.js + Socket.io and another using Supabase Realtime. We'll look at real code, not pseudocode.

Table of Contents

WebRTC Signaling Server: Complete Guide with Node.js Examples

What Does a Signaling Server Actually Do?

Let's strip away the jargon. A signaling server is a message relay. That's it. Two peers need to exchange a few kilobytes of text before they can connect directly. The signaling server carries those messages back and forth.

Specifically, a signaling server handles:

  1. Peer discovery -- User A needs to tell User B "I want to connect with you." Someone has to route that message.
  2. SDP exchange -- Both peers generate Session Description Protocol blobs that describe their media capabilities (codecs, encryption, etc.). These need to get from A to B and back.
  3. ICE candidate relay -- Each peer discovers potential network paths (candidates) and sends them to the other peer through the signaling server.
  4. Session lifecycle -- Starting, renegotiating, and tearing down connections.

Here's what the signaling server does NOT do: it never touches your audio or video data. Once the two peers establish a direct connection, the signaling server's job is essentially done. Media flows peer-to-peer.

Think of it like introducing two people at a party. You walk over, say "Hey Sarah, this is Mike, you both like climbing." Then you walk away. Sarah and Mike take it from there. You're the signaling server in that scenario.

Why WebRTC Doesn't Standardize Signaling

This is a deliberate design choice, not an oversight. The WebRTC spec authors recognized that most applications already have a communication channel between users -- a chat system, a matchmaking service, a collaboration platform. Forcing a specific signaling protocol would mean every app needs to implement two communication layers instead of piggybacking on what already exists.

A dating app already has messaging infrastructure. A telehealth platform already has appointment systems. A gaming platform already has lobby servers. WebRTC lets you use whatever you've already got to pass those SDP and ICE messages around.

You could literally use carrier pigeons carrying USB sticks if the latency didn't matter. (It does matter, but the point stands.)

The Offer/Answer Protocol and SDP

The core signaling exchange follows a pattern called the offer/answer model, borrowed from SIP (Session Initiation Protocol). Here's the flow:

  1. Peer A creates an RTCPeerConnection and calls createOffer()
  2. Peer A sets the offer as its local description via setLocalDescription()
  3. Peer A sends the offer (an SDP blob) to Peer B through the signaling server
  4. Peer B receives the offer and sets it as its remote description via setRemoteDescription()
  5. Peer B calls createAnswer()
  6. Peer B sets the answer as its local description
  7. Peer B sends the answer back through the signaling server
  8. Peer A receives the answer and sets it as its remote description

After this exchange, both peers know each other's media capabilities. But they still can't connect -- they need network path information, which comes from ICE.

What's Inside an SDP Blob?

SDP is a text format that looks like it was designed in 1996. Because it was. Here's a trimmed example:

v=0
o=- 4625943584070133116 2 IN IP4 127.0.0.1
s=-
t=0 0
m=video 9 UDP/TLS/RTP/SAVPF 96 97 98
c=IN IP4 0.0.0.0
a=rtcp:9 IN IP4 0.0.0.0
a=ice-ufrag:aR7B
a=ice-pwd:hN3Y2mDoS68sGLprHvBGNbKp
a=fingerprint:sha-256 D1:3C:A0:...
a=setup:actpass
a=mid:0
a=rtpmap:96 VP8/90000
a=rtpmap:97 H264/90000

The important bits: it lists supported codecs (VP8, H264), encryption fingerprints, ICE credentials, and media types. Your signaling server doesn't need to parse any of this. It just passes the blob along as an opaque string.

The SDP Offer/Answer in Code

Here's the client-side JavaScript for the offering peer:

const pc = new RTCPeerConnection({
  iceServers: [
    { urls: 'stun:stun.l.google.com:19302' },
    { urls: 'turn:your-turn-server.com:3478', username: 'user', credential: 'pass' }
  ]
});

// Add local media tracks
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
stream.getTracks().forEach(track => pc.addTrack(track, stream));

// Create and send offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);

// Send to signaling server
signalingServer.send({
  type: 'offer',
  sdp: pc.localDescription,
  targetPeerId: 'peer-b-id'
});

And the answering peer:

signalingServer.on('offer', async (data) => {
  await pc.setRemoteDescription(new RTCSessionDescription(data.sdp));
  const answer = await pc.createAnswer();
  await pc.setLocalDescription(answer);

  signalingServer.send({
    type: 'answer',
    sdp: pc.localDescription,
    targetPeerId: data.fromPeerId
  });
});

ICE Candidates and NAT Traversal

SDP negotiation handles what media to send. ICE handles how to reach the other peer on the network. This is where things get interesting -- and where most WebRTC connections actually fail in the real world.

The NAT Problem

Most devices sit behind a router running NAT (Network Address Translation). Your laptop might have a local IP of 192.168.1.42, but the outside world sees your router's public IP. When two peers both sit behind NATs, neither knows how to reach the other directly.

ICE (Interactive Connectivity Establishment) solves this by gathering multiple candidate paths and trying them all:

Candidate Type Source Success Rate Latency
Host Local network interface Only works on same LAN Lowest
Server-reflexive (srflx) Discovered via STUN server ~80-85% of connections Low
Relay Allocated via TURN server ~99%+ (fallback) Higher (relayed)

ICE Candidate Trickling

Here's a critical optimization: trickle ICE. Instead of waiting for all candidates to be gathered before sending them, you send each candidate to the remote peer as soon as it's discovered. This can shave seconds off connection establishment.

// Caller side: send candidates as they arrive
pc.onicecandidate = (event) => {
  if (event.candidate) {
    signalingServer.send({
      type: 'ice-candidate',
      candidate: event.candidate,
      targetPeerId: remotePeerId
    });
  }
};

// Receiver side: add candidates as they arrive
signalingServer.on('ice-candidate', async (data) => {
  try {
    await pc.addIceCandidate(new RTCIceCandidate(data.candidate));
  } catch (err) {
    // Candidates can arrive before remote description is set
    // Buffer them and add later
    console.error('Failed to add ICE candidate:', err);
  }
});

That catch block hints at a real-world gotcha: ICE candidates can arrive before you've called setRemoteDescription(). You need to buffer them. We'll handle this properly in the production implementations below.

ICE Candidate Buffering

This is something that almost every tutorial skips, and it causes bugs in production. Here's the pattern:

let pendingCandidates = [];
let remoteDescriptionSet = false;

signalingServer.on('ice-candidate', async (data) => {
  if (remoteDescriptionSet) {
    await pc.addIceCandidate(new RTCIceCandidate(data.candidate));
  } else {
    pendingCandidates.push(data.candidate);
  }
});

// After setting remote description:
await pc.setRemoteDescription(remoteDesc);
remoteDescriptionSet = true;
for (const candidate of pendingCandidates) {
  await pc.addIceCandidate(new RTCIceCandidate(candidate));
}
pendingCandidates = [];

WebRTC Signaling Server: Complete Guide with Node.js Examples - architecture

Signaling Transport Options Compared

You can use basically any bidirectional communication channel for signaling. Here's how the common options stack up:

Transport Pros Cons Best For
WebSocket (raw) Low latency, full-duplex, lightweight Manual reconnection logic, no rooms Simple 1:1 apps
Socket.io Auto-reconnection, rooms, fallback to polling Larger library size (~45KB), not a standard Most web apps
Supabase Realtime Managed infrastructure, built-in auth, Postgres integration Vendor lock-in, message size limits Apps already on Supabase
Firebase Realtime DB Managed, good docs, offline support Vendor lock-in, pricing at scale Firebase-based apps
HTTP polling Works everywhere, simple to implement High latency, server load Legacy environments
SIP over WebSocket Interop with telephony systems Complex, overkill for most web apps VoIP integration

For most projects, Socket.io or a managed realtime service like Supabase is the right call. Let's build both.

Implementation 1: Node.js + Socket.io

This is the most common approach and the one I'd recommend for teams that want full control over their signaling infrastructure. We're building a signaling server that supports rooms with multiple peers.

Server Setup

import { createServer } from 'http';
import { Server } from 'socket.io';

const httpServer = createServer();
const io = new Server(httpServer, {
  cors: {
    origin: process.env.CLIENT_ORIGIN || 'http://localhost:3000',
    methods: ['GET', 'POST']
  }
});

// Track rooms and their participants
const rooms = new Map();

io.on('connection', (socket) => {
  console.log(`Peer connected: ${socket.id}`);

  socket.on('join-room', (roomId) => {
    socket.join(roomId);

    if (!rooms.has(roomId)) {
      rooms.set(roomId, new Set());
    }
    rooms.get(roomId).add(socket.id);

    // Notify existing peers in the room
    socket.to(roomId).emit('peer-joined', { peerId: socket.id });

    // Send the new peer a list of existing peers
    const existingPeers = [...rooms.get(roomId)].filter(id => id !== socket.id);
    socket.emit('existing-peers', { peers: existingPeers });
  });

  socket.on('offer', ({ targetPeerId, sdp }) => {
    io.to(targetPeerId).emit('offer', {
      sdp,
      fromPeerId: socket.id
    });
  });

  socket.on('answer', ({ targetPeerId, sdp }) => {
    io.to(targetPeerId).emit('answer', {
      sdp,
      fromPeerId: socket.id
    });
  });

  socket.on('ice-candidate', ({ targetPeerId, candidate }) => {
    io.to(targetPeerId).emit('ice-candidate', {
      candidate,
      fromPeerId: socket.id
    });
  });

  socket.on('disconnect', () => {
    // Clean up rooms
    for (const [roomId, peers] of rooms.entries()) {
      if (peers.has(socket.id)) {
        peers.delete(socket.id);
        socket.to(roomId).emit('peer-left', { peerId: socket.id });
        if (peers.size === 0) {
          rooms.delete(roomId);
        }
      }
    }
  });
});

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

Client-Side Integration

import { io } from 'socket.io-client';

const socket = io('wss://your-signaling-server.com');
const peerConnections = new Map();

const ICE_SERVERS = {
  iceServers: [
    { urls: 'stun:stun.l.google.com:19302' },
    { urls: 'stun:stun1.l.google.com:19302' },
    // Add TURN server for production
  ]
};

function createPeerConnection(remotePeerId) {
  const pc = new RTCPeerConnection(ICE_SERVERS);
  let pendingCandidates = [];
  let isRemoteDescSet = false;

  pc.onicecandidate = (event) => {
    if (event.candidate) {
      socket.emit('ice-candidate', {
        targetPeerId: remotePeerId,
        candidate: event.candidate
      });
    }
  };

  pc.ontrack = (event) => {
    // Attach remote stream to a video element
    const remoteVideo = document.getElementById(`video-${remotePeerId}`);
    if (remoteVideo) {
      remoteVideo.srcObject = event.streams[0];
    }
  };

  pc.onconnectionstatechange = () => {
    if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') {
      console.warn(`Connection to ${remotePeerId} ${pc.connectionState}`);
      // Implement reconnection logic here
    }
  };

  // Store the connection and its state
  peerConnections.set(remotePeerId, {
    pc,
    pendingCandidates,
    isRemoteDescSet,
    setRemoteDescDone() {
      this.isRemoteDescSet = true;
      this.pendingCandidates.forEach(c => pc.addIceCandidate(new RTCIceCandidate(c)));
      this.pendingCandidates = [];
    }
  });

  return pc;
}

// Join a room
socket.emit('join-room', 'my-room-id');

// When a new peer joins, create an offer
socket.on('peer-joined', async ({ peerId }) => {
  const pc = createPeerConnection(peerId);
  localStream.getTracks().forEach(track => pc.addTrack(track, localStream));

  const offer = await pc.createOffer();
  await pc.setLocalDescription(offer);
  socket.emit('offer', { targetPeerId: peerId, sdp: pc.localDescription });
});

socket.on('offer', async ({ sdp, fromPeerId }) => {
  const pc = createPeerConnection(fromPeerId);
  localStream.getTracks().forEach(track => pc.addTrack(track, localStream));

  await pc.setRemoteDescription(new RTCSessionDescription(sdp));
  peerConnections.get(fromPeerId).setRemoteDescDone();

  const answer = await pc.createAnswer();
  await pc.setLocalDescription(answer);
  socket.emit('answer', { targetPeerId: fromPeerId, sdp: pc.localDescription });
});

socket.on('answer', async ({ sdp, fromPeerId }) => {
  const conn = peerConnections.get(fromPeerId);
  await conn.pc.setRemoteDescription(new RTCSessionDescription(sdp));
  conn.setRemoteDescDone();
});

socket.on('ice-candidate', ({ candidate, fromPeerId }) => {
  const conn = peerConnections.get(fromPeerId);
  if (conn.isRemoteDescSet) {
    conn.pc.addIceCandidate(new RTCIceCandidate(candidate));
  } else {
    conn.pendingCandidates.push(candidate);
  }
});

This handles multiple peers in a room, buffers ICE candidates properly, and cleans up on disconnect. It's the pattern we use at Social Animal when building real-time features into Next.js applications.

Implementation 2: Supabase Realtime

If you're already using Supabase (or want to avoid running your own WebSocket server), Supabase Realtime channels work beautifully for signaling. This approach uses Supabase's Broadcast feature -- no database writes needed.

import { createClient } from '@supabase/supabase-js';

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
);

const roomId = 'video-call-room-123';
const myPeerId = crypto.randomUUID();

const channel = supabase.channel(roomId, {
  config: { broadcast: { self: false } }
});

// Listen for signaling messages
channel
  .on('broadcast', { event: 'offer' }, ({ payload }) => {
    if (payload.targetPeerId === myPeerId) {
      handleOffer(payload);
    }
  })
  .on('broadcast', { event: 'answer' }, ({ payload }) => {
    if (payload.targetPeerId === myPeerId) {
      handleAnswer(payload);
    }
  })
  .on('broadcast', { event: 'ice-candidate' }, ({ payload }) => {
    if (payload.targetPeerId === myPeerId) {
      handleIceCandidate(payload);
    }
  })
  .on('broadcast', { event: 'peer-joined' }, ({ payload }) => {
    if (payload.peerId !== myPeerId) {
      initiateConnection(payload.peerId);
    }
  })
  .subscribe(async (status) => {
    if (status === 'SUBSCRIBED') {
      // Announce presence
      await channel.send({
        type: 'broadcast',
        event: 'peer-joined',
        payload: { peerId: myPeerId }
      });
    }
  });

// Send offer
async function sendOffer(targetPeerId, sdp) {
  await channel.send({
    type: 'broadcast',
    event: 'offer',
    payload: { sdp, fromPeerId: myPeerId, targetPeerId }
  });
}

// Send answer
async function sendAnswer(targetPeerId, sdp) {
  await channel.send({
    type: 'broadcast',
    event: 'answer',
    payload: { sdp, fromPeerId: myPeerId, targetPeerId }
  });
}

// Send ICE candidate
async function sendIceCandidate(targetPeerId, candidate) {
  await channel.send({
    type: 'broadcast',
    event: 'ice-candidate',
    payload: { candidate, fromPeerId: myPeerId, targetPeerId }
  });
}

The Supabase approach has a notable advantage: you get presence tracking for free using Supabase's Presence feature. You can see who's in a room without extra code. The trade-off is that broadcast messages are visible to all channel subscribers -- you're filtering by targetPeerId client-side, which is fine for signaling (it's not sensitive data; the actual media is encrypted).

For teams building on Supabase, this eliminates an entire server from your infrastructure. We've used this pattern in headless CMS projects where real-time collaboration was a requirement.

Production Hardening Tips

Getting signaling working in development is straightforward. Keeping it reliable in production is where the real work is.

1. Message Ordering and Deduplication

WebSocket messages can arrive out of order during reconnections. Add sequence numbers to your messages and handle reordering on the client:

let messageSeq = 0;

function sendSignalingMessage(type, payload) {
  socket.emit(type, { ...payload, seq: ++messageSeq, timestamp: Date.now() });
}

2. Heartbeats and Reconnection

Socket.io handles reconnection automatically, but you need to re-join rooms after reconnecting:

socket.on('connect', () => {
  if (currentRoom) {
    socket.emit('join-room', currentRoom);
  }
});

3. Rate Limiting

ICE candidate trickling can generate dozens of messages in quick succession. On a busy server, this adds up. Implement rate limiting per socket:

import { RateLimiterMemory } from 'rate-limiter-flexible';

const rateLimiter = new RateLimiterMemory({
  points: 50, // messages
  duration: 10, // per 10 seconds
});

io.on('connection', (socket) => {
  socket.use(async ([event, ...args], next) => {
    try {
      await rateLimiter.consume(socket.id);
      next();
    } catch {
      next(new Error('Rate limit exceeded'));
    }
  });
});

4. Authentication

Never run a signaling server without authentication in production. With Socket.io, use middleware:

io.use(async (socket, next) => {
  const token = socket.handshake.auth.token;
  try {
    const user = await verifyJWT(token);
    socket.data.userId = user.id;
    next();
  } catch {
    next(new Error('Authentication failed'));
  }
});

5. Horizontal Scaling

A single Node.js process can handle thousands of signaling connections (signaling is lightweight -- just text messages). When you need to scale beyond one server, use the @socket.io/redis-adapter:

import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';

const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);

io.adapter(createAdapter(pubClient, subClient));

This ensures that socket.to(peerId).emit() works across multiple server instances.

TURN Servers: When Direct Connections Fail

STUN works for roughly 80-85% of connections. For the remaining 15-20% (symmetric NATs, restrictive firewalls, corporate networks), you need a TURN server to relay media traffic.

In 2026, your options for TURN include:

Provider Pricing (approx.) Notes
Cloudflare Calls TURN Free tier available, usage-based Best value for most projects
Twilio TURN $0.40/GB Reliable, well-documented
Metered.ca Free 500GB/mo, then $0.40/GB Popular for indie projects
Self-hosted coturn Server costs only Full control, operational burden
Xirsys From $24.99/mo Global PoPs

Always include at least one TURN server in your ICE configuration. Telling users "it works for most people" isn't acceptable when a VP is trying to demo your product on a hotel WiFi.

If you're building something that needs to work reliably across enterprise networks, reach out to us -- we've helped teams architect WebRTC solutions that handle the gnarly edge cases.

FAQ

What is a WebRTC signaling server?

A signaling server is a message relay that helps two WebRTC peers exchange the information they need to establish a direct connection. It carries SDP offers and answers (which describe media capabilities) and ICE candidates (which describe network paths). The signaling server never touches audio or video data -- once the peers connect, media flows directly between them.

Why doesn't WebRTC include a standard signaling protocol?

The WebRTC specification intentionally leaves signaling unspecified because most applications already have a communication channel between users. A chat app, a matchmaking service, or a collaboration tool can reuse its existing infrastructure for signaling. This flexibility means you can use WebSockets, HTTP, SIP, XMPP, or any other transport that can carry text messages.

What is the difference between SDP offer and SDP answer?

An SDP offer is created by the peer initiating the connection. It describes that peer's media capabilities -- supported codecs, encryption methods, and media types. The SDP answer is created by the receiving peer and confirms which capabilities it supports from the offer. Together, they negotiate the shared parameters both peers will use for the media session.

What are ICE candidates and why do they need to be exchanged?

ICE candidates are potential network paths that a peer can be reached at. Each peer discovers candidates by checking its local network interfaces, querying STUN servers for public-facing addresses, and allocating relay addresses on TURN servers. These candidates must be sent to the other peer through the signaling server so both sides can try multiple paths and find one that works through NATs and firewalls.

Can I use Supabase Realtime as a WebRTC signaling server?

Yes. Supabase Realtime's Broadcast feature works well for signaling because it provides low-latency message delivery between connected clients without requiring any database writes. You create a channel for each room, broadcast offer/answer/ICE messages, and filter by target peer ID on the client side. It's a solid choice for projects already using Supabase that want to avoid running a separate signaling server.

How many concurrent connections can a signaling server handle?

A single Node.js + Socket.io process can typically handle 10,000–50,000 concurrent signaling connections, depending on message frequency and server resources. Signaling traffic is lightweight -- just small JSON messages. For larger scale, use Redis adapter with Socket.io to distribute connections across multiple server instances. The bottleneck in WebRTC is rarely the signaling server; it's usually the TURN relay bandwidth.

Do I need a TURN server in production?

Yes. While STUN handles about 80-85% of connections, roughly 15-20% of users sit behind symmetric NATs or restrictive firewalls that prevent direct connections. Without a TURN server, those users simply can't connect. In enterprise environments, the failure rate without TURN can be even higher. Services like Cloudflare Calls, Twilio, and Metered.ca offer TURN with free tiers.

What happens if the signaling server goes down during an active call?

Once a WebRTC connection is established, it doesn't depend on the signaling server anymore. An existing call will continue working if the signaling server crashes. However, new connections can't be established, and if the existing connection needs to be renegotiated (for example, adding screen sharing), that will fail. You should design your signaling server for high availability using clustering or managed services.