.NET 10 SignalR MAUI WebSockets

How I Built a Real-Time Chat App in .NET 10 with SignalR & MAUI

AL
Azim Litanga
· March 1, 2025 · ⏱ 8 min read
Series: Logic Dynamics Web Development Projects #01 — Part 1 of 3

The idea

Every serious .NET developer needs to have built a real-time application at some point. Not just read about it — actually built one, watched two windows talk to each other live, and felt that moment when a message appears on screen without refreshing the page.

That's exactly what this project is about. I built a full-stack chat application using:

By the end of this article, you'll understand how all four fit together — and how I went from zero to two instances chatting in real-time on the same machine in a single session.

What is SignalR?

Normally, HTTP is a one-way conversation. Your client asks, the server answers, and the connection closes. That's fine for loading a webpage — but terrible for chat. You'd have to keep asking "any new messages?" every second. That's called polling, and it's inefficient.

SignalR solves this by keeping a persistent connection open between client and server. When a message arrives, the server pushes it to all connected clients instantly. No asking. No waiting. Just real-time.

Under the hood, SignalR prefers WebSockets — a full-duplex protocol that keeps the connection alive. If WebSockets aren't available, it falls back gracefully to Server-Sent Events or Long Polling.

Solution structure

I organized the solution into three projects — a pattern I use across all my Logic Dynamics builds:

ChatApp.sln
├── ChatApp.API/        → ASP.NET Core Web API
├── ChatApp.MAUI/       → Cross-platform client
└── ChatApp.Shared/     → Shared models and DTOs

The Shared project is the key. Both API and MAUI reference it, meaning ChatMessage is defined once and used everywhere. No duplication, no sync issues.

dotnet new sln -n ChatApp
dotnet new webapi -n ChatApp.API
dotnet new maui -n ChatApp.MAUI
dotnet new classlib -n ChatApp.Shared

dotnet sln add ChatApp.API ChatApp.MAUI ChatApp.Shared
dotnet add ChatApp.API reference ChatApp.Shared
dotnet add ChatApp.MAUI reference ChatApp.Shared

The shared model

Everything flows through ChatMessage. I kept it lean:

// ChatApp.Shared/Models/ChatMessage.cs
namespace ChatApp.Shared.Models;

public class ChatMessage
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public string SenderName { get; set; } = string.Empty;
    public string Content { get; set; } = string.Empty;
    public string RoomId { get; set; } = "general";
    public DateTime SentAt { get; set; } = DateTime.UtcNow;
    public MessageType Type { get; set; } = MessageType.Text;

    // UI helpers — set client-side only
    public bool IsSystem => Type == MessageType.System;
    public bool IsSent { get; set; }
    public bool IsReceived => !IsSystem && !IsSent;
}

public enum MessageType { Text, System, Image }

Notice the three UI helpers at the bottom — IsSystem, IsSent, IsReceived. These drive the chat bubble layout in MAUI without any extra logic in the view.

The SignalR Hub

This is the heart of the application. A Hub is a class that manages all real-time connections:

// ChatApp.API/Hubs/ChatHub.cs
public class ChatHub : Hub
{
    private static readonly Dictionary<string, string> _connectedUsers = new();

    public async Task JoinRoom(string roomId, string userName)
    {
        await Groups.AddToGroupAsync(Context.ConnectionId, roomId);
        _connectedUsers[Context.ConnectionId] = userName;

        var systemMsg = new ChatMessage
        {
            SenderName = "System",
            Content = $"{userName} joined {roomId}",
            RoomId = roomId,
            Type = MessageType.System
        };

        await Clients.Group(roomId).SendAsync("ReceiveMessage", systemMsg);
    }

    public async Task SendMessage(string roomId, string senderName, string content)
    {
        var message = new ChatMessage
        {
            SenderName = senderName,
            Content = content,
            RoomId = roomId,
            SentAt = DateTime.UtcNow
        };

        await Clients.Group(roomId).SendAsync("ReceiveMessage", message);
    }
}

Three things worth noting here:

Groups — SignalR groups let you broadcast to a subset of connections. When a user joins "general", they're added to that group. Messages sent to the group go to everyone in it — and only them.

Clients.Group(roomId).SendAsync — this is the magic line. It pushes the message to every client in the room simultaneously. No polling. No delay.

Static dictionary — connected users are stored keyed by ConnectionId. This is fine for a single-server app. For multi-server, you'd use a Redis backplane.

Message persistence

Chat without history is just noise. I wired up EF Core with SQLite to persist every message:

// ChatApp.API/Data/ChatDbContext.cs
public class ChatDbContext(DbContextOptions<ChatDbContext> options) : DbContext(options)
{
    public DbSet<ChatMessage> Messages => Set<ChatMessage>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<ChatMessage>(e =>
        {
            e.HasKey(m => m.Id);
            e.Property(m => m.Content).HasMaxLength(2000);
            e.HasIndex(m => m.RoomId); // fast room queries
        });
    }
}
💡 Why index RoomId? When loading history for a room, you're always filtering by RoomId. Without an index, that's a full table scan on every load. With it — instant lookup regardless of message count.

The MAUI client

On the client side, I built a ChatService that wraps the SignalR connection:

// ChatApp.MAUI/Services/ChatService.cs
public class ChatService : IAsyncDisposable
{
    private HubConnection? _hub;
    public event Action<ChatMessage>? OnMessageReceived;

    public async Task ConnectAsync()
    {
        _hub = new HubConnectionBuilder()
            .WithUrl("https://chat-api.logicdynamics.net/hubs/chat")
            .WithAutomaticReconnect()  // handles dropped connections
            .Build();

        _hub.On<ChatMessage>("ReceiveMessage", msg =>
            OnMessageReceived?.Invoke(msg));

        await _hub.StartAsync();
    }

    public async Task SendMessageAsync(string roomId, string senderName, string content)
    {
        if (_hub is null) return;
        await _hub.InvokeAsync("SendMessage", roomId, senderName, content);
    }
}
💡 WithAutomaticReconnect() This single method handles network hiccups, server restarts, and brief outages without the user noticing. Always include it in production SignalR clients.

The moment it all clicked

I opened two instances of the MAUI app. Named one "Azim", the other "Claude". Joined the same room. Sent a message from one window.

It appeared on the other instantly.

No refresh. No polling. Just real-time WebSocket magic. That's the moment you stop reading about SignalR and start understanding it.

What I'd do differently


The full source code is on GitHub at github.com/LogicDynamics/ChatApp.

In the next article, I cover how I took this API from localhost to a live Ubuntu VPS — with Nginx, systemd, and a free SSL certificate.

Next in series →
From Localhost to the Internet: Deploying a .NET API to a Linux VPS
AL
Azim Litanga

Founder of LogicDynamics — .NET engineer, builder, and creator. Passionate about Blazor, Web APIs, AI, and shipping real software. Based in Oklahoma City.