Servidor de Señalización de WebRTC: Guía Completa con Ejemplos en Node.js

WebRTC permite que los navegadores envíen video, audio y datos directamente entre sí. Sin plugins, sin Flash, sin servidores intermediarios tocando tus flujos de medios. Pero aquí está la cosa que confunde a cada desarrollador la primera vez: dos navegadores no pueden simplemente encontrarse mágicamente en internet. Necesitan un intermediario. Ese intermediario es el servidor de señalización.

He construido servidores de señalización para plataformas de consultas de video, juegos multijugador en navegadores y herramientas de edición colaborativa. Cada vez, la capa de señalización es donde vive la confusión -- no porque sea difícil, sino porque WebRTC intencionalmente la deja sin especificar. La especificación te dice qué información necesita intercambiarse pero no dice nada sobre cómo intercambiarla. Eso es tanto un regalo como una maldición.

Esta guía cubre todo: qué hace realmente la señalización bajo el capó, el protocolo de oferta/respuesta con SDP, el intercambio de candidatos ICE y el traversal de NAT, y luego dos implementaciones listas para producción -- una con Node.js + Socket.io y otra usando Supabase Realtime. Veremos código real, no pseudocódigo.

Tabla de Contenidos

Servidor de Señalización de WebRTC: Guía Completa con Ejemplos en Node.js

¿Qué Hace Realmente un Servidor de Señalización?

Quitemos la jerga. Un servidor de señalización es un relé de mensajes. Eso es todo. Dos pares necesitan intercambiar algunos kilobytes de texto antes de poder conectarse directamente. El servidor de señalización lleva esos mensajes de un lado a otro.

Específicamente, un servidor de señalización maneja:

  1. Descubrimiento de pares -- El Usuario A necesita decirle al Usuario B "Quiero conectarme contigo." Alguien tiene que enrutar ese mensaje.
  2. Intercambio SDP -- Ambos pares generan blobs del Protocolo de Descripción de Sesión que describen sus capacidades de medios (códecs, encriptación, etc.). Estos necesitan ir de A a B y de regreso.
  3. Relé de candidatos ICE -- Cada par descubre posibles rutas de red (candidatos) y los envía al otro par a través del servidor de señalización.
  4. Ciclo de vida de la sesión -- Iniciando, renegociando y cerrando conexiones.

Aquí está lo que el servidor de señalización NO hace: nunca toca tus datos de audio o video. Una vez que los dos pares establecen una conexión directa, el trabajo del servidor de señalización es esencialmente terminado. Los medios fluyen de par a par.

Piénsalo como presentar a dos personas en una fiesta. Caminas hacia allá, dices "Oye Sarah, este es Mike, a ambos les gusta el alpinismo." Luego te vas. Sarah y Mike lo resuelven desde ahí. Eres el servidor de señalización en ese escenario.

Por Qué WebRTC No Estandariza la Señalización

Esta es una elección deliberada, no un descuido. Los autores de la especificación de WebRTC reconocieron que la mayoría de las aplicaciones ya tienen un canal de comunicación entre usuarios -- un sistema de chat, un servicio de emparejamiento, una plataforma de colaboración. Forzar un protocolo de señalización específico significaría que cada aplicación necesitaría implementar dos capas de comunicación en lugar de aprovechar lo que ya existe.

Una aplicación de citas ya tiene infraestructura de mensajería. Una plataforma de telesalud ya tiene sistemas de citas. Una plataforma de juegos ya tiene servidores de lobby. WebRTC te permite usar lo que ya tienes para pasar esos mensajes de SDP e ICE.

Literalmente podrías usar palomas mensajeras llevando USB si la latencia no importara. (Importa, pero el punto se mantiene.)

El Protocolo de Oferta/Respuesta y SDP

El intercambio de señalización central sigue un patrón llamado modelo de oferta/respuesta, tomado de SIP (Protocolo de Iniciación de Sesión). Aquí está el flujo:

  1. El Par A crea un RTCPeerConnection y llama a createOffer()
  2. El Par A establece la oferta como su descripción local vía setLocalDescription()
  3. El Par A envía la oferta (un blob SDP) al Par B a través del servidor de señalización
  4. El Par B recibe la oferta y la establece como su descripción remota vía setRemoteDescription()
  5. El Par B llama a createAnswer()
  6. El Par B establece la respuesta como su descripción local
  7. El Par B envía la respuesta de regreso a través del servidor de señalización
  8. El Par A recibe la respuesta y la establece como su descripción remota

Después de este intercambio, ambos pares conocen las capacidades de medios del otro. Pero aún no pueden conectarse -- necesitan información sobre rutas de red, que viene de ICE.

¿Qué hay Dentro de un Blob SDP?

SDP es un formato de texto que parece haber sido diseñado en 1996. Porque lo fue. Aquí hay un ejemplo abreviado:

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

Los bits importantes: lista códecs soportados (VP8, H264), huellas dactilares de encriptación, credenciales ICE y tipos de medios. Tu servidor de señalización no necesita analizar nada de esto. Solo pasa el blob como una cadena opaca.

La Oferta/Respuesta SDP en Código

Aquí está el JavaScript del lado del cliente para el par que ofrece:

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

// Agregar pistas de medios locales
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
stream.getTracks().forEach(track => pc.addTrack(track, stream));

// Crear y enviar oferta
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);

// Enviar al servidor de señalización
signalingServer.send({
  type: 'offer',
  sdp: pc.localDescription,
  targetPeerId: 'peer-b-id'
});

Y el par que responde:

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
  });
});

Candidatos ICE y Traversal de NAT

La negociación de SDP maneja qué medios enviar. ICE maneja cómo alcanzar al otro par en la red. Aquí es donde las cosas se ponen interesantes -- y donde la mayoría de las conexiones de WebRTC realmente fallan en el mundo real.

El Problema de NAT

La mayoría de dispositivos están detrás de un router ejecutando NAT (Traducción de Direcciones de Red). Tu laptop podría tener una IP local de 192.168.1.42, pero el mundo exterior ve la IP pública de tu router. Cuando dos pares están ambos detrás de NATs, ninguno sabe cómo alcanzar al otro directamente.

ICE (Establecimiento Interactivo de Conectividad) resuelve esto reuniendo múltiples rutas de candidatos e intentándolas todas:

Tipo de Candidato Fuente Tasa de Éxito Latencia
Host Interfaz de red local Solo funciona en la misma LAN Más baja
Server-reflexiva (srflx) Descubierta vía servidor STUN ~80-85% de conexiones Baja
Relay Asignada vía servidor TURN ~99%+ (respaldo) Más alta (retransmitida)

Goteo de Candidatos ICE

Aquí hay una optimización crítica: ICE goteo. En lugar de esperar a que todos los candidatos se reúnan antes de enviarlos, envías cada candidato al par remoto tan pronto como se descubre. Esto puede ahorrar segundos en el establecimiento de la conexión.

// Lado del llamador: enviar candidatos mientras llegan
pc.onicecandidate = (event) => {
  if (event.candidate) {
    signalingServer.send({
      type: 'ice-candidate',
      candidate: event.candidate,
      targetPeerId: remotePeerId
    });
  }
};

// Lado del receptor: añadir candidatos mientras llegan
signalingServer.on('ice-candidate', async (data) => {
  try {
    await pc.addIceCandidate(new RTCIceCandidate(data.candidate));
  } catch (err) {
    // Los candidatos pueden llegar antes de que se establezca la descripción remota
    // Almacenarlos en buffer y añadir después
    console.error('Failed to add ICE candidate:', err);
  }
});

Ese bloque catch indica un problema del mundo real: los candidatos ICE pueden llegar antes de que hayas llamado a setRemoteDescription(). Necesitas almacenarlos en buffer. Manejaremos esto correctamente en las implementaciones de producción abajo.

Almacenamiento en Buffer de Candidatos ICE

Esto es algo que casi todos los tutoriales omiten, y causa errores en producción. Aquí está el patrón:

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);
  }
});

// Después de establecer la descripción remota:
await pc.setRemoteDescription(remoteDesc);
remoteDescriptionSet = true;
for (const candidate of pendingCandidates) {
  await pc.addIceCandidate(new RTCIceCandidate(candidate));
}
pendingCandidates = [];

Servidor de Señalización de WebRTC: Guía Completa con Ejemplos en Node.js - arquitectura

Opciones de Transporte de Señalización Comparadas

Puedes usar básicamente cualquier canal de comunicación bidireccional para la señalización. Aquí está cómo se comparan las opciones comunes:

Transporte Pros Contras Mejor Para
WebSocket (crudo) Baja latencia, full-duplex, ligero Lógica de reconexión manual, sin salas Aplicaciones simples 1:1
Socket.io Reconexión automática, salas, respaldo a polling Tamaño más grande de librería (~45KB), no es un estándar La mayoría de aplicaciones web
Supabase Realtime Infraestructura gestionada, autenticación integrada, integración Postgres Bloqueo de proveedor, límites de tamaño de mensaje Aplicaciones ya en Supabase
Firebase Realtime DB Gestionado, buena documentación, soporte offline Bloqueo de proveedor, precios a escala Aplicaciones basadas en Firebase
HTTP polling Funciona en todas partes, simple de implementar Alta latencia, carga del servidor Entornos heredados
SIP sobre WebSocket Interoperabilidad con sistemas de telefonía Complejo, excesivo para la mayoría de aplicaciones web Integración VoIP

Para la mayoría de proyectos, Socket.io o un servicio realtime gestionado como Supabase es la opción correcta. Construyamos ambos.

Implementación 1: Node.js + Socket.io

Este es el enfoque más común y el que recomendaría para equipos que quieren control total sobre su infraestructura de señalización. Estamos construyendo un servidor de señalización que soporta salas con múltiples pares.

Configuración del Servidor

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']
  }
});

// Rastrear salas y sus participantes
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);

    // Notificar a pares existentes en la sala
    socket.to(roomId).emit('peer-joined', { peerId: socket.id });

    // Enviar al nuevo par una lista de pares existentes
    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', () => {
    // Limpiar salas
    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}`);
});

Integración del Lado del Cliente

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' },
    // Añadir servidor TURN para producción
  ]
};

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) => {
    // Adjuntar flujo remoto a un elemento de video
    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}`);
      // Implementar lógica de reconexión aquí
    }
  };

  // Almacenar la conexión y su estado
  peerConnections.set(remotePeerId, {
    pc,
    pendingCandidates,
    isRemoteDescSet,
    setRemoteDescDone() {
      this.isRemoteDescSet = true;
      this.pendingCandidates.forEach(c => pc.addIceCandidate(new RTCIceCandidate(c)));
      this.pendingCandidates = [];
    }
  });

  return pc;
}

// Unirse a una sala
socket.emit('join-room', 'my-room-id');

// Cuando un nuevo par se une, crear una oferta
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);
  }
});

Esto maneja múltiples pares en una sala, almacena candidatos ICE en buffer correctamente y limpia al desconectarse. Es el patrón que usamos cuando construimos características en tiempo real en aplicaciones Next.js.

Implementación 2: Supabase Realtime

Si ya estás usando Supabase (o quieres evitar ejecutar tu propio servidor WebSocket), los canales de Supabase Realtime funcionan maravillosamente para la señalización. Este enfoque usa la característica de Broadcast de Supabase -- no se necesitan escrituras en la base de datos.

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 } }
});

// Escuchar mensajes de señalización
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') {
      // Anunciar presencia
      await channel.send({
        type: 'broadcast',
        event: 'peer-joined',
        payload: { peerId: myPeerId }
      });
    }
  });

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

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

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

El enfoque de Supabase tiene una ventaja notable: obtienes rastreo de presencia gratis usando la característica de Presencia de Supabase. Puedes ver quién está en una sala sin código adicional. El compromiso es que los mensajes de broadcast son visibles para todos los suscriptores del canal -- estás filtrando por targetPeerId del lado del cliente, lo cual está bien para la señalización (no son datos sensibles; los medios reales están encriptados).

Para equipos que construyen en Supabase, esto elimina un servidor completo de tu infraestructura. Hemos usado este patrón cuando la colaboración en tiempo real era un requisito.

Consejos de Endurecimiento para Producción

Hacer que la señalización funcione en desarrollo es sencillo. Mantenerla confiable en producción es donde está el trabajo real.

1. Ordenamiento de Mensajes y Deduplicación

Los mensajes de WebSocket pueden llegar fuera de orden durante reconexiones. Añade números de secuencia a tus mensajes y maneja el reordenamiento en el cliente:

let messageSeq = 0;

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

2. Latidos del Corazón y Reconexión

Socket.io maneja la reconexión automáticamente, pero necesitas reunirte a salas después de reconectar:

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

3. Limitación de Tasa

El goteo de candidatos ICE puede generar docenas de mensajes en sucesión rápida. En un servidor ocupado, esto suma. Implementa limitación de tasa por socket:

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

const rateLimiter = new RateLimiterMemory({
  points: 50, // mensajes
  duration: 10, // por 10 segundos
});

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. Autenticación

Nunca ejecutes un servidor de señalización sin autenticación en producción. Con Socket.io, usa 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. Escalado Horizontal

Un único proceso de Node.js puede manejar miles de conexiones de señalización (la señalización es ligera -- solo mensajes de texto). Cuando necesites escalar más allá de un servidor, usa el @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));

Esto asegura que socket.to(peerId).emit() funciona en múltiples instancias de servidor.

Servidores TURN: Cuando las Conexiones Directas Fallan

STUN funciona para aproximadamente el 80-85% de conexiones. Para el 15-20% restante (NATs simétricos, cortafuegos restrictivos, redes corporativas), necesitas un servidor TURN para retransmitir tráfico de medios.

En 2026, tus opciones para TURN incluyen:

Proveedor Precios (aprox.) Notas
Cloudflare Calls TURN Nivel gratuito disponible, basado en uso Mejor valor para la mayoría de proyectos
Twilio TURN $0.40/GB Confiable, bien documentado
Metered.ca 500GB gratuitos/mes, luego $0.40/GB Popular para proyectos indie
coturn auto-alojado Solo costos del servidor Control total, carga operacional
Xirsys Desde $24.99/mes PoPs globales

Siempre incluye al menos un servidor TURN en tu configuración ICE. Decirle a los usuarios "funciona para la mayoría de personas" no es aceptable cuando un vicepresidente intenta demostrar tu producto en WiFi de hotel.

Si estás construyendo algo que necesita funcionar confiablemente en redes empresariales, contáctanos -- hemos ayudado a equipos a diseñar soluciones de WebRTC que manejan los casos extremos desagradables.

Preguntas Frecuentes

¿Qué es un servidor de señalización de WebRTC? Un servidor de señalización es un relé de mensajes que ayuda a dos pares de WebRTC a intercambiar la información que necesitan para establecer una conexión directa. Lleva ofertas y respuestas de SDP (que describen capacidades de medios) y candidatos ICE (que describen rutas de red). El servidor de señalización nunca toca datos de audio o video -- una vez que los pares se conectan, los medios fluyen directamente entre ellos.

¿Por qué WebRTC no incluye un protocolo de señalización estándar? La especificación de WebRTC intencionalmente deja la señalización sin especificar porque la mayoría de aplicaciones ya tienen un canal de comunicación entre usuarios. Una aplicación de chat, un servicio de emparejamiento, o una herramienta de colaboración puede reutilizar su infraestructura existente para la señalización. Esta flexibilidad significa que puedes usar WebSockets, HTTP, SIP, XMPP, o cualquier otro transporte que pueda llevar mensajes de texto.

¿Cuál es la diferencia entre una oferta SDP y una respuesta SDP? Una oferta SDP es creada por el par que inicia la conexión. Describe las capacidades de medios de ese par -- códecs soportados, métodos de encriptación y tipos de medios. La respuesta SDP es creada por el par que recibe y confirma qué capacidades soporta de la oferta. Juntas, negocian los parámetros compartidos que ambos pares usarán para la sesión de medios.

¿Qué son los candidatos ICE y por qué necesitan intercambiarse? Los candidatos ICE son rutas potenciales de red en las que se puede alcanzar a un par. Cada par descubre candidatos revisando sus interfaces de red local, consultando servidores STUN para direcciones públicas, y asignando direcciones de relé en servidores TURN. Estos candidatos deben enviarse al otro par a través del servidor de señalización para que ambos lados puedan intentar múltiples rutas y encontrar una que funcione a través de NATs y cortafuegos.

¿Puedo usar Supabase Realtime como servidor de señalización de WebRTC? Sí. La característica de Broadcast de Supabase Realtime funciona bien para la señalización porque proporciona entrega de mensajes de baja latencia entre clientes conectados sin requerir ninguna escritura en la base de datos. Creas un canal para cada sala, transmites mensajes de oferta/respuesta/ICE, y filtras por ID de par de destino en el lado del cliente. Es una sólida opción para proyectos ya usando Supabase que quieren evitar ejecutar un servidor de señalización separado.

¿Cuántas conexiones concurrentes puede manejar un servidor de señalización? Un único proceso de Node.js + Socket.io puede manejar típicamente 10,000–50,000 conexiones de señalización concurrentes, dependiendo de la frecuencia de mensajes y los recursos del servidor. El tráfico de señalización es ligero -- solo pequeños mensajes JSON. Para escala más grande, usa el adaptador de Redis con Socket.io para distribuir conexiones a través de múltiples instancias de servidor. El cuello de botella en WebRTC raramente es el servidor de señalización; generalmente es el ancho de banda de relé de TURN.

¿Necesito un servidor TURN en producción? Sí. Mientras que STUN maneja aproximadamente el 80-85% de conexiones, aproximadamente el 15-20% de usuarios están detrás de NATs simétricos o cortafuegos restrictivos que previenen conexiones directas. Sin un servidor TURN, esos usuarios simplemente no pueden conectarse. En entornos empresariales, la tasa de fallo sin TURN puede ser aún más alta. Servicios como Cloudflare Calls, Twilio y Metered.ca ofrecen TURN con niveles gratuitos.

¿Qué sucede si el servidor de señalización se cae durante una llamada activa? Una vez que una conexión de WebRTC se establece, no depende del servidor de señalización más. Una llamada existente continuará funcionando si el servidor de señalización se cae. Sin embargo, no se pueden establecer nuevas conexiones, y si la conexión existente necesita renegociarse (por ejemplo, añadiendo compartimiento de pantalla), eso fallará. Deberías diseñar tu servidor de señalización para alta disponibilidad usando clustering o servicios gestionados.