Saltar al contenido
Alvaro Acevedo
Go back

Construí un chat en tiempo real con SignalR, .NET 8 y React — cómo funciona por dentro

Editar artículo

Tener una pestaña abierta y ver aparecer mensajes nuevos sin recargar nada es de esos efectos que parecen magia hasta que te toca implementarlos. Detrás hay un WebSocket, un servidor que tiene que recordar quién está conectado, una base de datos que persiste todo, y un montón de detalles que solo aparecen cuando lo armas.

Construí ChatApp: un chat en tiempo real, estilo Discord, con canales públicos, panel de usuarios online y mensajes que aparecen al instante. Backend en ASP.NET Core 8 + SignalR, frontend en React 18 + Vite, persistencia en PostgreSQL y autenticación con JWT + BCrypt. El código está completo en github.com/varocode/ChatApp.

No es un experimento ni un POC: es una pieza completa que ejercita los patrones que aparecen en cualquier sistema con conexiones persistentes — broadcast a grupos, autenticación sobre WebSocket, manejo de estado en el cliente, y closures de React que muerden si no las cuidas.

Chat activo en ChatApp con dos usuarios conversando en el canal #general, con panel de usuarios online a la derecha

Qué quería resolver

La idea era usar SignalR de verdad, no solo invocarlo desde un Hello World. Quería un caso donde muchos usuarios se conectan al mismo tiempo, se suscriben a canales distintos y reciben actualizaciones solo del canal donde están. Eso obliga a entender Groups, autenticación sobre WebSocket, ciclo de vida de la conexión y sincronización con la base de datos.

Y quería el frontend en React, no en Razor Pages ni Blazor. La mayoría del contenido en español sobre SignalR mezcla todo dentro de Microsoft — válido, pero no es lo que quiero practicar. El cruce React + SignalR es donde aparecen los problemas más interesantes, especialmente cuando metes JWT y un cliente que cambia de canal.

La arquitectura real

El flujo de un mensaje, de punta a punta:

┌──────────────┐                    ┌────────────────────────────────┐
│   React      │   WebSocket / JWT  │      ASP.NET Core 8            │
│   (Vite)     │ ─────────────────▶ │                                │
│              │                    │  ┌─────────────────────────┐   │
│  signalR.    │                    │  │   ChatHub               │   │
│  HubConn.    │ ◀───────────────── │  │   - SendMessage         │   │
└──────────────┘   ReceiveMessage   │  │   - JoinRoom/LeaveRoom  │   │
                                    │  │   - OnConnected/Disc.   │   │
                                    │  └──────────┬──────────────┘   │
                                    │             │                  │
                                    │             ▼                  │
                                    │  ┌─────────────────────────┐   │
                                    │  │   MessageService (DI)   │   │
                                    │  └──────────┬──────────────┘   │
                                    │             ▼                  │
                                    │       ┌──────────┐             │
                                    │       │EF Core 8 │             │
                                    │       └────┬─────┘             │
                                    └────────────┼───────────────────┘

                                          ┌──────────────┐
                                          │  PostgreSQL  │
                                          └──────────────┘

Cuando un usuario manda un mensaje:

  1. El cliente llama connection.invoke('SendMessage', content, roomId) por WebSocket.
  2. El ChatHub recibe la llamada, lee el userId del JWT, persiste el mensaje vía MessageService.
  3. El Hub hace broadcast a todos los conectados al grupo room-{roomId} con Clients.Group(...).SendAsync("ReceiveMessage", message).
  4. Cada cliente suscrito a ese canal recibe el ReceiveMessage y lo agrega al estado local.

Lo importante: el remitente también recibe el evento, no se hace optimistic update. Una sola fuente de verdad: lo que el servidor confirma. Más simple, menos bugs de “el mensaje aparece dos veces”.

Estado vacío del canal #general en ChatApp, mostrando el sidebar de canales, la cabecera del canal y el mensaje de bienvenida

El núcleo: ChatHub.cs

Toda la magia del tiempo real está en este archivo. Es corto a propósito — el Hub es coordinación, no lógica de negocio:

[Authorize]
public class ChatHub(MessageService messageService) : Hub
{
    // Suscribe la conexión actual al grupo del canal.
    // Solo recibirá ReceiveMessage de mensajes en ese canal.
    public async Task JoinRoom(int roomId)
    {
        await Groups.AddToGroupAsync(Context.ConnectionId, $"room-{roomId}");
    }

    public async Task LeaveRoom(int roomId)
    {
        await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"room-{roomId}");
    }

    // Persiste el mensaje y lo emite a todos los del grupo.
    public async Task SendMessage(string content, int roomId)
    {
        var userId = int.Parse(Context.User!.FindFirstValue(ClaimTypes.NameIdentifier)!);
        var dto = new SendMessageDto(content, roomId);
        var message = await messageService.CreateAsync(dto, userId);
        await Clients.Group($"room-{roomId}").SendAsync("ReceiveMessage", message);
    }

    public override async Task OnConnectedAsync()
    {
        var username = Context.User!.FindFirstValue(ClaimTypes.Name)!;
        await Clients.All.SendAsync("UserOnline", username);
        await base.OnConnectedAsync();
    }

    public override async Task OnDisconnectedAsync(Exception? exception)
    {
        var username = Context.User!.FindFirstValue(ClaimTypes.Name)!;
        await Clients.All.SendAsync("UserOffline", username);
        await base.OnDisconnectedAsync(exception);
    }
}

Tres detalles que vale anotar:

JWT sobre WebSocket: el detalle que casi todos pasan por alto

Aquí está la parte que más tiempo me ahorró saber de antemano: un WebSocket del navegador no puede mandar headers customizados. Es una limitación de la spec. El típico Authorization: Bearer <token> que funciona en HTTP no funciona en el handshake del WebSocket.

La solución estándar de SignalR: pasar el token por query string y leerlo en el evento OnMessageReceived del JWT bearer.

.AddJwtBearer(opt =>
{
    opt.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuerSigningKey = true,
        IssuerSigningKey = new SymmetricSecurityKey(
            Encoding.UTF8.GetBytes(jwtSecret)),
        ValidateIssuer = true,  ValidIssuer = builder.Configuration["Jwt:Issuer"],
        ValidateAudience = true, ValidAudience = builder.Configuration["Jwt:Audience"],
    };

    opt.Events = new JwtBearerEvents
    {
        OnMessageReceived = ctx =>
        {
            // Solo para rutas del Hub: leer el token del query string
            // (el WebSocket del navegador no puede mandar headers).
            var token = ctx.Request.Query["access_token"];
            if (!string.IsNullOrEmpty(token) &&
                ctx.HttpContext.Request.Path.StartsWithSegments("/hubs"))
                ctx.Token = token;
            return Task.CompletedTask;
        }
    };
});

El check StartsWithSegments("/hubs") es importante: limita esta lectura solo a las rutas del Hub. Para el resto de los endpoints HTTP, el token sigue viniendo del header Authorization como debe ser. Sin esa restricción, expones tu API a tokens viajando en query string que se quedan en logs, en el Referer y en el historial del navegador.

Y en el cliente, igual de simple:

const conn = new signalR.HubConnectionBuilder()
  .withUrl(`/hubs/chat?access_token=${token}`)
  .withAutomaticReconnect()
  .build()

El resto de la API HTTP (rooms, messages, auth) usa el patrón clásico: un interceptor de Axios que mete Authorization: Bearer <token> en cada request, y un [Authorize] a nivel de controlador.

El problema que más me costó: el closure que mostraba mensajes en el canal equivocado

Funcionaba todo. JWT, broadcast, persistencia. Cambiaba de canal y los mensajes nuevos seguían apareciendo… en el canal anterior.

El callback de ReceiveMessage en React se registra una sola vez cuando se crea la conexión. En ese momento captura las variables de su scope — incluyendo activeRoom, que es estado de React. Cuando activeRoom cambia más tarde, el callback sigue viendo el valor viejo. Closure capturado, estado obsoleto.

La fix correcta no es leer el estado dentro del callback (no lo tiene). Es usar un ref que sí refleja el valor actual en cada render:

const activeRoomRef = useRef(null)
activeRoomRef.current = activeRoom   // se sincroniza en cada render

useEffect(() => {
  const conn = new signalR.HubConnectionBuilder()
    .withUrl(`/hubs/chat?access_token=${token}`)
    .withAutomaticReconnect()
    .build()

  conn.on('ReceiveMessage', msg => {
    // Leer el room actual desde el ref, no desde el closure.
    if (msg.roomId === activeRoomRef.current?.id) {
      setMessages(prev => [...prev, msg])
    }
  })

  conn.start()
  connectionRef.current = conn
  return () => conn.stop()
}, [])  // efecto sin dependencias: la conexión vive una sola vez

Dos cosas que vale internalizar:

El patrón ref + effect sin dependencias es la forma idiomática de cruzar el puente entre React (que vive en el modelo declarativo) y librerías imperativas como SignalR (que viven en el modelo de callbacks). Vale aprenderlo bien — vuelve a aparecer con WebSockets crudos, MQTT, MapLibre, cualquier cosa con suscripciones.

Detalle bonus: Npgsql y las fechas

Línea 1 de Program.cs:

AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);

Sin eso, DateTime.UtcNow mezclado con timestamp without time zone rompe en runtime con un mensaje confuso sobre Cannot write DateTime with Kind=UTC.... Es un cambio de comportamiento entre versiones de Npgsql que solo te encuentras cuando hace crash en producción. Lo dejé al inicio del archivo para que se vea — y para acordarme.

Lo que haría diferente

Cierre

El código está completo en github.com/varocode/ChatApp, MIT, con instrucciones de setup en el README. Si lo clonas y lo corres, en cinco minutos tienes el chat funcionando local con tu PostgreSQL.

Si te interesa este cruce — .NET + tiempo real + arquitectura backend en español — el siguiente post va a profundizar en el cliente: reconnect, presencia con Redis, y tests con TestServer. Suscríbete al RSS o sígueme en GitHub.


Editar artículo
Share this post on:

Previous Post
Construí un sistema de inventario fullstack con .NET 8, React y Postgres — las decisiones que no salen en los tutoriales
Next Post
Construí mi primer MCP server en C# y otra IA encontró un bug que mis tests ocultaron