Real-Time Chat App

Build a real-time chat app in C# with SignalR: typed hubs, group management, message persistence, presence and typing indicators.

Part of the free C# course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

Advanced Project — SignalR, Caching, Presence Tracking

🧠 Project Overview

You are building a chat server where a message typed in one browser appears in every other browser in the same room almost immediately — no polling loop, no refresh button, no "check for new messages every three seconds" timer. The application is ASP.NET Core; the piece that makes it live is SignalR, which keeps an open connection to each browser so the server can call a method on the client instead of waiting to be asked.

When it works, you can open two windows, sign in as two people, and join the room general in both. Each window shows the last fifty messages the moment it joins. Typing in one makes a "someone is typing" line appear in the other and vanish when you stop. The sidebar lists everyone connected and updates when a window closes. Refresh, and the conversation is still there, because every message was written to a database on its way out.

Three problems have to be solved at once, which is what lifts this above a five-minute demo. Delivery is SignalR groups. Durability is a database with a cache in front of it. Presence — knowing who is actually here — is a map the server keeps in memory, because the framework will happily deliver to a room without ever telling you who is in it. The same three parts sit under a support inbox, a live dashboard, a game lobby or a notification bell; chat is just the version where you can see immediately whether it worked.

🎯 Requirements

🧩 Core Concepts

Hubs and two-way RPC

A hub is a server class whose public methods a browser may call by name, and which can call methods back on the browser in return. That second direction is the whole point: with a plain HTTP API the client must keep asking "anything new?", while a hub lets the server say "here". SignalR negotiates a transport when the connection opens — WebSockets where the network allows it, fallbacks where it does not — and your C# is identical either way.

Strongly typed hubs

Declaring the hub as Hub<IChatClient> describes the client's callbacks with an interface instead of with strings. The untyped style names the client method in a string literal the compiler never reads, so a typo there produces silence rather than an error. Typed, you call the method directly, and renaming it breaks the build at every call site instead of breaking the product at two in the morning.

Groups are the routing layer

A group is a named bucket of connection IDs that SignalR maintains for you: joining is one await, broadcasting is one call, and you never touch a socket. Groups are pure runtime state — nothing persists them and they do not survive the connection. That single fact explains a lot of chat bugs, because a client that drops and reconnects is a brand-new connection in no groups at all and must re-join before it receives anything.

A connection is not a person

SignalR identifies connections, not users. One person with two tabs open is two connection IDs; after a refresh they are a third, and the earlier ones may linger briefly before the server notices. Anything user-facing — the online list, the typing line, an unread badge — has to be derived from connections and then collapsed down to distinct people, which is exactly the job of the connection manager in Step 3.

Hub instances are disposable

A hub object is created to service a single method invocation and then discarded. Anything assigned to an instance field is gone before the next message arrives, which makes a hub a bad place for state and a fine place for dependencies. That is why the connection map here is static while the chat service is injected fresh on every call.

Caching, and the second copy of the truth

Re-reading fifty rows from the database every time somebody joins a busy room is wasteful when the answer rarely changes, so history is cached in memory. The price of any cache is that a second copy of the truth now exists and can drift. This project takes the simple, correct route — every write removes the room's cached entry, forcing the next reader back to the database — with a short expiry behind it as a safety net, not as the design.

Step 1: Typed SignalR Hub

The hub is the entire surface the browser is allowed to touch. Every public method here is something a client can invoke by name, and every member of IChatClient is something the server can invoke on the browser — both halves of the protocol written as real types the compiler can police. Read JoinRoom in order, because the order is deliberate: the connection joins the group first so it cannot miss a message sent mid-join, is then recorded so presence is accurate before presence is published, and only then does the method fan out.

Notice who receives what. The history goes to Clients.Caller alone, since everyone else has just read those messages, while the arrival notice and the refreshed user list go to the whole group whose sidebar changed. The typing methods use Clients.OthersInGroup, the group minus the caller; swap in Clients.Group and every user watches their own "is typing" banner flicker at them. OnDisconnectedAsync is the half beginners omit — the only place a connection leaves the map — and it still calls the base implementation so the framework's own cleanup runs.

// Hubs/ChatHub.cs
using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.Authorization;

[Authorize]
public class ChatHub : Hub<IChatClient>
{
    private readonly IChatService _chatService;
    private static readonly ConnectionManager _connections = new();

    public ChatHub(IChatService chatService) => _chatService = chatService;

    public async Task JoinRoom(string roomName)
    {
        await Groups.AddToGroupAsync(Context.ConnectionId, roomName);
        _connections.Add(Context.ConnectionId, Context.User!.Identity!.Name!, roomName);

        // Notify room members
        await Clients.Group(roomName).UserJoined(
            Context.User!.Identity!.Name!, roomName);

        // Send recent message history
        var history = await _chatService.GetRecentMessages(roomName, 50);
        await Clients.Caller.ReceiveHistory(history);

        // Send online users list
        var users = _connections.GetUsersInRoom(roomName);
        await Clients.Group(roomName).UpdatePresence(users);
    }

    public async Task SendMessage(string roomName, string content)
    {
        var userName = Context.User!.Identity!.Name!;

        // Persist message
        var message = await _chatService.SaveMessage(
            userName, roomName, content);

        // Broadcast to room
        await Clients.Group(roomName).ReceiveMessage(message);
    }

    public async Task StartTyping(string roomName)
    {
        await Clients.OthersInGroup(roomName).UserTyping(
            Context.User!.Identity!.Name!, true);
    }

    public async Task StopTyping(string roomName)
    {
        await Clients.OthersInGroup(roomName).UserTyping(
            Context.User!.Identity!.Name!, false);
    }

    public override async Task OnDisconnectedAsync(Exception? exception)
    {
        var info = _connections.Remove(Context.ConnectionId);
        if (info != null)
        {
            await Clients.Group(info.Room).UserLeft(info.UserName, info.Room);
            var users = _connections.GetUsersInRoom(info.Room);
            await Clients.Group(info.Room).UpdatePresence(users);
        }
        await base.OnDisconnectedAsync(exception);
    }
}

public interface IChatClient
{
    Task ReceiveMessage(ChatMessage message);
    Task ReceiveHistory(List<ChatMessage> messages);
    Task UserJoined(string userName, string room);
    Task UserLeft(string userName, string room);
    Task UserTyping(string userName, bool isTyping);
    Task UpdatePresence(List<string> onlineUsers);
}

The mistake this shape prevents is treating a connection as a user; the mistake it still leaves open is hiding in Context.User!.Identity!.Name!. Those null-forgiving operators silence the compiler and do nothing at runtime, so if the token that authenticated the connection carries no name claim, that expression throws on the first message. The code above re-reads it five times across four methods — twice in JoinRoom, once each in SendMessage, StartTyping and StopTyping. Read the name once into a local at the top of the method, check it, and reject the call with a clear error instead of letting a null travel deeper into the system.

Step 2: Chat Service

The hub should not know what a database is. Storage lives entirely in ChatService, which the hub receives by constructor injection — that is what keeps the hub readable in one screen and makes the interesting logic testable without a live connection. Notice what the write path refuses to trust: the id, the author and the timestamp are all assigned on the server, because a client-supplied author is only a claim about who somebody is and a client-supplied time is whatever that machine's clock says. Storing UTC means two users in different zones sort the same conversation identically, leaving the display zone as a presentation problem.

The read path is written for the case that happens constantly: the query runs only when the cache has no entry for that room, otherwise the fifty messages come from memory. The two orderings are not redundant — sorting descending and taking fifty is how you get the newest fifty rather than the oldest, and the second sort flips them back into reading order so the UI can render top to bottom without reversing anything. The most important line is in the other method: the write removes the room's cached entry as part of saving, so a join immediately after a message can never be served a history missing it.

// Services/ChatService.cs
public class ChatService : IChatService
{
    private readonly AppDbContext _db;
    private readonly IMemoryCache _cache;

    public ChatService(AppDbContext db, IMemoryCache cache)
    {
        _db = db;
        _cache = cache;
    }

    public async Task<ChatMessage> SaveMessage(
        string userName, string room, string content)
    {
        var message = new ChatMessage
        {
            Id = Guid.NewGuid(),
            UserName = userName,
            Room = room,
            Content = content.Trim(),
            SentAt = DateTime.UtcNow
        };

        _db.Messages.Add(message);
        await _db.SaveChangesAsync();

        // Invalidate room history cache
        _cache.Remove($"history:{room}");

        return message;
    }

    public async Task<List<ChatMessage>> GetRecentMessages(
        string room, int count)
    {
        return await _cache.GetOrCreateAsync($"history:{room}",
            async entry =>
        {
            entry.SetAbsoluteExpiration(TimeSpan.FromMinutes(5));
            return await _db.Messages
                .Where(m => m.Room == room)
                .OrderByDescending(m => m.SentAt)
                .Take(count)
                .OrderBy(m => m.SentAt)
                .ToListAsync();
        }) ?? new List<ChatMessage>();
    }
}

The pitfall is forgetting the cache exists the moment you add a second way to write a message. An admin import, a bot, an HTTP endpoint that posts on someone's behalf — any of them that saves a row without clearing that room's key leaves joiners looking at a history with a hole in it until the expiry quietly rescues you minutes later. A subtler trap: an in-memory cache lives in one process, so the instant you run two copies of this app behind a load balancer each holds its own private idea of the room's history. Watch the database side too, since a context is not safe for two operations at once — overlapping hub calls sharing one produce the "second operation" error listed below.

Step 3: Connection Manager

Groups tell SignalR where to deliver a message, but they will not tell you who is in one — there is no supported way to enumerate a group's members. So if you want a sidebar, you keep your own map, and that is all this class is: connection ID to a small record of user name and room. It is a ConcurrentDictionary for a concrete reason rather than as a precaution. Hub methods run on thread pool threads with no lock around them, so two people joining the same room in the same millisecond really are two threads writing at once, and a plain dictionary in that situation does not merely lose an entry — a torn resize can corrupt its buckets and make an unrelated read hang or throw much later.

TryAdd and TryRemove are the safe pair, returning a result instead of throwing when a key is already there or already gone. GetUsersInRoom calls Distinct on purpose, so the colleague with three tabs appears once, and sorts the names so the sidebar has a stable order rather than reshuffling on every update. The registrations at the bottom are the part that is easy to skip and impossible to do without: the SignalR services, the memory cache, the chat service, and the call that finally gives the hub a URL for clients to connect to.

// Infrastructure/ConnectionManager.cs
using System.Collections.Concurrent;

public class ConnectionManager
{
    private readonly ConcurrentDictionary<string, ConnectionInfo> _connections = new();

    public void Add(string connectionId, string userName, string room)
    {
        _connections.TryAdd(connectionId,
            new ConnectionInfo(userName, room));
    }

    public ConnectionInfo? Remove(string connectionId)
    {
        _connections.TryRemove(connectionId, out var info);
        return info;
    }

    public List<string> GetUsersInRoom(string room)
    {
        return _connections.Values
            .Where(c => c.Room == room)
            .Select(c => c.UserName)
            .Distinct()
            .OrderBy(n => n)
            .ToList();
    }

    public int OnlineCount => _connections.Count;
}

public record ConnectionInfo(string UserName, string Room);

// Program.cs
builder.Services.AddSignalR();
builder.Services.AddMemoryCache();
builder.Services.AddScoped<IChatService, ChatService>();

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

Two mistakes cluster here. The first is scoping the map wrongly: register it as scoped or transient and every hub invocation is handed a brand-new empty dictionary, so the room reports nobody online and nobody can see why. It has to outlive the invocation — a static field, as here, or a singleton. The second is assuming disconnect always gets to run. It does on a clean exit, when the tab closes or the client stops; a laptop that sleeps or a phone that enters a tunnel simply stops answering, and the server only notices once the keep-alive window elapses. Presence is therefore always slightly optimistic, so never build something that must be exactly right — a seat count, a lock — on top of it.

📋 Common Errors

❌ Failed to complete negotiation with the server: Not Found

The client is connecting to a URL no hub is mapped to. Either the path in the browser does not match the one the hub was mapped at, or the mapping sits behind middleware that already handled the request and never passed it on. Compare the two strings character by character before assuming anything more exotic.

❌ Failed to complete negotiation with the server: Unauthorized

The hub requires authorization and the connection arrived without a usable token. This bites almost everyone once, because a browser WebSocket cannot send a custom Authorization header. The JavaScript client's access-token option exists for exactly this: it sends the token as an access_token query parameter, and the server must be configured to read it from there for hub paths as well as from the header.

❌ has been blocked by CORS policy

The page and the hub are on different origins in development. A SignalR connection sends credentials, and a browser refuses a wildcard allowed-origin together with credentials — so the permissive catch-all policy people reach for first is precisely the one that cannot work. Name the origin explicitly and allow credentials.

❌ An unexpected error occurred invoking 'SendMessage' on the server.

Deliberately vague: SignalR does not leak server exception detail to clients by default, because that detail is a gift to an attacker. The real exception is in the server logs, and while developing you can switch on the detailed-errors hub option to have it sent to the client too. Never leave that on in production.

❌ System.NullReferenceException: Object reference not set to an instance of an object.

Thrown by the chain of null-forgiving operators in the hub when the authenticated principal carries no name claim. The exclamation marks stopped the compiler warning; they did nothing at runtime. Whatever issues your tokens must include the claim your identity configuration treats as the name, and the hub should verify it once rather than assume it five times.

❌ Unable to resolve service for type 'IChatService' while attempting to activate 'ChatHub'.

The container has no registration for the interface the hub's constructor asks for. The hub is constructed when a client connects, not when the application starts, so this surfaces as a failed connection rather than a startup crash — which is why it can masquerade as a networking problem. Check the registration line is present and runs before the application is built.

❌ A second operation was started on this context instance before a previous operation completed.

An Entity Framework context is in use by two operations at once. In a hub that usually means a missing await, or overlapping invocations from one client sharing a context. Await every database call, and where genuinely concurrent work is needed give each operation its own short-lived context instead of sharing one.

❌ The maximum message size was exceeded

Someone pasted an image as a data URL, or a "message" that is really a document. SignalR caps the size of an incoming message and closes the connection when it is exceeded, which from the user's side looks like the chat randomly disconnecting. The limit is adjustable in the hub options, but the better fix is to upload large content over ordinary HTTP and send only a link through the hub.

❌ Every message appears twice

Almost always a client-side duplicate rather than a server bug: the incoming-message handler was registered a second time on reconnect, so one delivery fires two callbacks. Register handlers once when the connection is created, and re-join rooms in the reconnected event rather than in the code path that also wires callbacks.

🚀 Enhancement Ideas & Next Steps

1. Make it survive a second server

The first thing that breaks when this stops being local. The static map and the in-memory cache are per process, so with two instances behind a load balancer a message sent on one never reaches a client attached to the other, and each shows half the room as online. SignalR supports a backplane that forwards group broadcasts between servers; move the presence map into shared storage in the same step, since it is no longer the whole picture.

2. Load older history on demand

Fifty messages is a starting screen, not an archive. Add a method that takes the timestamp of the oldest message the client already holds and returns the page before it. Have that path query the database directly and skip the cache — it is a rare request over deep, unchanging data, the opposite of what the cache is for.

3. Direct messages

A private conversation is the same machinery with a different group name: derive one deterministically from the two user names sorted, so both participants compute the same string. Alternatively address a user by identifier, which reaches every connection that person currently holds — the two-tab problem solved for you.

4. Rate limiting and validation

Today a script can call the send method in a loop and fill your database. Track a short window of recent sends per connection and reject anything over the threshold before you persist. In the same place reject empty and oversized content, and make sure the client renders messages as text — a chat box is the most inviting place in any app to try injecting markup.

5. Edited and deleted messages

Add an edited-at column and a soft-delete flag instead of removing rows, then broadcast an update callback so open clients patch the message in place. This forces messages to have a stable identity on the client, which read receipts and reactions will need anyway.

6. Reconnect properly

Handle the client's reconnected event by re-joining the current room and asking for everything since the last message you hold. Without it, a user whose train enters a tunnel returns to a page that looks connected and silently receives nothing. Show connection state in the UI, because a quiet chat and a broken chat look identical.

7. Tests and observability

Hub methods are ordinary classes with injected dependencies, so they can be tested by substituting the clients object and asserting the right callback went to the right group. Add structured logging around joins, disconnects and send failures, plus a connected-client counter — when a real-time system misbehaves in production, the logs are the only witness.

Challenge Complete Checklist

Related lessons