Sitemap

Blazor Hybrid + SignalR: Building a Real-Time Chat App in a WPF Window

10 min read2 days ago

--

One UI, two processes, zero JavaScript frameworks — a practical walkthrough of Blazor Hybrid in WPF, with the failures that actually cost time.

Press enter or click to view image in full size

Most .NET desktop teams eventually face the same fork in the road. The WPF app works, the users are on Windows, and somebody asks for a modern UI — a chat panel, a live dashboard, something with rounded corners and a bit of animation.

You have three options. Rebuild the whole thing as a web app and lose the desktop requirements. Learn an entire JavaScript stack to embed a modern front end. Or host Blazor inside the WPF window.

The third option is smaller than it sounds, and it has a specific property I care about most: the UI is written in C#, compiled into your app, and runs in your own process. There is no WebAssembly payload to download and no web server hosting the interface.

This article builds a complete, working real-time chat app that way — Blazor Hybrid in WPF, with SignalR as the backend. Everything here is from a project I recorded end to end, and it includes the two setup failures that made me lose an hour, plus the reconnect bug that ships in a surprising number of SignalR samples.

What “Blazor Hybrid” actually means

Blazor Hybrid is one control: BlazorWebView. You put it in a WPF window. It contains an embedded Chromium instance — WebView2 — which renders ordinary HTML and CSS. Your Razor components run as .NET code inside your WPF process, and the renderer walks the DOM inside that embedded browser.

That has consequences worth being explicit about:

  • No WASM download. Components are not shipped to a browser and executed there; they execute in your process. Cold start is a WPF cold start.
  • No UI web server. Your wwwroot files are served from disk through the WebView's virtual host (https://0.0.0.1/), not from Kestrel.
  • The DOM is real. CSS, flexbox, position: sticky, scroll behaviour — all normal. Which means ordinary web layout skills transfer directly.
  • Interop is a boundary you choose. C# cannot set scrollTop. When you need the DOM, you cross into JavaScript deliberately.
  • The component library is portable. Put your components in a Razor Class Library and the same UI runs in WPF today and Blazor WebAssembly tomorrow.

That last point is the architectural decision that makes this worth doing. The window is a host. The UI is a library. Keep those separate.

The shape of the app

Three projects:

BlazorHybridChat.Server   ASP.NET Core + SignalR hub (the source of truth)
BlazorHybridChat.Shared Razor Class Library: components, CSS, JS, client service
BlazorHybridChat.Wpf WPF window, BlazorWebView, DI container

The chat has three hub methods (JoinAsync, SendMessageAsync, SetTypingAsync) and four server-to-client callbacks (ReceiveMessage, PresenceChanged, TypingChanged, SystemNotice). Three rooms, a presence roster, typing indicators, and automatic reconnection.

Roughly six hundred lines of C# plus a stylesheet.

Setup, and the two things that break

dotnet new sln -n BlazorHybridChat
dotnet new wpf -n BlazorHybridChat.Wpf -o src/BlazorHybridChat.Wpf
dotnet new razorclasslib -n BlazorHybridChat.Shared -o src/BlazorHybridChat.Shared
dotnet new web -n BlazorHybridChat.Server -o src/BlazorHybridChat.Server
dotnet sln add src/*/*.csproj
dotnet add src/BlazorHybridChat.Server reference src/BlazorHybridChat.Shared
dotnet add src/BlazorHybridChat.Wpf reference src/BlazorHybridChat.Shared

Then two edits to the WPF project file that the default template does not make for you.

1. The WPF project must use the Razor SDK

<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<UseWPF>true</UseWPF>
<!-- … -->
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebView.Wpf" Version="10.0.120" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\BlazorHybridChat.Shared\BlazorHybridChat.Shared.csproj" />
</ItemGroup>
</Project>

Microsoft.NET.Sdk.Razor is what maps wwwroot and the _content/<AssemblyName>/… paths from referenced class libraries. Keep the plain desktop SDK and your app will build and run — it will simply have no stylesheet and no JavaScript, which looks like a rendering bug and sends you hunting in the wrong place.

2. The target framework needs a Windows SDK version

This one is new enough that most tutorials predate it:

<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
<SupportedOSPlatformVersion>10.0.17763.0</SupportedOSPlatformVersion>

With plain net10.0-windows, the app compiles cleanly and then dies the moment the window measures its layout:

System.IO.FileNotFoundException:
Could not load file or assembly 'Microsoft.Windows.SDK.NET, Version=10.0.17763.10'
at Microsoft.Web.WebView2.Wpf.WebView2CompositionControl.TryInitializeD3DImage()

In .NET 10 the WPF BlazorWebView renders through WebView2CompositionControl, which depends on the Windows SDK projection assembly. That assembly is only copied to your output when the target framework names a Windows SDK version. Adding 10.0.19041.0 fixes it. Nothing else changes.

Hosting the UI in eight lines of XAML

Remove StartupUri from App.xaml, because we want to construct the window ourselves and give it a service provider.

<Window x:Class="BlazorHybridChat.Wpf.MainWindow"
xmlns:blazor="clr-namespace:Microsoft.AspNetCore.Components.WebView.Wpf;assembly=Microsoft.AspNetCore.Components.WebView.Wpf"
xmlns:chat="clr-namespace:BlazorHybridChat.Shared.Components;assembly=BlazorHybridChat.Shared"
Title="SignalChat — Blazor Hybrid + SignalR"
Height="820" Width="1240" Background="#0B0D12">

<Grid>
<blazor:BlazorWebView x:Name="ChatView" HostPage="wwwroot/index.html">
<blazor:BlazorWebView.RootComponents>
<blazor:RootComponent Selector="#app"
ComponentType="{x:Type chat:ChatRoom}" />
</blazor:BlazorWebView.RootComponents>
</blazor:BlazorWebView>
</Grid>
</Window>

Read that last element carefully, because it is the entire integration. #app is a div in index.html. ChatRoom is a Razor component in the class library. On load, the WebView boots Blazor, finds the selector, and mounts the component there.

The host page is deliberately boring:

<div id="app">Booting Blazor…</div>

<script src="_content/BlazorHybridChat.Shared/js/chatInterop.js"></script>
<script src="_framework/blazor.webview.js"></script>

blazor.webview.js is supplied by the BlazorWebView control itself. The other script comes from our class library under _content. Note the path convention: _content/<AssemblyName>/<path-inside-wwwroot>. Get it wrong and you get an unstyled but perfectly functional app, which is the single most common "it doesn't work" report for this setup.

The dependency injection bridge

Components inside the WebView resolve services from a container that you assign:

protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);

var services = new ServiceCollection();
services.AddWpfBlazorWebView();
#if DEBUG
services.AddBlazorWebViewDeveloperTools();
#endif
services.AddSingleton<ChatService>();

_services = services.BuildServiceProvider();

var window = new MainWindow(_services);
MainWindow = window;
window.Show();
}
public MainWindow(IServiceProvider services)
{
InitializeComponent();
ChatView.Services = services;
}

One AddSingleton<ChatService>() means every component in the app shares one SignalR connection. That is what you want: a chat window with three connections per component is a debugging nightmare.

Contracts first: what goes on the wire

SignalR’s default style is string-based method names, which turns a rename into a runtime bug in a file you forgot existed. Instead, describe the server-to-client surface as an interface:

public interface IChatClient
{
Task ReceiveMessage(ChatMessage message);
Task PresenceChanged(PresenceSnapshot presence);
Task TypingChanged(string[] names);
Task SystemNotice(string text);
}

And make the hub generic over it:

public sealed class ChatHub : Hub<IChatClient>
{
// …
await Clients.Group(room).ReceiveMessage(message);
}

Now ReceiveMessage is compiler-checked. If I change the parameter type, the build fails in the hub instead of silently failing at runtime in a client. It costs one interface.

Get VectoArt’s stories in your inbox

Join Medium for free to get updates from this writer.

The payload types are flat records in the shared library — ChatMessage(Id, Room, Author, Text, SentAtUtc) and JoinResult(Room, History, Participants). Flat and serialisable, no behaviour, no EF navigation properties leaking onto the wire.

The hub: where the design decisions live

public async Task<JoinResult> JoinAsync(string room, string displayName)
{
var target = NormalizeRoom(room);
var name = NormalizeName(displayName, Context.ConnectionId);

var change = _rooms.Join(target, Context.ConnectionId, name);
await Groups.AddToGroupAsync(Context.ConnectionId, target);

if (change.LeftRoom is not null)
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, change.LeftRoom);
await Clients.Group(change.LeftRoom)
.PresenceChanged(new PresenceSnapshot(change.LeftRoom, change.LeftRoster));
}

await Clients.OthersInGroup(target).SystemNotice($"{name} joined #{target}");
await Clients.Group(target).PresenceChanged(new PresenceSnapshot(target, change.Roster));

return new JoinResult(target, _store.GetRecent(target).ToArray(), change.Roster);
}

Groups are the fan-out primitive: Clients.Group(room) is one call that reaches everyone who joined that room. No dictionary of connections to keep in sync, no manual iteration, and it still works when you add a backplane later.

Joining returns data, which is why the client calls InvokeAsync<JoinResult> rather than SendAsync — we need history before the first render, not after.

Sending is where the interesting decisions are:

var name = _rooms.GetDisplayName(Context.ConnectionId);
if (name is null || !_rooms.IsMember(target, Context.ConnectionId))
throw new HubException("Join the room before sending messages.");

var body = (text ?? string.Empty).Trim();
if (body.Length == 0) return;
if (body.Length > MaxMessageLength) body = body[..MaxMessageLength];

var message = new ChatMessage(Guid.NewGuid(), target, name, body, DateTimeOffset.UtcNow);
_store.Add(message);
await Clients.Group(target).ReceiveMessage(message);

Three deliberate choices:

  1. The room comes from the request but membership is verified server-side. A hub method is a public endpoint. Anyone with the connection can call it with any argument.
  2. The author comes from the server’s registry, keyed by connection id — never from the message payload. If you accept an author field from the client, you have built a chat app where anyone can speak as anyone.
  3. Length is clamped, blank is dropped. Trivial, and it prevents a client from pushing a multi-megabyte string through your hub.

And note that the sender is in the group, so their own message comes back through the same stream as everyone else’s. That is a feature: one code path builds every message list, there is no optimistic local echo to reconcile, and ordering is whatever the server decided.

The client service: one connection, one update model

Components never touch HubConnection. One class owns it:

_connection = new HubConnectionBuilder()
.WithUrl($"{ServerUrl.TrimEnd('/')}/hubs/chat")
.WithAutomaticReconnect(new[]
{
TimeSpan.Zero,
TimeSpan.FromSeconds(2),
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(10)
})
.Build();

RegisterHandlers(_connection);
await _connection.StartAsync();
await JoinAsync(room);

The update model is deliberately crude and therefore easy to reason about: mutate local state, raise a Changed event, the component calls StateHasChanged, Blazor re-renders.

protected override void OnInitialized() => Chat.Changed += OnChatChanged;

private void OnChatChanged() => InvokeAsync(StateHasChanged);

The InvokeAsync wrapper is not decoration. SignalR callbacks arrive on a thread-pool thread; the renderer wants its own dispatcher. Without it you get The current thread is not associated with the Dispatcher.

The reconnect bug

This is the part of SignalR that trips up almost everyone:

connection.Reconnected += async _ =>
{
await JoinAsync(Room);
State = ChatConnectionState.Connected;
Notify();
};

When the socket drops and comes back, it comes back as a new connection with a new connection id. SignalR does not restore your group membership, and your server-side registry has never seen that id. If you don’t re-join, the app looks fine — connected, no errors — and silently stops receiving messages. Re-joining also reloads recent history, which fills the gap from while you were offline.

This is why JoinAsync must be idempotent. Joining the same room twice must not produce a duplicate member, or your roster grows a clone every time the Wi-Fi hiccups.

Presence and typing, without the stuck indicator

Two features separate a demo from something that feels alive, and both are server state.

RoomRegistry holds, per room, the members and the typing timestamps, behind a single lock — because SignalR invokes hub methods on thread-pool threads and two people typing at the same instant will collide.

The typing indicator is the part worth stealing:

private static readonly TimeSpan TypingWindow = TimeSpan.FromSeconds(5);

var cutoff = DateTimeOffset.UtcNow - TypingWindow;
foreach (var stale in state.Typing.Where(p => p.Value < cutoff).Select(p => p.Key).ToList())
state.Typing.Remove(stale);

A typing flag is a timestamp with an expiry, not a boolean. If a client vanishes mid-sentence, nobody sends “stopped typing” — so the server ages it out. That is the cheapest possible fix for the eternal “Ana is typing…” that never clears.

One subtlety I got wrong the first time: the server sends one list of typists to the whole room, and each client removes its own name. My initial version excluded the sender when building the list and broadcast it to everyone — which meant the only name filtered out was the one nobody else could see. The indicator never appeared. Compute once, filter per recipient, on the recipient.

The client then renders a sentence instead of a list: one name, two names, or “three people are typing”. Small detail, disproportionate effect on how finished it feels.

Running it

Two terminals, or one terminal and two app launches:

dotnet run --project src/BlazorHybridChat.Server

Start the app twice. Join general as two different names. The roster updates on both sides without a refresh. Type in one — the indicator appears in the other. Send — the bubble arrives with the server's timestamp, tagged you only for the sender. Switch one window to random: the roster in general drops back, and messages don't leak across rooms, because groups are per room. Close a window: the member disappears, because OnDisconnectedAsync did its job.

The server prints each join and leave, which is genuinely useful when you are debugging presence.

The production checklist

The pattern is production-grade. This demo is a demo. Before real users:

Authentication. Derive the display name from Context.User, never from a client-supplied string. With JWT bearer tokens this is a [Authorize] on the hub plus Context.User.Identity.Name. The rest of the code doesn't change — _rooms.Join already takes the name from the server.

Per-room authorisation. Being in a SignalR group is not a permission. Check that this identity may read this room before AddToGroupAsync.

Real storage. History is a bounded in-memory queue — 200 messages per room, gone on restart. IChatStore is the seam: two methods, one DI registration. EF Core or Redis slots in without touching the hub.

Scale-out. RoomRegistry is process-local. Two server instances means two disjoint member lists and, depending on the load balancer, missed messages. Either pin sessions, or add the Redis backplane for groups and move presence into Redis, because presence is shared state, not local state.

Guard rails. We clamp length and validate room names, but there is no rate limit and no content filtering. Add a per-connection budget and a cap on connections per user, and log joins. A hub method deserves the same suspicion as a public API action.

What I’d take away

The interesting part of Blazor Hybrid isn’t that you can render HTML in a WPF window — you’ve been able to do that with WebView2 for years. It’s that the UI becomes a normal .NET component library with a real dependency injection container, real typing, and a host that is one control.

Keep the three boundaries clean — the window hosts, the library draws, the server decides — and the features that come next (file uploads, read receipts, threads, mentions) are additions rather than rewrites. The same components can also be hosted in a browser, which is the cheapest insurance policy you can buy against “we might need a web version” arriving in six months.

The full solution, the video walkthrough and the script are linked below. If you build something with it, I’d like to see it.

--

--

VectoArt
VectoArt

Written by VectoArt

UI/UX Design | WinForms | WPF | Resume Design | UI/UX Design