How to Build Real-Time Features in .NET MAUI 10 with SignalR

Build real-time .NET MAUI 10 apps with SignalR — from hub setup and JWT authentication to mobile reconnection strategies, app lifecycle handling, offline message queuing, and .NET Aspire integration. Includes full working code examples.

Why Real-Time Communication Matters in Mobile Apps

Let's be honest — users don't wait around anymore. Whether it's a chat message, a stock ticker, a live order tracker, or a collaborative whiteboard, the pull-to-refresh era is pretty much over. SignalR gives .NET MAUI developers a solid, production-ready abstraction over WebSockets (with automatic fallback to Server-Sent Events and Long Polling) that works across Android, iOS, Windows, and Mac Catalyst from a single codebase.

In this guide, you'll build a real-time .NET MAUI 10 application end-to-end: from the ASP.NET Core 10 hub on the server to a robust client that handles reconnection, app lifecycle changes, authentication, and offline message queuing. Every code sample targets the .NET 10 LTS release and the latest Microsoft.AspNetCore.SignalR.Client NuGet package.

Prerequisites

  • .NET 10 SDK (LTS)
  • Visual Studio 2026 17.14+ or VS Code with the .NET MAUI extension
  • An Android emulator or iOS simulator (or physical devices)
  • Basic familiarity with MVVM and dependency injection in .NET MAUI

Architecture Overview

The solution is split into two projects:

  1. RealTimeServer — An ASP.NET Core 10 Minimal API project hosting the SignalR hub, authentication endpoints, and optional .NET Aspire integration.
  2. RealTimeMAUI — A .NET MAUI 10 app that connects to the hub and displays real-time data.

Data flows through a single HubConnection that the MAUI client opens at startup. The server broadcasts events to specific users or groups, and the client updates the UI via an MVVM pattern using CommunityToolkit.Mvvm's WeakReferenceMessenger (since MessagingCenter is now internal in .NET MAUI 10).

Setting Up the ASP.NET Core 10 SignalR Server

Create the Project

dotnet new webapi -n RealTimeServer --framework net10.0
cd RealTimeServer
dotnet add package Microsoft.AspNetCore.SignalR

Define the Hub

Create a Hubs/NotificationHub.cs file. I'm a big fan of strongly-typed hub interfaces here — they keep the contract explicit and eliminate those pesky magic strings on the server side.

// Hubs/INotificationClient.cs
public interface INotificationClient
{
    Task ReceiveMessage(string user, string message, DateTime sentAt);
    Task OrderStatusChanged(int orderId, string status);
    Task UserJoined(string user);
    Task UserLeft(string user);
}

// Hubs/NotificationHub.cs
using Microsoft.AspNetCore.SignalR;

public class NotificationHub : Hub<INotificationClient>
{
    public async Task SendMessage(string user, string message)
    {
        await Clients.All.ReceiveMessage(user, message, DateTime.UtcNow);
    }

    public async Task JoinGroup(string groupName)
    {
        await Groups.AddToGroupAsync(Context.ConnectionId, groupName);
        await Clients.Group(groupName).UserJoined(Context.User?.Identity?.Name ?? "Anonymous");
    }

    public async Task LeaveGroup(string groupName)
    {
        await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName);
        await Clients.Group(groupName).UserLeft(Context.User?.Identity?.Name ?? "Anonymous");
    }

    public async Task UpdateOrderStatus(int orderId, string status, string groupName)
    {
        await Clients.Group(groupName).OrderStatusChanged(orderId, status);
    }

    public override async Task OnDisconnectedAsync(Exception? exception)
    {
        // Clean-up logic: remove user from tracking, notify groups, etc.
        await base.OnDisconnectedAsync(exception);
    }
}

Register the Hub in Program.cs

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSignalR(options =>
{
    options.EnableDetailedErrors = builder.Environment.IsDevelopment();
    options.KeepAliveInterval = TimeSpan.FromSeconds(15);
    options.ClientTimeoutInterval = TimeSpan.FromSeconds(60);
});

var app = builder.Build();

app.MapHub<NotificationHub>("/hub/notifications");

app.Run();

A quick note on KeepAliveInterval: 15 seconds strikes a nice balance. Too frequent and you're burning battery on mobile devices; too infrequent and you won't catch dropped connections quickly enough. I've found 15 seconds works well for most real-world scenarios.

Adding JWT Authentication to the Hub

Most production apps need authenticated connections, and here's where things get slightly tricky. SignalR passes the JWT bearer token as a query string parameter because WebSocket connections can't set custom headers during the upgrade handshake. It's a bit ugly, but it works reliably.

Server-Side Auth Configuration

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidIssuer = builder.Configuration["Jwt:Issuer"],
            ValidAudience = builder.Configuration["Jwt:Audience"],
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!))
        };

        // SignalR sends the token as a query string parameter
        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("/hub"))
                {
                    context.Token = accessToken;
                }

                return Task.CompletedTask;
            }
        };
    });

builder.Services.AddAuthorization();

Then protect the hub endpoint:

app.MapHub<NotificationHub>("/hub/notifications").RequireAuthorization();

Setting Up the .NET MAUI 10 Client

Install the NuGet Package

dotnet add package Microsoft.AspNetCore.SignalR.Client

Create a SignalR Service

This is where things get interesting. You'll want to wrap the HubConnection in a dedicated service class — it keeps SignalR concerns out of your ViewModels and makes the whole thing testable. Trust me, your future self will thank you for this separation.

using Microsoft.AspNetCore.SignalR.Client;

public interface ISignalRService
{
    event Action<string, string, DateTime>? MessageReceived;
    event Action<int, string>? OrderStatusChanged;
    event Action<HubConnectionState>? ConnectionStateChanged;
    Task ConnectAsync(string accessToken, CancellationToken ct = default);
    Task SendMessageAsync(string user, string message);
    Task JoinGroupAsync(string groupName);
    Task DisconnectAsync();
    HubConnectionState State { get; }
}

public class SignalRService : ISignalRService, IAsyncDisposable
{
    private HubConnection? _connection;
    private readonly string _hubUrl;

    public event Action<string, string, DateTime>? MessageReceived;
    public event Action<int, string>? OrderStatusChanged;
    public event Action<HubConnectionState>? ConnectionStateChanged;

    public HubConnectionState State =>
        _connection?.State ?? HubConnectionState.Disconnected;

    public SignalRService(IConfiguration configuration)
    {
        _hubUrl = configuration["SignalR:HubUrl"]
            ?? "https://localhost:5001/hub/notifications";
    }

    public async Task ConnectAsync(string accessToken, CancellationToken ct = default)
    {
        if (_connection is not null)
            await DisconnectAsync();

        _connection = new HubConnectionBuilder()
            .WithUrl(_hubUrl, options =>
            {
                options.AccessTokenProvider = () => Task.FromResult<string?>(accessToken);
            })
            .WithAutomaticReconnect(new MobileRetryPolicy())
            .Build();

        RegisterHandlers();
        RegisterLifecycleEvents();

        await ConnectWithRetryAsync(ct);
    }

    private void RegisterHandlers()
    {
        _connection!.On<string, string, DateTime>("ReceiveMessage",
            (user, message, sentAt) =>
            {
                MessageReceived?.Invoke(user, message, sentAt);
            });

        _connection.On<int, string>("OrderStatusChanged",
            (orderId, status) =>
            {
                OrderStatusChanged?.Invoke(orderId, status);
            });
    }

    private void RegisterLifecycleEvents()
    {
        _connection!.Reconnecting += _ =>
        {
            ConnectionStateChanged?.Invoke(HubConnectionState.Reconnecting);
            return Task.CompletedTask;
        };

        _connection.Reconnected += _ =>
        {
            ConnectionStateChanged?.Invoke(HubConnectionState.Connected);
            return Task.CompletedTask;
        };

        _connection.Closed += _ =>
        {
            ConnectionStateChanged?.Invoke(HubConnectionState.Disconnected);
            return Task.CompletedTask;
        };
    }

    private async Task ConnectWithRetryAsync(CancellationToken ct)
    {
        while (true)
        {
            try
            {
                await _connection!.StartAsync(ct);
                ConnectionStateChanged?.Invoke(HubConnectionState.Connected);
                return;
            }
            catch when (ct.IsCancellationRequested)
            {
                return;
            }
            catch
            {
                ConnectionStateChanged?.Invoke(HubConnectionState.Disconnected);
                await Task.Delay(5000, ct);
            }
        }
    }

    public async Task SendMessageAsync(string user, string message)
    {
        if (_connection?.State == HubConnectionState.Connected)
            await _connection.InvokeAsync("SendMessage", user, message);
    }

    public async Task JoinGroupAsync(string groupName)
    {
        if (_connection?.State == HubConnectionState.Connected)
            await _connection.InvokeAsync("JoinGroup", groupName);
    }

    public async Task DisconnectAsync()
    {
        if (_connection is not null)
        {
            await _connection.StopAsync();
            await _connection.DisposeAsync();
            _connection = null;
        }
    }

    public async ValueTask DisposeAsync()
    {
        await DisconnectAsync();
        GC.SuppressFinalize(this);
    }
}

Custom Retry Policy for Mobile Networks

Here's something that trips up a lot of developers. Mobile connections are inherently unreliable — tunnels, elevators, switching between Wi-Fi and cellular — all of these cause transient disconnections that are totally normal.

The default retry policy (0s, 2s, 10s, 30s and then it gives up) is way too aggressive for mobile. A custom IRetryPolicy with exponential back-off and a longer retry window makes a huge difference in the user experience.

using Microsoft.AspNetCore.SignalR.Client;

public class MobileRetryPolicy : IRetryPolicy
{
    private static readonly TimeSpan MaxDelay = TimeSpan.FromSeconds(60);
    private static readonly TimeSpan MaxElapsed = TimeSpan.FromMinutes(10);

    public TimeSpan? NextRetryDelay(RetryContext retryContext)
    {
        // Give up after 10 minutes of failed attempts
        if (retryContext.ElapsedTime > MaxElapsed)
            return null;

        // Exponential back-off: 0s, 1s, 2s, 4s, 8s, 16s, 32s, 60s, 60s, ...
        var delay = retryContext.PreviousRetryCount == 0
            ? TimeSpan.Zero
            : TimeSpan.FromSeconds(Math.Pow(2, retryContext.PreviousRetryCount - 1));

        return delay > MaxDelay ? MaxDelay : delay;
    }
}

Handling the App Lifecycle

This is the part most tutorials conveniently skip, and honestly, it's one of the most important pieces to get right.

What happens when the user backgrounds your app? On iOS, the OS suspends your process after just a few seconds. On Android, the process can get killed under memory pressure. You need to gracefully disconnect when the app goes to the background and reconnect when it comes back.

// MauiProgram.cs
builder.Services.AddSingleton<ISignalRService, SignalRService>();

// App.xaml.cs
public partial class App : Application
{
    private readonly ISignalRService _signalR;

    public App(ISignalRService signalR)
    {
        InitializeComponent();
        _signalR = signalR;
    }

    protected override Window CreateWindow(IActivationState? activationState)
    {
        var window = new Window(new AppShell());

        window.Resumed += async (s, e) =>
        {
            // Reconnect when the app returns to the foreground
            if (_signalR.State == HubConnectionState.Disconnected)
            {
                var token = await SecureStorage.GetAsync("access_token");
                if (!string.IsNullOrEmpty(token))
                    await _signalR.ConnectAsync(token);
            }
        };

        window.Stopped += async (s, e) =>
        {
            // Gracefully disconnect when backgrounded
            await _signalR.DisconnectAsync();
        };

        return window;
    }
}

Connecting the ViewModel

With .NET MAUI 10, MessagingCenter is now internal — so if you're upgrading from an older version, you'll need to switch to CommunityToolkit.Mvvm's WeakReferenceMessenger or plain events to relay SignalR events to your ViewModels.

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using System.Collections.ObjectModel;
using Microsoft.AspNetCore.SignalR.Client;

public partial class ChatViewModel : ObservableObject
{
    private readonly ISignalRService _signalR;

    [ObservableProperty]
    private string _messageText = string.Empty;

    [ObservableProperty]
    private string _connectionStatus = "Disconnected";

    [ObservableProperty]
    private bool _isConnected;

    public ObservableCollection<ChatMessage> Messages { get; } = [];

    public ChatViewModel(ISignalRService signalR)
    {
        _signalR = signalR;

        _signalR.MessageReceived += OnMessageReceived;
        _signalR.ConnectionStateChanged += OnConnectionStateChanged;
    }

    private void OnMessageReceived(string user, string message, DateTime sentAt)
    {
        MainThread.BeginInvokeOnMainThread(() =>
        {
            Messages.Add(new ChatMessage(user, message, sentAt));
        });
    }

    private void OnConnectionStateChanged(HubConnectionState state)
    {
        MainThread.BeginInvokeOnMainThread(() =>
        {
            ConnectionStatus = state switch
            {
                HubConnectionState.Connected => "Connected",
                HubConnectionState.Reconnecting => "Reconnecting...",
                _ => "Disconnected"
            };
            IsConnected = state == HubConnectionState.Connected;
        });
    }

    [RelayCommand]
    private async Task SendMessage()
    {
        if (string.IsNullOrWhiteSpace(MessageText))
            return;

        await _signalR.SendMessageAsync("User", MessageText);
        MessageText = string.Empty;
    }
}

public record ChatMessage(string User, string Text, DateTime SentAt);

Building the XAML UI

Nothing too fancy here — a simple chat interface with a connection status banner and a scrollable message list. The key detail is ItemsUpdatingScrollMode="KeepLastItemInView", which auto-scrolls to the newest message.

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="RealTimeMAUI.Views.ChatPage"
             x:DataType="viewmodels:ChatViewModel"
             Title="Real-Time Chat">

    <Grid RowDefinitions="Auto,*,Auto" Padding="16">

        <!-- Connection status banner -->
        <Border Grid.Row="0"
                BackgroundColor="{Binding IsConnected,
                    Converter={StaticResource BoolToColorConverter}}"
                Padding="8" StrokeThickness="0">
            <Label Text="{Binding ConnectionStatus}"
                   HorizontalTextAlignment="Center"
                   TextColor="White" FontSize="12" />
        </Border>

        <!-- Messages list -->
        <CollectionView Grid.Row="1"
                        ItemsSource="{Binding Messages}"
                        ItemsUpdatingScrollMode="KeepLastItemInView">
            <CollectionView.ItemTemplate>
                <DataTemplate x:DataType="viewmodels:ChatMessage">
                    <VerticalStackLayout Padding="4,8">
                        <Label Text="{Binding User}" FontAttributes="Bold" />
                        <Label Text="{Binding Text}" />
                        <Label Text="{Binding SentAt, StringFormat='{0:HH:mm}'}"
                               FontSize="10" TextColor="Gray" />
                    </VerticalStackLayout>
                </DataTemplate>
            </CollectionView.ItemTemplate>
        </CollectionView>

        <!-- Input area -->
        <Grid Grid.Row="2" ColumnDefinitions="*,Auto" ColumnSpacing="8">
            <Entry Text="{Binding MessageText}"
                   Placeholder="Type a message..."
                   ReturnCommand="{Binding SendMessageCommand}" />
            <Button Grid.Column="1" Text="Send"
                    Command="{Binding SendMessageCommand}"
                    IsEnabled="{Binding IsConnected}" />
        </Grid>

    </Grid>
</ContentPage>

Registering Services with Dependency Injection

Wire everything up in MauiProgram.cs. One important thing: register the SignalR service as a singleton so the connection persists across pages.

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();

        builder
            .UseMauiApp<App>()
            .ConfigureFonts(fonts =>
            {
                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
            });

        // Register SignalR service as singleton so
        // the connection persists across pages
        builder.Services.AddSingleton<ISignalRService, SignalRService>();

        // Register ViewModels and pages
        builder.Services.AddTransient<ChatViewModel>();
        builder.Services.AddTransient<ChatPage>();

        return builder.Build();
    }
}

Going Beyond Chat: Live Data Updates

Real-time isn't just for chat apps. Consider an order tracking scenario where a delivery rider's location updates every few seconds and the customer watches their food inch closer on a map. Same SignalR pattern, totally different use case.

// On the server hub
public async Task BroadcastLocation(string orderId, double lat, double lng)
{
    await Clients.Group($"order-{orderId}")
        .SendAsync("LocationUpdated", orderId, lat, lng);
}

// On the MAUI client
_connection.On<string, double, double>("LocationUpdated",
    (orderId, lat, lng) =>
    {
        MainThread.BeginInvokeOnMainThread(() =>
        {
            // Update the map pin position
            DeliveryPin.Location = new Location(lat, lng);
        });
    });

This same pattern works for dashboards, collaborative editing, live sports scores, auction bidding — basically anything where the server needs to push data to connected clients.

Offline Message Queuing

So, what happens when the user sends a message while the connection is down? You could silently drop it (please don't), or you could queue messages locally and flush them once the connection is re-established. The second option is obviously better.

public class SignalRServiceWithQueue : SignalRService
{
    private readonly Queue<(string User, string Message)> _pendingMessages = new();

    public SignalRServiceWithQueue(IConfiguration configuration)
        : base(configuration)
    {
        ConnectionStateChanged += async state =>
        {
            if (state == HubConnectionState.Connected)
                await FlushQueueAsync();
        };
    }

    public override async Task SendMessageAsync(string user, string message)
    {
        if (State == HubConnectionState.Connected)
        {
            await base.SendMessageAsync(user, message);
        }
        else
        {
            _pendingMessages.Enqueue((user, message));
        }
    }

    private async Task FlushQueueAsync()
    {
        while (_pendingMessages.TryDequeue(out var msg))
        {
            await base.SendMessageAsync(msg.User, msg.Message);
        }
    }
}

Integrating with .NET Aspire for Service Discovery

.NET MAUI 10 introduced first-class .NET Aspire integration, and it's a game changer for multi-project setups. Instead of hard-coding the hub URL (which never ends well across environments), you can use Aspire service discovery so the client automatically finds the right backend endpoint.

// In MauiProgram.cs
builder.AddServiceDefaults(); // Configures telemetry + service discovery

// Then in SignalRService, resolve the URL via service discovery
public SignalRService(IConfiguration configuration)
{
    // "realtimeserver" is the Aspire resource name
    _hubUrl = configuration["services:realtimeserver:https:0"]
        ?? "https://localhost:5001/hub/notifications";
}

This eliminates environment-specific configuration files and makes local development with Dev Tunnels seamless.

Android and iOS Platform Configuration

Android: Allow Cleartext Traffic for Local Development

If your development server runs over HTTP, Android will block the connection by default. Add this to AndroidManifest.xml:

<application android:usesCleartextTraffic="true" />

Make sure you remove this before shipping to production. Always use HTTPS in prod.

iOS: App Transport Security

iOS enforces HTTPS by default (as it should). For local development with self-signed certificates, add this to Info.plist:

<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsLocalNetworking</key>
    <true/>
</dict>

Testing Your Real-Time Features

Real-time features are notoriously hard to test — there's no getting around that. But here are some strategies that have worked well for me:

  • Unit test ViewModels by mocking ISignalRService. Fire events from the mock and assert the ViewModel updates correctly.
  • Integration test the hub using Microsoft.AspNetCore.SignalR.Client in an xUnit test project with WebApplicationFactory.
  • Simulate network conditions on Android using the emulator's network throttling, or on iOS using the Network Link Conditioner.
  • Test reconnection by toggling airplane mode and verifying the connection status banner updates and messages resume after reconnecting.
// Integration test example
[Fact]
public async Task Client_ReceivesMessage_WhenServerBroadcasts()
{
    await using var app = new WebApplicationFactory<Program>();
    var server = app.Server;

    var connection = new HubConnectionBuilder()
        .WithUrl($"{server.BaseAddress}hub/notifications",
            o => o.HttpMessageHandlerFactory = _ => server.CreateHandler())
        .Build();

    string? receivedMessage = null;
    connection.On<string, string, DateTime>("ReceiveMessage",
        (user, msg, _) => receivedMessage = msg);

    await connection.StartAsync();
    await connection.InvokeAsync("SendMessage", "TestUser", "Hello!");

    await Task.Delay(100); // Allow propagation
    Assert.Equal("Hello!", receivedMessage);
}

Performance Tips

  • Use MessagePack serialization instead of JSON for smaller payloads and faster deserialization. Install Microsoft.AspNetCore.SignalR.Protocols.MessagePack and call .AddMessagePackProtocol() on both server and client.
  • Throttle high-frequency updates on the server. For something like GPS tracking, batch updates every 2–3 seconds instead of sending every single position change. Your users' batteries will thank you.
  • Use groups efficiently. Instead of broadcasting to all connections, use SignalR groups to target only interested clients. This dramatically reduces network traffic.
  • Monitor with OpenTelemetry. .NET MAUI 10 ships with built-in diagnostics. Combined with Aspire's telemetry, you can trace SignalR message latency end-to-end.

Common Pitfalls and How to Avoid Them

  • Forgetting MainThread.BeginInvokeOnMainThread — SignalR callbacks run on a background thread. Updating the UI from there will crash your app on both Android and iOS. Every. Single. Time.
  • Not handling initial connection failures — This catches a lot of people off guard: WithAutomaticReconnect only kicks in after a successful initial connection. You need separate retry logic for that first StartAsync call.
  • Token expiry during long sessions — If your JWT expires while the connection is active, the next reconnection attempt will fail with a 401. Set up a token refresh mechanism and make sure the AccessTokenProvider always returns a valid token.
  • Leaking event handlers — If you subscribe to MessageReceived in a ViewModel, unsubscribe when the page disappears. Otherwise you'll end up with memory leaks and duplicate handler invocations (which is a really fun bug to track down).
  • Large payloads blocking the UI — If the server sends big data sets in real-time, deserialize them off the main thread and only push the final result to the ObservableCollection.

Frequently Asked Questions

Does SignalR work when the .NET MAUI app is in the background?

Short answer: no. On iOS, the OS suspends your app's process within seconds of backgrounding, killing the WebSocket connection. On Android, it may survive briefly but don't count on it. The recommended approach is to disconnect when backgrounded, reconnect when resumed, and use push notifications (FCM/APNs) for truly urgent updates that need to reach the user immediately.

Can I use SignalR with Blazor Hybrid in .NET MAUI?

Absolutely. The SignalR .NET client works the same way inside a BlazorWebView. Just inject ISignalRService into your Razor components via DI and use the same connection management patterns covered in this article.

How do I scale SignalR for thousands of mobile users?

For production scale, Azure SignalR Service is the way to go. It offloads connection management to Azure infrastructure and supports up to one million concurrent connections. The migration is minimal — just swap your self-hosted hub config with: builder.Services.AddSignalR().AddAzureSignalR(connectionString).

Is MessagePack serialization compatible with Native AOT in .NET MAUI?

This one's a bit nuanced. MessagePack can trigger trimming and AOT warnings because it relies on runtime type inspection. If you're publishing with Native AOT enabled, test thoroughly. Consider using the source-generated serializer or sticking with JSON serialization, which has full trim compatibility in .NET 10.

What is the difference between InvokeAsync and SendAsync on the client?

InvokeAsync waits for the server method to complete and returns the result (or throws if something goes wrong). SendAsync only waits until the message leaves the client — it doesn't wait for the server to actually process it. Use InvokeAsync when you need confirmation that the server handled the request, and SendAsync for fire-and-forget things like typing indicators.

About the Author Editorial Team

Our team of expert writers and editors.