SignalR en ASP.NET Core para aplicaciones en tiempo real
- Posted on 15 junio, 2026
- /Under .NET, C#, Desarrollo, HTML
- /With 0 Comments
¿Qué problema queremos resolver?
Imagina que tienes una aplicación web y quieres que, cuando ocurra algo en el servidor, los usuarios lo vean al instante. Sin recargar la página. Sin pulsar ningún botón.
Suena sencillo, pero HTTP no funciona así. En el modelo clásico de la web, siempre es el navegador quien pregunta y el servidor quien responde. El servidor no puede tomar la iniciativa y enviar datos por su cuenta.
La solución habitual ha sido el polling: el navegador pregunta al servidor cada pocos segundos si hay algo nuevo. Funciona, pero es ineficiente. Si el intervalo es de 5 segundos, el usuario puede esperar hasta 5 segundos para ver un cambio que ocurrió hace uno.
SignalR resuelve esto estableciendo un canal de comunicación bidireccional y persistente entre el servidor y el cliente. Cuando hay algo nuevo, el servidor se lo envía directamente. Sin esperas, sin consultas innecesarias.
Qué vas a aprender
- Qué es SignalR y cómo funciona por dentro.
- Qué es un Hub y para qué sirve.
- Cómo enviar mensajes a todos los usuarios, a uno en concreto o a un grupo.
- Cómo gestionar la conexión, desconexión y reconexión automática desde el cliente JavaScript.
- Cómo integrar el cliente JavaScript de SignalR en una página HTML estática.
Cómo lo vamos a resolver
Construiremos una aplicación web completa llamada Centro de Notificaciones. Cuando varios usuarios abran la aplicación en sus navegadores y se conecten con un nombre, podrán:
- Enviar una notificación broadcast que ven todos los conectados al instante.
- Enviar un mensaje directo a un usuario concreto, sin que el resto lo vea.
- Ver en tiempo real cuántos usuarios están conectados y cuándo alguien entra o sale.
La aplicación no usa base de datos ni APIs externas. Todo el estado vive en memoria. Así el ejemplo es lo más limpio posible y podemos centrarnos en lo que importa: SignalR.
Paso 1 — Crear el proyecto
Abre una terminal y ejecuta:
dotnet new web -n SignalRNotifications
cd SignalRNotificationsEl template web crea la estructura mínima: un Program.cs y el archivo de proyecto .csproj. Nada más.
Una buena noticia: SignalR ya está incluido en ASP.NET Core. No necesitas instalar ningún paquete NuGet adicional. Viene con el framework desde la versión 3.0.
Paso 2 — Estructura de carpetas
Crea las siguientes carpetas dentro del proyecto:
SignalRNotifications/
├── Hubs/
├── Models/
└── wwwroot/
├── css/
└── js/Puedes crearlas desde la terminal:
mkdir Hubs Models wwwroot/css wwwroot/jsLa carpeta wwwroot es especial en ASP.NET Core: es donde van los archivos estáticos (HTML, CSS, JavaScript) que el servidor servirá directamente al navegador.
Paso 3 — El modelo de datos
Crea el archivo Models/Notification.cs:
namespace SignalRNotifications.Models;
public class Notification
{
public string Id { get; set; } = Guid.NewGuid().ToString("N")[..8];
public string From { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public NotificationType Type { get; set; }
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
}
public enum NotificationType
{
Broadcast,
Direct
}Este modelo representa una notificación. Contiene quién la envió, el mensaje, el tipo (broadcast o directo) y la hora. El Id es un identificador corto generado automáticamente con un fragmento del GUID.
Paso 4 — El Hub de SignalR
Este es el archivo más importante. Crea Hubs/NotificationHub.cs:
using Microsoft.AspNetCore.SignalR;
using SignalRNotifications.Models;
using System.Collections.Concurrent;
namespace SignalRNotifications.Hubs;
public class NotificationHub : Hub
{
private static readonly ConcurrentDictionary<string, string> _connectedUsers = new();
public override async Task OnDisconnectedAsync(Exception? exception)
{
if (_connectedUsers.TryRemove(Context.ConnectionId, out var username))
{
await Clients.Others.SendAsync("UserDisconnected", username);
await Clients.All.SendAsync("UpdateUserCount", _connectedUsers.Count);
await Clients.All.SendAsync("UpdateUserList",
_connectedUsers.Values.Distinct().ToList());
}
await base.OnDisconnectedAsync(exception);
}
public async Task Register(string username)
{
_connectedUsers[Context.ConnectionId] = username;
await Groups.AddToGroupAsync(Context.ConnectionId, username);
await Clients.Others.SendAsync("UserConnected", username);
await Clients.All.SendAsync("UpdateUserCount", _connectedUsers.Count);
await Clients.All.SendAsync("UpdateUserList",
_connectedUsers.Values.Distinct().ToList());
await Clients.Caller.SendAsync("Registered", username);
}
public async Task SendBroadcast(string message)
{
if (!_connectedUsers.TryGetValue(Context.ConnectionId, out var username))
return;
var notification = new Notification
{
From = username,
Message = message,
Type = NotificationType.Broadcast
};
await Clients.All.SendAsync("ReceiveNotification", notification);
}
public async Task SendDirect(string targetUsername, string message)
{
if (!_connectedUsers.TryGetValue(Context.ConnectionId, out var username))
return;
var notification = new Notification
{
From = username,
Message = message,
Type = NotificationType.Direct
};
await Clients.Group(targetUsername).SendAsync("ReceiveNotification", notification);
if (!username.Equals(targetUsername, StringComparison.OrdinalIgnoreCase))
await Clients.Caller.SendAsync("ReceiveNotification", notification);
}
}Qué hace cada parte:
El Hub hereda de la clase Hub de SignalR. Eso le da acceso a Clients, Groups, Context y los métodos de ciclo de vida.
El diccionario _connectedUsers es estático y thread-safe. Es estático porque SignalR crea una nueva instancia del Hub por cada llamada, así que sin static el estado se perdería entre una llamada y la siguiente.
Context.ConnectionId es el identificador único que SignalR asigna a cada conexión. Si el mismo usuario abre dos pestañas, tiene dos ConnectionId distintos.
Los grupos son la clave para los mensajes directos: al registrarse, cada usuario se añade a un grupo con su nombre. Cuando enviamos al grupo «Ana», llega a todas las conexiones de Ana, aunque tenga varias pestañas abiertas.
Paso 5 — Configurar Program.cs
Reemplaza el contenido de Program.cs con:
using System.Text.Json;
using System.Text.Json.Serialization;
using SignalRNotifications.Hubs;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSignalR()
.AddJsonProtocol(options =>
{
options.PayloadSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
options.PayloadSerializerOptions.Converters
.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
});
var app = builder.Build();
app.UseDefaultFiles();
app.UseStaticFiles();
app.MapHub<NotificationHub>("/hubs/notifications");
app.Run();Tres cosas importantes aquí:
AddSignalR() registra el servicio. Sin esta línea, el Hub no existe.
La configuración de AddJsonProtocol hace que las propiedades del modelo lleguen al cliente en camelCase y que el enum viaje como texto ("broadcast", "direct") en lugar de como número (0, 1).
MapHub publica el Hub en esa ruta. A partir de ahí la comunicación no viaja por HTTP convencional sino por WebSocket.
Paso 6 — La interfaz HTML
Crea wwwroot/index.html. Lo importante no es el aspecto visual sino los IDs que el JavaScript necesita: usernameInput, connectBtn, connectionStatus, notificationFeed, etc. El CSS puede cambiar completamente sin afectar al comportamiento.
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<title>Centro de Notificaciones</title>
<link rel="stylesheet" href="/css/site.css" />
</head>
<body>
<div id="login-panel">
<input type="text" id="usernameInput" placeholder="Tu nombre..." />
<button id="connectBtn">Conectar</button>
</div>
<div id="main-panel" class="hidden">
<span id="connectionStatus">Conectado</span>
<span id="userCount">0</span>
<strong id="currentUsername"></strong>
<textarea id="broadcastMessage"></textarea>
<button id="sendBroadcastBtn">Enviar a todos</button>
<select id="targetUser"></select>
<textarea id="directMessage"></textarea>
<button id="sendDirectBtn">Enviar directo</button>
<div id="notificationFeed"></div>
<button id="clearBtn">Limpiar</button>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/microsoft-signalr/8.0.7/signalr.min.js"></script>
<script src="/js/notifications.js"></script>
</body>
</html>Paso 7 — El cliente JavaScript
Crea wwwroot/js/notifications.js. Este archivo es el puente entre el navegador y el Hub:
// 1. Construir la conexión
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/notifications")
.withAutomaticReconnect([0, 2000, 5000, 10000])
.configureLogging(signalR.LogLevel.Information)
.build();
let currentUsername = '';
// 2. Escuchar mensajes del servidor
connection.on("Registered", (username) => {
currentUsername = username;
document.getElementById("currentUsername").textContent = username;
document.getElementById("login-panel").classList.add("hidden");
document.getElementById("main-panel").classList.remove("hidden");
});
connection.on("ReceiveNotification", (notification) => {
const feed = document.getElementById("notificationFeed");
const card = document.createElement("div");
card.textContent = `[${notification.type}] ${notification.from}: ${notification.message}`;
feed.insertBefore(card, feed.firstChild);
});
connection.on("UpdateUserCount", (count) => {
document.getElementById("userCount").textContent = count;
});
connection.on("UpdateUserList", (users) => {
const select = document.getElementById("targetUser");
select.innerHTML = '<option value="">Selecciona...</option>';
users.filter(u => u !== currentUsername).forEach(u => {
const opt = document.createElement("option");
opt.value = opt.textContent = u;
select.appendChild(opt);
});
});
// 3. Reconexión
connection.onreconnected(() => {
if (currentUsername)
connection.invoke("Register", currentUsername).catch(console.error);
});
// 4. Eventos UI → Hub
document.getElementById("connectBtn").addEventListener("click", async () => {
const username = document.getElementById("usernameInput").value.trim();
if (username) await connection.invoke("Register", username);
});
document.getElementById("sendBroadcastBtn").addEventListener("click", async () => {
const msg = document.getElementById("broadcastMessage").value.trim();
if (msg) {
await connection.invoke("SendBroadcast", msg);
document.getElementById("broadcastMessage").value = '';
}
});
document.getElementById("sendDirectBtn").addEventListener("click", async () => {
const target = document.getElementById("targetUser").value;
const msg = document.getElementById("directMessage").value.trim();
if (target && msg) {
await connection.invoke("SendDirect", target, msg);
document.getElementById("directMessage").value = '';
}
});
document.getElementById("clearBtn").addEventListener("click", () => {
document.getElementById("notificationFeed").innerHTML = '';
});
// 5. Arrancar
connection.start().catch(console.error);La línea más importante es connection.invoke("NombreDelMétodo", argumento): así llama el cliente a los métodos del Hub. Y connection.on("NombreDelEvento", handler) es cómo escucha los mensajes que el servidor envía.
Fíjate en withAutomaticReconnect([0, 2000, 5000, 10000]): son los milisegundos de espera entre cada intento de reconexión. Primero intenta de inmediato, luego a los 2 segundos, a los 5 y a los 10. Tras esos cuatro intentos fallidos, dispara onclose.
Paso 8 — Ejecutar y probar
Desde la carpeta del proyecto:
dotnet runLa consola mostrará la URL, normalmente http://localhost:5000. Ábrela en el navegador.
La prueba definitiva es abrir dos pestañas (o dos navegadores diferentes) en la misma URL:
- En la primera, conecta como Pedro.
- En la segunda, conecta como Ana.
- Ambas deberían mostrar 2 usuarios conectados.
- Desde Pedro, envía un broadcast: aparece en las dos pestañas al instante.
- Desde Pedro, selecciona a Ana y envía un mensaje directo: solo lo ve Ana.
Problemas que te vas a encontrar
El servidor arranca pero no conecta. Casi siempre es que la URL en withUrl("/hubs/notifications") del JavaScript no coincide con la de MapHub en Program.cs. Compara carácter a carácter.
El Hub «olvida» al usuario entre llamadas. SignalR crea una instancia nueva del Hub por cada invocación. Por eso el diccionario de usuarios es static. Si olvidas el static, cada llamada ve un diccionario vacío.
Tras reconectar, los mensajes directos dejan de llegar. Cuando SignalR reconecta, asigna un nuevo ConnectionId. El servidor no sabe quién eras antes. Hay que llamar a Register de nuevo desde el handler onreconnected del cliente.
El archivo .exe está bloqueado al compilar. Si el servidor sigue corriendo de una sesión anterior, bloquea el ejecutable. Ciérralo con taskkill /PID <número> /F o desde el Administrador de Tareas.
Funciona en local pero no tras un proxy en producción. Algunos proxies bloquean WebSocket. SignalR cae a Long Polling automáticamente, pero con más latencia. Para que nginx permita WebSocket hay que añadir las cabeceras Upgrade y Connection en la configuración del proxy.
Recomendaciones
No guardes estado en el Hub con campos de instancia. Usa un campo estático o, mejor aún en proyectos reales, inyecta un servicio singleton desde el contenedor de dependencias.
Escala horizontal con Azure SignalR Service o Redis Backplane. Si despliegas varias instancias del servidor, cada una tiene su propio diccionario en memoria. Azure SignalR Service externaliza el estado con un cambio de configuración mínimo.
Protege el Hub en producción. Añade el atributo [Authorize] al Hub para que solo usuarios autenticados puedan conectar. Y en lugar de recibir el nombre de usuario como parámetro, léelo de Context.User.Identity.Name.
Reduce el nivel de logging en producción. LogLevel.Information es útil para depurar. En producción cámbialo a Warning o None.
Para reflexionar y debatir
¿SignalR o WebSocket puro? SignalR es una abstracción sobre WebSocket. Añade reconexión automática, negociación de transporte y una API de grupos. Si tu stack es .NET, SignalR es la elección obvia. Si necesitas control total sobre el protocolo, WebSocket puro puede tener sentido.
¿SignalR o polling? El polling tiene su lugar: si las actualizaciones son muy poco frecuentes y la latencia no importa, es la solución más simple. SignalR brilla cuando necesitas latencia baja o el servidor toma la iniciativa de enviar datos.
¿Qué pasa si el servidor se reinicia? Con este ejemplo, todo el estado se pierde. Los usuarios tienen que volver a conectarse. En un sistema real necesitarías persistencia y un mecanismo para que los clientes descarguen el estado al reconectar.
¿Cómo escalarlo en la nube? La respuesta corta: Azure SignalR Service. La larga: cualquier solución que externalice el estado de las conexiones para que múltiples instancias del servidor puedan coordinarse. Redis Backplane es la alternativa open-source de referencia.
Autor: Pedro Alvarez Ruiz
Máster: Desarrollo Full Stack + Arquitecturas Cloud
Centro: Tajamar Tech
Año académico: 2025-2026
Recursos utilizados:
https://dotnet.microsoft.com/es-es/apps/aspnet/signalr