tests.ws

C# WebSocket with SignalR

csharp signalr aspnet websocket real-time

ASP.NET Core SignalR is a library that simplifies adding real-time communication to .NET applications. This .NET WebSocket library abstracts the underlying transport, preferring WebSocket when available and falling back to Server-Sent Events or long polling when it is not. If you need push-based updates like chat messages, live dashboards, collaborative editing, or notification feeds, SignalR gives you a high-level API on top of the ASP.NET WebSocket protocol so you can focus on application logic instead of connection management.

Project Setup

Start by creating a new ASP.NET Core Web API project targeting .NET 8:

dotnet new web -n RealtimeApp
cd RealtimeApp

SignalR ships as part of the Microsoft.AspNetCore.App shared framework, so there is no extra NuGet package required on the server side. For the JavaScript client you will install the npm package later. If you plan to use the .NET client (for example in a console app or Blazor), add the client package:

dotnet add package Microsoft.AspNetCore.SignalR.Client

Open Program.cs and register the SignalR services:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSignalR();

var app = builder.Build();

app.MapHub<ChatHub>("/chat");

app.Run();

That single MapHub<ChatHub>("/chat") call exposes a WebSocket endpoint at /chat that clients can connect to.

Creating a Hub

A Hub is the server-side component that clients call into and that pushes messages back to clients. Create a file called ChatHub.cs:

using Microsoft.AspNetCore.SignalR;

public class ChatHub : Hub
{
    public async Task SendMessage(string user, string message)
    {
        await Clients.All.SendAsync("ReceiveMessage", user, message);
    }

    public override async Task OnConnectedAsync()
    {
        Console.WriteLine($"Client connected: {Context.ConnectionId}");
        await base.OnConnectedAsync();
    }

    public override async Task OnDisconnectedAsync(Exception? exception)
    {
        Console.WriteLine($"Client disconnected: {Context.ConnectionId}");
        await base.OnDisconnectedAsync(exception);
    }
}

When a client invokes SendMessage, the hub broadcasts the message to every connected client through Clients.All.SendAsync. The OnConnectedAsync and OnDisconnectedAsync overrides let you run logic whenever a connection opens or closes. The Context.ConnectionId property uniquely identifies each connection.

JavaScript Client Connection

Install the SignalR JavaScript client in your frontend project:

npm install @microsoft/signalr

Then connect to the hub:

import * as signalR from "@microsoft/signalr";

const connection = new signalR.HubConnectionBuilder()
  .withUrl("https://localhost:5001/chat")
  .withAutomaticReconnect()
  .configureLogging(signalR.LogLevel.Information)
  .build();

connection.on("ReceiveMessage", (user, message) => {
  console.log(`${user}: ${message}`);
});

async function start() {
  try {
    await connection.start();
    console.log("Connected to SignalR hub");
  } catch (err) {
    console.error("Connection failed:", err);
    setTimeout(start, 5000);
  }
}

start();

The withAutomaticReconnect() call tells the client to attempt reconnection with increasing backoff intervals if the WebSocket connection drops. You can also pass a custom array of delay values like withAutomaticReconnect([0, 2000, 5000, 10000]) for more control.

To send a message from the client:

await connection.invoke("SendMessage", "Alice", "Hello from the browser!");

Sending Messages

SignalR gives you fine-grained control over who receives a message. Inside any hub method you have access to the Clients property:

// Send to every connected client
await Clients.All.SendAsync("ReceiveMessage", user, message);

// Send to the caller only
await Clients.Caller.SendAsync("ReceiveMessage", user, message);

// Send to everyone except the caller
await Clients.Others.SendAsync("ReceiveMessage", user, message);

// Send to a specific connection
await Clients.Client(connectionId).SendAsync("ReceiveMessage", user, message);

// Send to a specific user (requires authentication)
await Clients.User(userId).SendAsync("ReceiveMessage", user, message);

The Clients.User() method maps to a user identity. By default SignalR uses the ClaimTypes.NameIdentifier claim from the authenticated user. This means a single user can have multiple active connections (for example, a phone and a laptop), and Clients.User() will push to all of them.

Groups

Groups are one of SignalR’s most useful features. They let you organize connections into named channels without managing any state yourself. The server handles all the bookkeeping.

public class ChatHub : Hub
{
    public async Task JoinRoom(string roomName)
    {
        await Groups.AddToGroupAsync(Context.ConnectionId, roomName);
        await Clients.Group(roomName).SendAsync(
            "SystemMessage", $"{Context.ConnectionId} joined {roomName}");
    }

    public async Task LeaveRoom(string roomName)
    {
        await Groups.RemoveFromGroupAsync(Context.ConnectionId, roomName);
        await Clients.Group(roomName).SendAsync(
            "SystemMessage", $"{Context.ConnectionId} left {roomName}");
    }

    public async Task SendToRoom(string roomName, string user, string message)
    {
        await Clients.Group(roomName).SendAsync("ReceiveMessage", user, message);
    }
}

When a client disconnects, SignalR automatically removes it from all groups. You do not need to clean up manually. Groups are stored in memory by default, which works on a single server. When you scale to multiple servers you need a backplane like Redis, covered later in this guide.

You can also exclude specific connections when sending to a group:

await Clients.GroupExcept(roomName, new[] { Context.ConnectionId })
    .SendAsync("ReceiveMessage", user, message);

Strongly Typed Hubs

String-based method names like "ReceiveMessage" are error-prone. A typo on either side will silently fail. Strongly typed hubs fix this by defining a client interface:

public interface IChatClient
{
    Task ReceiveMessage(string user, string message);
    Task SystemMessage(string text);
}

public class ChatHub : Hub<IChatClient>
{
    public async Task SendMessage(string user, string message)
    {
        await Clients.All.ReceiveMessage(user, message);
    }

    public async Task JoinRoom(string roomName)
    {
        await Groups.AddToGroupAsync(Context.ConnectionId, roomName);
        await Clients.Group(roomName).SystemMessage(
            $"{Context.ConnectionId} joined {roomName}");
    }
}

Now the compiler checks every client call at build time. If you rename a method in the interface and forget to update the hub, the build fails. This also improves IDE autocompletion. The JavaScript client still uses string names on its side, but the server code is now type-safe.

Authentication

JWT Bearer Tokens

For API-style authentication, configure JWT bearer tokens. SignalR sends the token as a query string parameter because the WebSocket handshake does not support custom headers in browser clients.

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifecycleKey = true,
            ValidIssuer = "https://yourapp.com",
            ValidAudience = "https://yourapp.com",
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes("your-signing-key-at-least-32-chars!"))
        };

        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("/chat"))
                {
                    context.Token = accessToken;
                }

                return Task.CompletedTask;
            }
        };
    });

Then protect the hub:

[Authorize]
public class ChatHub : Hub<IChatClient>
{
    // Hub methods here
}

On the JavaScript client, pass the token when building the connection:

const connection = new signalR.HubConnectionBuilder()
  .withUrl("https://localhost:5001/chat", {
    accessTokenFactory: () => localStorage.getItem("jwt_token"),
  })
  .build();

If your app already uses cookie authentication (common in server-rendered apps), SignalR picks it up automatically. The browser sends cookies with the WebSocket handshake request. Just make sure you have app.UseAuthentication() and app.UseAuthorization() in your middleware pipeline before app.MapHub<ChatHub>("/chat").

Inside the hub, access the authenticated user through Context.User:

public async Task SendMessage(string message)
{
    var userName = Context.User?.Identity?.Name ?? "Anonymous";
    await Clients.All.ReceiveMessage(userName, message);
}

Error Handling and Logging

Unhandled exceptions in hub methods are sent back to the calling client as a generic error by default. In development you may want to see detailed errors:

builder.Services.AddSignalR(options =>
{
    if (builder.Environment.IsDevelopment())
    {
        options.EnableDetailedErrors = true;
    }
});

For controlled error responses, throw HubException:

public async Task SendMessage(string user, string message)
{
    if (string.IsNullOrWhiteSpace(message))
    {
        throw new HubException("Message cannot be empty.");
    }

    await Clients.All.ReceiveMessage(user, message);
}

HubException messages are forwarded to the client. Other exception types are replaced with a generic error for security reasons.

Add structured logging to track connection lifecycle events:

public class ChatHub : Hub<IChatClient>
{
    private readonly ILogger<ChatHub> _logger;

    public ChatHub(ILogger<ChatHub> logger)
    {
        _logger = logger;
    }

    public override async Task OnConnectedAsync()
    {
        _logger.LogInformation("Client {ConnectionId} connected from {RemoteIp}",
            Context.ConnectionId,
            Context.GetHttpContext()?.Connection.RemoteIpAddress);
        await base.OnConnectedAsync();
    }

    public async Task SendMessage(string user, string message)
    {
        _logger.LogDebug("Message from {User}: {Message}", user, message);
        await Clients.All.ReceiveMessage(user, message);
    }
}

Scaling with Redis Backplane

A single-server SignalR deployment keeps all connection state in memory. When you add a second server behind a load balancer, a message sent on server A will not reach clients connected to server B. The solution is a backplane that relays messages between servers. Redis is the most common choice.

Install the Redis backplane package:

dotnet add package Microsoft.AspNetCore.SignalR.StackExchangeRedis

Configure it in Program.cs:

builder.Services.AddSignalR()
    .AddStackExchangeRedis("localhost:6379", options =>
    {
        options.Configuration.ChannelPrefix =
            RedisChannel.Literal("RealtimeApp");
    });

That is the only code change needed. SignalR will now publish messages to Redis, and every server subscribed to the same Redis instance will receive and forward them to their local clients. For production, point to a managed Redis service (Azure Cache for Redis, AWS ElastiCache, etc.) and enable TLS.

Keep in mind that the Redis backplane adds a small amount of latency to each message because every broadcast must pass through Redis pub/sub. For most applications this is negligible. For more on scaling WebSocket connections, see WebSocket Scalability.

Production Tips

Keep-Alive and Timeouts

SignalR sends periodic ping frames to detect dead connections. You can tune the intervals:

builder.Services.AddSignalR(options =>
{
    options.KeepAliveInterval = TimeSpan.FromSeconds(15);
    options.ClientTimeoutInterval = TimeSpan.FromSeconds(30);
    options.HandshakeTimeout = TimeSpan.FromSeconds(15);
});

KeepAliveInterval controls how often the server pings the client. ClientTimeoutInterval is how long the server waits for any message from the client before closing the connection. Set the client timeout to at least double the keep-alive interval.

Reconnection Strategy

On the client side, always enable automatic reconnection and handle the events:

connection.onreconnecting((error) => {
  console.warn("Connection lost. Attempting to reconnect...", error);
});

connection.onreconnected((connectionId) => {
  console.log("Reconnected with ID:", connectionId);
  // Re-join groups here since the new connection is not in any groups
});

connection.onclose((error) => {
  console.error("Connection closed permanently:", error);
});

After a reconnect, the client gets a new ConnectionId. Any group memberships from the previous connection are gone. You need to re-join groups in the onreconnected callback. For more about the WebSocket protocol, see What Is WebSocket.

Message Size Limits

By default SignalR limits incoming messages to 32 KB. For applications that send larger payloads (file transfers, images), increase the limit:

builder.Services.AddSignalR(options =>
{
    options.MaximumReceiveMessageSize = 256 * 1024; // 256 KB
});

Be cautious with large limits. A misbehaving client could exhaust server memory. For very large payloads, consider uploading through a regular HTTP endpoint and sending only a reference through the hub.

Running Behind a Reverse Proxy

If you run behind Nginx or another reverse proxy, configure it to support WebSocket upgrades. For Nginx:

location /chat {
    proxy_pass https://localhost:5001;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_cache_bypass $http_upgrade;
}

Without these headers the WebSocket upgrade handshake will fail and SignalR will fall back to long polling, which is significantly less efficient.

Alternatives to SignalR

SignalR is the right choice for most .NET real-time scenarios, but there are situations where you might want something different.

Raw WebSocket Middleware

ASP.NET Core supports WebSocket directly without SignalR. This gives you full control over the protocol but requires you to handle serialization, connection tracking, and reconnection yourself.

app.UseWebSockets();

app.Map("/ws", async context =>
{
    if (context.WebSockets.IsWebSocketRequest)
    {
        var ws = await context.WebSockets.AcceptWebSocketAsync();
        var buffer = new byte[1024 * 4];

        while (ws.State == WebSocketState.Open)
        {
            var result = await ws.ReceiveAsync(buffer, CancellationToken.None);
            if (result.MessageType == WebSocketMessageType.Close)
            {
                await ws.CloseAsync(
                    WebSocketCloseStatus.NormalClosure, "Closing", CancellationToken.None);
            }
            else
            {
                await ws.SendAsync(
                    buffer.AsMemory(0, result.Count),
                    result.MessageType,
                    result.EndOfMessage,
                    CancellationToken.None);
            }
        }
    }
    else
    {
        context.Response.StatusCode = 400;
    }
});

This approach makes sense when you need a lightweight echo server, a proxy, or tight control over the binary protocol. For anything with groups, authentication, or fan-out messaging, SignalR saves considerable effort.

Fleck

Fleck is a standalone C# WebSocket server that does not depend on ASP.NET Core. It is useful for embedding a WebSocket server in a console application, Windows service, or game server where you do not want the ASP.NET Core dependency. However, it does not provide features like groups, automatic reconnection, or backplane support.

When to Use Raw WebSocket vs. SignalR

Use raw WebSocket when you need binary protocols, when you are building a proxy, or when message overhead must be minimal. Use SignalR when you want built-in reconnection, group management, multiple transport fallbacks, and integration with the ASP.NET Core authentication pipeline. For more background on the WebSocket protocol itself, see What Is WebSocket?.

FAQ

Does SignalR always use WebSocket?

SignalR prefers WebSocket but will fall back to Server-Sent Events or long polling if WebSocket is not available. This can happen when a proxy strips the Upgrade header or the client environment does not support WebSocket. You can force a specific transport by passing HttpTransportType.WebSockets in the hub options, which will cause the connection to fail rather than fall back if WebSocket is unavailable.

How many concurrent connections can a single server handle?

It depends on your hardware and message throughput, but a typical ASP.NET Core server can handle tens of thousands of idle WebSocket connections. Each connection consumes a small amount of memory. The bottleneck is usually CPU for message serialization and network bandwidth for fan-out. Profile your specific workload with tools like dotnet-counters and load test with a tool like k6 before setting capacity targets.

Can I call hub methods from outside the hub, like from a controller or background service?

Yes. Inject IHubContext<ChatHub> (or IHubContext<ChatHub, IChatClient> for strongly typed hubs) into any service or controller:

public class NotificationService
{
    private readonly IHubContext<ChatHub, IChatClient> _hubContext;

    public NotificationService(IHubContext<ChatHub, IChatClient> hubContext)
    {
        _hubContext = hubContext;
    }

    public async Task NotifyAll(string message)
    {
        await _hubContext.Clients.All.SystemMessage(message);
    }
}

This is the recommended way to push real-time updates from background jobs, message queue consumers, or HTTP endpoints.

How do I handle user presence (online/offline tracking)?

SignalR does not include a built-in presence system, but you can build one using OnConnectedAsync and OnDisconnectedAsync. Store a mapping of user IDs to connection IDs in a concurrent dictionary (single server) or in Redis (multi-server). When a user’s last connection disconnects, mark them offline. Be aware that OnDisconnectedAsync may not fire immediately if the client drops without a clean close, so combine it with the client timeout for accurate tracking.