Saltar al contenido principal

SignalR & WebSockets — Real-Time en .NET 🟡

SignalR es la librería de .NET para comunicación en tiempo real. Abstrae WebSockets, Server-Sent Events y Long Polling en una sola API. Es la solución estándar para chats, notificaciones push, dashboards en vivo y colaboración en tiempo real.


¿Por qué no usar HTTP polling?

HTTP Polling — cliente pregunta cada N segundos:
Cliente → GET /api/notificaciones (t=0) → 0 notificaciones nuevas
Cliente → GET /api/notificaciones (t=5s) → 0 notificaciones nuevas
Cliente → GET /api/notificaciones (t=10s) → 1 notificación nueva

❌ Latencia = hasta N segundos
❌ Carga innecesaria en el servidor (99% de requests vacíos)
❌ No escala bien con muchos clientes

WebSocket — conexión bidireccional persistente:
Cliente ←→ Servidor (conexión abierta)
Servidor → Cliente (envío inmediato cuando hay datos)

✅ Latencia: ~1ms
✅ Solo viajan datos cuando hay eventos reales
✅ El servidor puede iniciar la comunicación

Transporte: qué elige SignalR automáticamente

SignalR prueba transportes en orden:
1. WebSockets → mejor opción, requiere HTTP/1.1 upgrade
2. Server-Sent Events → solo servidor → cliente, no requiere upgrade
3. Long Polling → fallback universal, mayor latencia

En la práctica: WebSockets funciona en todos los navegadores modernos
y en .NET. SignalR elige WebSockets automáticamente.

Setup básico

# Backend — ya incluido en ASP.NET Core, sin paquete extra
# Frontend
npm install @microsoft/signalr

Backend — Hub

// Hubs/ChatHub.cs
// Un Hub es la clase que maneja las conexiones y mensajes
public class ChatHub : Hub
{
// Método que los clientes pueden invocar desde el browser
public async Task EnviarMensaje(string usuario, string mensaje)
{
// Clients.All — envía a TODOS los clientes conectados
await Clients.All.SendAsync("RecibirMensaje", usuario, mensaje);
}

// Se ejecuta cuando un cliente se conecta
public override async Task OnConnectedAsync()
{
Console.WriteLine($"Cliente conectado: {Context.ConnectionId}");
await base.OnConnectedAsync();
}

// Se ejecuta cuando un cliente se desconecta
public override async Task OnDisconnectedAsync(Exception? exception)
{
Console.WriteLine($"Cliente desconectado: {Context.ConnectionId}");
await base.OnDisconnectedAsync(exception);
}
}

// Program.cs
builder.Services.AddSignalR();

app.MapHub<ChatHub>("/hubs/chat"); // URL del hub

Frontend — React con TypeScript

import { HubConnection, HubConnectionBuilder, LogLevel } from '@microsoft/signalr';
import { useEffect, useRef, useState } from 'react';

interface Mensaje {
usuario: string;
texto: string;
timestamp: Date;
}

export function Chat() {
const [mensajes, setMensajes] = useState<Mensaje[]>([]);
const [texto, setTexto] = useState('');
const connectionRef = useRef<HubConnection | null>(null);

useEffect(() => {
// Crear y configurar la conexión
const connection = new HubConnectionBuilder()
.withUrl('/hubs/chat')
.withAutomaticReconnect() // Reconectar automáticamente si cae
.configureLogging(LogLevel.Warning)
.build();

// Registrar handler para mensajes entrantes
// El nombre debe coincidir con el SendAsync del Hub
connection.on('RecibirMensaje', (usuario: string, texto: string) => {
setMensajes(prev => [...prev, { usuario, texto, timestamp: new Date() }]);
});

// Iniciar la conexión
connection.start()
.then(() => console.log('Conectado al hub'))
.catch(err => console.error('Error conectando:', err));

connectionRef.current = connection;

// Cleanup: cerrar conexión al desmontar el componente
return () => {
connection.stop();
};
}, []);

const enviarMensaje = async () => {
if (!connectionRef.current || !texto.trim()) return;

try {
// Invocar método del Hub
await connectionRef.current.invoke('EnviarMensaje', 'Usuario1', texto);
setTexto('');
} catch (err) {
console.error('Error enviando mensaje:', err);
}
};

return (
<div>
<ul>
{mensajes.map((m, i) => (
<li key={i}><strong>{m.usuario}:</strong> {m.texto}</li>
))}
</ul>
<input value={texto} onChange={e => setTexto(e.target.value)} />
<button onClick={enviarMensaje}>Enviar</button>
</div>
);
}

Grupos — enviar a subconjuntos de clientes

El patrón más útil: salas de chat, canales, suscripciones por entidad.

public class ChatHub : Hub
{
// Cliente se une a un grupo (sala)
public async Task UnirseASala(string sala)
{
await Groups.AddToGroupAsync(Context.ConnectionId, sala);

// Notificar a todos en la sala que alguien se unió
await Clients.Group(sala)
.SendAsync("UsuarioUnido", Context.User?.Identity?.Name ?? "Anónimo");
}

// Cliente abandona un grupo
public async Task AbandonarSala(string sala)
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, sala);
await Clients.Group(sala)
.SendAsync("UsuarioSalio", Context.User?.Identity?.Name ?? "Anónimo");
}

// Enviar mensaje solo a la sala
public async Task EnviarMensajeSala(string sala, string mensaje)
{
// Clients.Group — solo los de esa sala
await Clients.Group(sala).SendAsync("MensajeSala",
Context.User?.Identity?.Name, mensaje);
}

// Otros targets disponibles:
// Clients.All → todos los conectados
// Clients.Caller → solo quien invocó el método
// Clients.Others → todos excepto quien invocó
// Clients.Client(id) → conexión específica por ConnectionId
// Clients.Users(ids) → usuarios específicos (requiere autenticación)
// Clients.OthersInGroup(sala) → grupo excluyendo al caller
}

Autenticación en SignalR

// Program.cs — habilitar autenticación en hubs
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
// ⚠️ SignalR envía el token como query string, no en header
// Esto es necesario porque WebSockets no soporta headers personalizados
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;

if (!string.IsNullOrEmpty(accessToken) &&
path.StartsWithSegments("/hubs"))
{
context.Token = accessToken;
}
return Task.CompletedTask;
}
};
});

// En el Hub — acceder al usuario autenticado
public class ChatHub : Hub
{
[Authorize] // Requiere autenticación
public async Task EnviarMensaje(string mensaje)
{
var userId = Context.UserIdentifier; // ClaimTypes.NameIdentifier
var userName = Context.User?.Identity?.Name;

await Clients.All.SendAsync("RecibirMensaje", userName, mensaje);
}
}

// Frontend — enviar JWT en la conexión
const connection = new HubConnectionBuilder()
.withUrl('/hubs/chat', {
accessTokenFactory: () => localStorage.getItem('token') ?? ''
})
.withAutomaticReconnect()
.build();

Enviar desde fuera del Hub — IHubContext

El caso de uso más común en producción: un controller o servicio necesita notificar a los clientes cuando pasa algo en el backend.

// Inyectar IHubContext<T> en cualquier servicio
public class PedidoService
{
private readonly IHubContext<NotificacionesHub> _hubContext;
private readonly AppDbContext _db;

public PedidoService(
IHubContext<NotificacionesHub> hubContext,
AppDbContext db)
{
_hubContext = hubContext;
_db = db;
}

public async Task ActualizarEstadoPedidoAsync(int pedidoId, string nuevoEstado)
{
var pedido = await _db.Pedidos.FindAsync(pedidoId);
pedido!.Estado = nuevoEstado;
await _db.SaveChangesAsync();

// Notificar en tiempo real al cliente dueño del pedido
await _hubContext.Clients
.User(pedido.UsuarioId.ToString())
.SendAsync("EstadoPedidoCambiado", new
{
PedidoId = pedidoId,
Estado = nuevoEstado,
Timestamp = DateTime.UtcNow
});
}
}

// También desde un controller
[ApiController]
[Route("api/pedidos")]
public class PedidosController : ControllerBase
{
private readonly IHubContext<NotificacionesHub> _hub;

[HttpPost("{id}/cancelar")]
public async Task<IActionResult> Cancelar(int id)
{
// ... lógica de cancelación ...

// Notificar a todos los admins
await _hub.Clients.Group("admins")
.SendAsync("PedidoCancelado", id);

return NoContent();
}
}

Reconexión y manejo de errores

// Frontend — manejo robusto de reconexión
const connection = new HubConnectionBuilder()
.withUrl('/hubs/notificaciones', {
accessTokenFactory: () => getToken()
})
.withAutomaticReconnect({
// Tiempos de espera entre intentos: 0s, 2s, 10s, 30s
nextRetryDelayInMilliseconds: (ctx) => {
if (ctx.previousRetryCount < 4) {
return [0, 2000, 10000, 30000][ctx.previousRetryCount];
}
return 30000; // Después del 4to intento, cada 30s
}
})
.build();

// Estados de la conexión
connection.onreconnecting(error => {
console.warn('Reconectando...', error);
setStatus('reconectando');
});

connection.onreconnected(connectionId => {
console.log('Reconectado con ID:', connectionId);
setStatus('conectado');
// Re-suscribirse a grupos si es necesario
connection.invoke('UnirseASala', salaActual);
});

connection.onclose(error => {
console.error('Conexión cerrada:', error);
setStatus('desconectado');
// Mostrar UI para reconexión manual si es necesario
});

Scaled-out SignalR — múltiples instancias

En producción con múltiples instancias del servidor, las conexiones de un usuario pueden ir a instancias distintas. SignalR necesita un backplane para sincronizar:

// ✅ Azure SignalR Service — backplane gestionado (recomendado en producción)
builder.Services.AddSignalR()
.AddAzureSignalR(connectionString);
// Azure gestiona el backplane, la escala y la resiliencia

// ✅ Redis como backplane — alternativa self-hosted
builder.Services.AddSignalR()
.AddStackExchangeRedis(connectionString, options =>
{
options.Configuration.ChannelPrefix = RedisChannel.Literal("MiApp");
});
Sin backplane (❌ en múltiples instancias):
Usuario A conectado a Instancia 1
Servidor envía mensaje desde Instancia 2
→ Usuario A nunca lo recibe

Con backplane Redis (✅):
Instancia 2 publica mensaje en Redis
Instancia 1 suscrita a Redis recibe y reenvía a Usuario A

Cuándo usar SignalR vs otras opciones

NecesidadSolución
Chat, colaboración en tiempo real (bidireccional)SignalR (WebSockets)
Notificaciones push del servidor al clienteSignalR o Server-Sent Events
Dashboard con datos que se actualizanSignalR o polling con React Query
Streaming de datos grandesServer-Sent Events o gRPC streaming
Juego multijugador / latencia crítica < 50msWebSockets puro (sin SignalR)

Preguntas frecuentes de entrevista 🎯

1. ¿Qué es SignalR y qué problema resuelve?

SignalR es una librería de ASP.NET Core que abstrae la comunicación en tiempo real. Maneja automáticamente WebSockets (preferido), Server-Sent Events y Long Polling como fallbacks. Resuelve el problema de que HTTP es un protocolo request-response — si el servidor necesita notificar al cliente sin que este pregunte, necesitas algo como SignalR.

2. ¿Cuál es la diferencia entre WebSockets y SignalR?

WebSockets es el protocolo de bajo nivel (RFC 6455) para conexiones TCP persistentes y bidireccionales. SignalR es una abstracción encima que añade: selección automática de transporte, reconexión automática, grupos de clientes, autenticación integrada y RPC (invocar métodos por nombre). Para la mayoría de casos se usa SignalR; WebSockets puro solo para latencia crítica o control total.

3. ¿Cómo envías un mensaje desde un Controller a los clientes conectados?

Inyectando IHubContext<THub> en el controller o servicio. Es el patrón más común en producción: una acción en el sistema (pedido cambia estado, nueva alerta) dispara una notificación a los clientes relevantes sin que ellos tengan que preguntar.

4. ¿Cómo escala SignalR con múltiples instancias?

Con un backplane: Azure SignalR Service (gestionado, recomendado) o Redis. Sin backplane, dos instancias no comparten el estado de conexiones y los mensajes se pierden. Azure SignalR Service es la opción más simple en Azure — delega toda la gestión de conexiones al servicio.

5. ¿Por qué el token JWT se pasa como query string en SignalR y no en el header?

Porque WebSockets no soporta headers personalizados durante el handshake inicial en los navegadores. SignalR soluciona esto enviando el token como ?access_token=... en la URL. En el servidor se configura OnMessageReceived en JwtBearer para leer el token desde el query string cuando el path es un hub.