How to Implement WebSocket Signaling with Node.js and Socket.IO for WebRTC
Exchange SDP offers, answers, and ICE candidates without race conditions. This implementation isolates signaling traffic, buffers asynchronous candidates, and guarantees strict message sequencing. Follow the exact configuration below for a production-ready setup.
Server-Side Socket.IO Setup & Namespace Isolation
Proper signaling requires isolating WebRTC traffic from other application events. Initialize a dedicated /signaling namespace to prevent cross-talk and ensure message ordering. This aligns with standard WebRTC Protocol Stack & Signaling Servers architecture guidelines. Configure pingTimeout and pingInterval to match typical ICE gathering windows.
Implementation Steps:
- Install dependencies:
npm install express socket.io - Initialize a dedicated
/signalingnamespace inserver.js - Attach room validation middleware before allowing joins
const { Server } = require('socket.io');
const http = require('http');
const server = http.createServer();
const io = new Server(server, {
cors: { origin: '*' },
pingTimeout: 60000,
pingInterval: 25000
});
const signaling = io.of('/signaling');
signaling.on('connection', (socket) => {
console.log(`[socket.io] namespace /signaling connected: ${socket.id}`);
socket.on('join', (roomId) => socket.join(roomId));
socket.on('sdp-offer', (data) => socket.to(data.roomId).emit('sdp-offer', data));
socket.on('ice-candidate', (data) => socket.to(data.roomId).emit('ice-candidate', data));
});
server.listen(3000);
Client-Side SDP & ICE Candidate Exchange Flow
The core of WebSocket Signaling Implementation revolves around strict message sequencing. Emit sdp-offer and sdp-answer before transmitting ICE candidates. Always buffer candidates until the remote description is set to prevent InvalidStateError during setRemoteDescription.
Implementation Steps:
- Create
RTCPeerConnectionwith STUN/TURN servers - Generate offer via
pc.createOffer()and set local description - Emit the offer payload to the
/signalingnamespace - Listen for
sdp-answer, apply it viasetRemoteDescription - Flush any buffered ICE candidates once
pc.signalingState === 'stable'
const config = { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] };
const pc = new RTCPeerConnection(config);
const socket = io('/signaling');
socket.emit('join', 'room-1');
pc.onicecandidate = ({ candidate }) => {
if (candidate) socket.emit('ice-candidate', { roomId: 'room-1', candidate });
};
async function startCall() {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
socket.emit('sdp-offer', { roomId: 'room-1', sdp: offer });
}
socket.on('sdp-answer', async ({ sdp }) => {
await pc.setRemoteDescription(new RTCSessionDescription(sdp));
flushBufferedCandidates();
});
Handling Race Conditions & Message Ordering
WebSocket guarantees in-order delivery, but asynchronous setRemoteDescription calls can still cause state mismatches. Implement a signaling state machine that queues incoming ICE candidates until the peer connection reaches have-remote-offer or stable. This prevents dropped candidates during the critical handshake phase.
Validation Steps:
- Simulate high-latency network using
tcor Chrome DevTools throttling - Trigger rapid ICE candidate emission before SDP exchange completes
- Verify queued candidates are applied immediately post-
setRemoteDescription
let candidateQueue = [];
socket.on('ice-candidate', ({ candidate }) => {
if (pc.remoteDescription) {
pc.addIceCandidate(new RTCIceCandidate(candidate));
} else {
candidateQueue.push(candidate);
console.log(`[Signaling] Queueing ICE candidate: state=${pc.signalingState}`);
}
});
function flushBufferedCandidates() {
while (candidateQueue.length) {
pc.addIceCandidate(new RTCIceCandidate(candidateQueue.shift()));
}
}
Common Mistakes
- Premature ICE emission: Sending candidates before the remote SDP is applied triggers
InvalidStateError: Failed to execute 'addIceCandidate' on 'RTCPeerConnection': No remoteDescription set. - Namespace collision: Using the default Socket.IO namespace for signaling causes event collision with chat or presence payloads.
- State mismanagement: Failing to handle
icegatheringstatechangeproperly results in premature connection closure beforegatheringcompletes. - Broadcast leaks: Omitting
socket.to(roomId)broadcasts to all clients, causing SDP leaks and connection failures.
FAQ
Why does Socket.IO drop WebRTC ICE candidates during high latency?
Socket.IO does not drop messages. The browser’s RTCPeerConnection rejects candidates if setRemoteDescription hasn’t been called. Implement a client-side buffer queue to hold candidates until the signaling state transitions to have-remote-offer.
Should I use Socket.IO or raw WebSockets for WebRTC signaling? Socket.IO is preferred for rapid prototyping due to built-in rooms, fallback transports, and automatic reconnection. Raw WebSockets reduce overhead and are better for production media servers where transport reliability is handled by the application layer.
How do I handle Socket.IO reconnections during an active WebRTC call?
Store the current localDescription and signalingState in memory. On reconnect, rejoin the room, emit the cached SDP if the state is have-local-offer or stable, and resume ICE candidate transmission. The peer connection remains intact across transport drops.