Real-Time Communication in .NET with SignalR
Friday, 22 August 2025
When building modern web applications, sometimes you need more than the standard request/response model that HTTP offers. Many user experiences today demand a sense of immediacy, where changes are reflected instantly without the user refreshing the page. Think about live chat apps, collaborative tools, online gaming dashboards, or real-time notifications. These all require updates to be pushed to users as soon as they happen, rather than waiting for a request from the client.
This is where SignalR comes in. SignalR is Microsoft’s real-time communication library for .NET, designed to make adding real-time features to your apps straightforward. It manages the underlying connections for you, automatically choosing the most efficient transport method available. By handling the complexity of persistent connections, SignalR enables developers to focus on application logic rather than the mechanics of delivering data instantly to users.
What is SignalR?❓
SignalR is an abstraction over real-time communication protocols like WebSockets, Server-Sent Events, and Long Polling. It automatically detects the best available option for the client and server, ensuring maximum compatibility. This means your app can still function in real-time even if certain browsers, networks, or environments can’t use WebSockets.
From a development perspective, SignalR introduces the concept of Hubs. These are central endpoints that handle communication between clients and servers. This allows clients to call server methods directly and the server to invoke client-side functions, enabling truly bidirectional communication.
Key Features
- Real-time messaging without manually handling connection code.
- Automatic reconnection support.
- Built-in scaling support for multi-server deployments.
- Supports server-to-client and client-to-server calls.
- Works seamlessly with ASP.NET Core and Blazor.
When to Use SignalR 📌
SignalR shines in scenarios where low-latency updates, continuous feedback, and live collaboration are central to the user experience. This includes applications like chat systems, live dashboards, collaborative editing tools, multiplayer games, and instant notifications. In these cases, having data pushed to the client in real time creates a smoother and more engaging experience while reducing the need for complex polling mechanisms.
Great fits:
- Live chat and customer support widgets.
- Real-time dashboards (trading, IoT telemetry, sports scores).
- Collaborative editors (documents, whiteboards, pair-programming tools).
- Multiplayer lobby/leaderboards; auction countdowns; live Q&A.
Probably not necessary:
- Content sites or CRUD admin panels where data changes infrequently.
- Workloads where eventual consistency is fine and seconds of delay are acceptable.
- Places where SSE or periodic polling is simpler and sufficient.
However, it’s important to understand that SignalR is a specialist technology best used when the situation truly calls for it. For content-driven sites, traditional CRUD apps, or systems where data changes infrequently, the additional complexity and infrastructure overhead of maintaining persistent connections may not be justified. In such cases, periodic polling, scheduled background refreshes, or standard HTTP APIs might be a better fit, simpler to run and easier to scale when low latency isn’t a requirement.
How SignalR Works 🔍
SignalR is built around Hubs, which are high-level endpoints for client-server communication. When a client connects, SignalR establishes a persistent connection, preferably using WebSockets if supported. If WebSockets aren’t available, it falls back to Server-Sent Events or Long Polling.
This means developers can focus on defining methods that clients and servers can call on each other, without worrying about the transport mechanism. SignalR takes care of connection management, reconnections, and message routing.
Example server hub:
public class ChatHub : Hub
{
public async Task SendMessage(string user, string message)
{
await Clients.All.SendAsync("ReceiveMessage", user, message);
}
}
Example JavaScript client:
const connection = new signalR.HubConnectionBuilder()
.withUrl("/chatHub")
.build();
connection.on("ReceiveMessage", (user, message) => {
console.log(`\${user}: \${message}`);
});
await connection.start();
await connection.invoke("SendMessage", "Chris", "Hello World");
SignalR Beyond .NET Clients 🌐
One of the great things about SignalR is that while the server host is built on .NET, the clients can be written in many different technologies. This makes SignalR a versatile choice for real-time communication across a range of platforms and languages.
For example, you can connect to a SignalR hub from a JavaScript or TypeScript front-end, whether that’s a vanilla JS app or something built with frameworks like React, Angular, or Vue.js. Mobile developers can use React Native or Flutter clients to communicate with a SignalR hub. Even desktop applications built with Electron, Java, or Python can consume SignalR messages using the appropriate client libraries or WebSocket connections.
This flexibility means you’re not tied to a single tech stack on the client side. You could have a .NET-based API broadcasting to a web dashboard built in Vue.js, a mobile app in React Native, and an IoT device client running on Node.js, all receiving the same real-time updates from SignalR.
Implementing SignalR in the Aspire Chat App 💬
API Changes
In the Aspire Chat App, SignalR is now the core of the real-time messaging system. On the API side, we enabled SignalR by creating the GroupChatHub class and configuring it in the API's startup.
public class GroupChatHub : Hub
{
public async Task JoinGroup(string groupId)
{
await Groups.AddToGroupAsync(Context.ConnectionId, groupId);
}
public async Task LeaveGroup(string groupId)
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupId);
}
}
We then registered SignalR services and mapped the hub in Program.cs:
// Add SignalR
builder.Services.AddSignalR();
//.... Rest of the code
// Map SignalR hubs
app.MapHub<AspireChat.Api.Hubs.GroupChatHub>("/hubs/groupchat");
We then update our Chat Send endpoint to notify any connected users of the new chats as they arrive. To do this we inject our IHubContext<GroupChatHub> hubContext in the constructor so we can use after we save the message to our database
await hubContext.Clients.Group(req.GroupId.ToString())
.SendAsync("ReceiveMessage", dto, cancellationToken: ct);
Blazor Changes
On the Blazor side, we added a ChatHubService to manage our connection to Hub. This is setup in our Program.cs in a similar way to the Api.
// SignalR chat hub service
builder.Services.AddScoped<IChatHubService, ChatHubService>();
And this is what the ChatHubService looks like. It takes our AuthProvider so we can make sure our request to the Hub are secure. It also provides our Connect and Disconnect logic that we'll use on the Chat page in a moment. It looks more complicated than it is, but all it's doing is ensuring we can connect to our hub on the Api and properly authenticate. There is some logic to make sure the service discovery works with Aspire injected urls, etc.
public interface IChatHubService : IAsyncDisposable
{
Task ConnectAsync(int groupId, Func<GetAll.Dto, Task> onMessage, CancellationToken cancellationToken = default);
Task DisconnectAsync(int groupId, CancellationToken cancellationToken = default);
}
public class ChatHubService(AuthenticationStateProvider authProvider, ILogger<ChatHubService> logger, IHttpMessageHandlerFactory httpMessageHandlerFactory) : IChatHubService
{
private readonly AuthProvider _authProvider = (AuthProvider)authProvider;
private HubConnection? _connection;
private int? _joinedGroupId;
public async Task ConnectAsync(int groupId, Func<GetAll.Dto, Task> onMessage, CancellationToken cancellationToken = default)
{
if (_connection is not null && _joinedGroupId == groupId && _connection.State == HubConnectionState.Connected)
{
return;
}
// Use Aspire service discovery base address for the API
var baseAddress = new Uri("https://api");
var hubUrl = new Uri(baseAddress, "/hubs/groupchat");
// Prepare access token from AuthProvider (JWT stored in session)
var authHeader = await _authProvider.AuthorizationHeaderValue();
var token = authHeader.Parameter ?? string.Empty;
_connection = new HubConnectionBuilder()
.WithUrl(hubUrl.ToString(), options =>
{
// Ensure negotiate and HTTP-based transports go through Aspire Service Discovery
options.HttpMessageHandlerFactory = _ => httpMessageHandlerFactory.CreateHandler(string.Empty);
if (!string.IsNullOrEmpty(token))
{
options.AccessTokenProvider = () => Task.FromResult<string?>(token);
}
})
.WithAutomaticReconnect()
.Build();
_connection.On<GetAll.Dto>("ReceiveMessage", async (dto) =>
{
try
{
await onMessage(dto);
}
catch (Exception ex)
{
logger.LogError(ex, "Error handling received message");
}
});
await _connection.StartAsync(cancellationToken);
await _connection.InvokeAsync("JoinGroup", groupId.ToString(), cancellationToken);
_joinedGroupId = groupId;
}
public async Task DisconnectAsync(int groupId, CancellationToken cancellationToken = default)
{
try
{
if (_connection is { State: HubConnectionState.Connected })
{
await _connection.InvokeAsync("LeaveGroup", groupId.ToString(), cancellationToken);
await _connection.StopAsync(cancellationToken);
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Error during hub disconnect");
}
finally
{
_joinedGroupId = null;
if (_connection is not null)
{
await _connection.DisposeAsync();
_connection = null;
}
}
}
public async ValueTask DisposeAsync()
{
if (_connection is not null)
{
try
{
await _connection.DisposeAsync();
}
catch
{
// ignore
}
}
}
}
Now that we have that service setup we can use it on our Chat page to replace the polling that it was doing before. First we add it as an inject at the start of the component:
@inject Services.IChatHubService ChatHubService
Next we initialise it:
protected override async Task OnInitializedAsync()
{
// Determine my user id first so we can compute IsMe correctly for both history and live messages
_myUserId = await AuthProvider.GetUserIdAsync() ?? 0;
await LoadChatsAsync();
//Connect to SignalR
await ConnectSignalRAsync();
}
And this is what the ConnectSignalRAsync method looks like:
private async Task ConnectSignalRAsync()
{
try
{
// Refresh user id in case it changed
_myUserId = await AuthProvider.GetUserIdAsync() ?? _myUserId;
await ChatHubService.ConnectAsync(GroupId, async dto =>
{
dto.IsMe = dto.UserId == _myUserId;
_chats.Add(dto);
await InvokeAsync(StateHasChanged);
});
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to connect to chat hub");
}
}
What we do here is call our ChatHubService to connect to the Hub. This creates our subscription so that whenever something happens on the server, like someone sending a message we will automatically receive it here. This in turn adds it to our _chats collection and then calls StateHasChanged so that our UI gets updated with the change.
Finally we implement the Dispose method so that we disconnect our client when you leave the page:
public async ValueTask DisposeAsync()
{
try
{
await ChatHubService.DisconnectAsync(GroupId);
}
catch
{
// ignored
}
}
And that's all you need to do to enable SignalR.
Why SignalR Fits Perfectly Here 🎯
In chat applications, speed and responsiveness are essential. SignalR’s ability to push updates instantly ensures a better user experience while reducing server load compared to constant polling. Messages appear almost instantly, making the app feel more dynamic.
Paired with .NET Aspire’s distributed architecture, SignalR integrates seamlessly into the service orchestration model. This combination makes it much easier to create responsive, scalable, real-time applications.
Handling Concurrent Connections and Scaling with Azure 📈
SignalR is designed to handle many concurrent client connections, but the number you can support depends heavily on your hosting environment. Each connection consumes server memory and CPU resources, and WebSockets in particular can hold open long-lived TCP connections. Hosting SignalR on-premises or in your own cloud infrastructure means you’ll need to plan for peak concurrent users, ensuring your servers have enough capacity to maintain those persistent connections without degraded performance.
For high-traffic or globally distributed applications, Microsoft offers the Azure SignalR Service, a fully managed service that offloads connection handling to Azure. This allows your application servers to focus on business logic while Azure manages the scale-out, reliability, and global reach of SignalR connections. It’s ideal for scenarios where you expect tens of thousands, or even millions, of concurrent connections, as it removes the operational burden of maintaining that infrastructure yourself.
Final Thoughts 🏁
SignalR removes the complexity of building real-time communication into .NET applications. Its support for multiple transport mechanisms, automatic connection handling, and intuitive API make it a must-have for certain types of apps.
For the Aspire Chat App, integrating SignalR transformed it into a responsive, interactive experience. Scaling this up with Azure SignalR Service could bring global, low-latency communication to millions of users.
Ready to try it yourself? Check out the GitHub repo here Aspire Chat Demo, experiment with the demo, and start adding real-time features to your own projects. If you have questions or want to share what you’ve built, feel free to connect with me on X, LinkedIn, Bluesky, or send me an email, I’d love to hear from you.
Subscribe so you don't miss out 🚀
Be the first to know about new blog posts, news, and more. Delivered straight to your inbox 📨