Sitemap
VectoArt

VectoArt

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

Blazor Hybrid vs WPF: Pick the Right One for Your Desktop App in 30 Seconds

11 min readOct 19, 2026

--

I built the same inventory app twice, on one shared C# library, so the comparison is about the UI technology and nothing else. Here is what the real code showed me.

Press enter or click to view image in full size

You are about to start a Windows desktop app in .NET and you have two serious options. WPF is the mature, native way: XAML for the layout, C# for the logic. Blazor Hybrid lets you write the UI with Razor, C# and CSS, and shows it inside a desktop window. Both are free to use. Both are good. The hard part is picking one without regretting it in two years.

Most comparisons give you opinions. I wanted code. So I built one app, InventoryPro, twice. It has summary cards, live search, row selection, a “Restock +10” button, a CSV export with a native Save dialog, and a startup and memory readout. Both versions sit on top of the same class library, so any difference you see comes from the UI layer.

By the end you will know how each one is set up, what the same feature costs in each, where each one hurts, and the single question that decides it. The full source for both apps is linked from the video description.

What Blazor Hybrid actually is (it is not a website)

The name confuses people, so let me clear it up first.

Blazor Hybrid is not a website, and there is no server. Your Razor components run as ordinary C# inside your app’s own .NET process. They do not run in WebAssembly in a browser. The web engine, a control called WebView2, only draws. Your C# decides what the screen should look like, WebView2 paints it as HTML and CSS, and your clicks travel back to your C#.

So: native code, web pixels. Keep that sentence in your head. It explains almost every tradeoff below.

How I kept the comparison fair

The solution has three projects:

Press enter or click to view image in full size

Core targets plain net10.0, with no -windows suffix, which is the compiler's way of promising it cannot touch any UI. The "low stock" rule lives in one place, and neither UI is allowed to reinvent it.

Round 1: Project setup

The pure WPF project file is about as small as .NET gets (excerpt, closing tag trimmed):

<Project Sdk="Microsoft.NET.Sdk">

<!--
PURE WPF. Compare this file with InventoryPro.Hybrid.csproj:
- SDK is Microsoft.NET.Sdk (the Hybrid app needs Microsoft.NET.Sdk.Razor)
- No WebView2, no Razor, no wwwroot
- One NuGet package: CommunityToolkit.Mvvm (free, MIT)
-->
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>InventoryPro.Wpf</RootNamespace>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
</ItemGroup>

The Hybrid project looks almost the same, with four differences that matter (excerpt: the reference to Core and the closing tag are trimmed):

<Project Sdk="Microsoft.NET.Sdk.Razor">

<PropertyGroup>
<OutputType>WinExe</OutputType>
<!--
The Windows version suffix (10.0.17763.0) is REQUIRED for BlazorWebView on .NET 10.
With plain "net10.0-windows" the app builds, then crashes at startup with:
FileNotFoundException: Could not load file or assembly
'Microsoft.Windows.SDK.NET, Version=10.0.17763.10 ...' (from TryInitializeD3DImage)
The suffix makes the SDK reference the Windows SDK .NET projection that WebView2 needs.
-->
<TargetFramework>net10.0-windows10.0.17763.0</TargetFramework>
<UseWPF>true</UseWPF>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>InventoryPro.Hybrid</RootNamespace>
</PropertyGroup>

<ItemGroup>
<!-- 10.0.* = newest 10.0.x patch. Pin an exact version once you have verified your build. -->
<PackageReference Include="Microsoft.AspNetCore.Components.WebView.Wpf" Version="10.0.*" />
</ItemGroup>
  1. Microsoft.NET.Sdk.Razor instead of Microsoft.NET.Sdk. This SDK compiles .razor files and publishes the wwwroot folder.
  2. A Windows version on the target framework: net10.0-windows10.0.17763.0. With plain net10.0-windows, the app builds and then crashes at startup with Could not load file or assembly 'Microsoft.Windows.SDK.NET'. Adding the version fixes it. The pure WPF app does not need it.
  3. UseWPF stays true. The host is still a normal WPF window.
  4. One extra package, Microsoft.AspNetCore.Components.WebView.Wpf, which brings the BlazorWebView control.

I deliberately did not add my own wwwroot include or a framework reference. The Razor SDK already handles both, and adding them again is a classic way to break the app.

The “Loading…” trap. Get the SDK, the script tag, the selector or the wwwroot setup wrong, and the app builds, runs, and shows the word "Loading..." forever, with no error. More on that below.

Verdict for setup: WPF is slightly simpler. Hybrid needs four specific things to be right.

Round 2: Building the UI

I started with the “before” shot: the same data and the same grid in WPF with no styling at all. White grid, grey buttons. That is what WPF gives you out of the box, and it is the honest starting point for any styling comparison.

Now a rounded button with a hover state. WPF has no border-radius setting on a button, so you replace its whole template:

<Style x:Key="DarkButton" TargetType="Button">
<Setter Property="Foreground" Value="White" />
<Setter Property="Background" Value="{StaticResource AccentBrush}" />
<Setter Property="Padding" Value="16,8" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Root"
Background="{TemplateBinding Background}"
CornerRadius="8"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Root" Property="Opacity" Value="0.85" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="Root" Property="Opacity" Value="0.35" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>

That is 27 lines. The same button in CSS:

.btn {
padding: 8px 16px; border: 0; border-radius: 8px; cursor: pointer;
background: var(--accent); color: #fff; font: inherit; font-weight: 600;
}
.btn:hover { opacity: .85; }
.btn:disabled { opacity: .35; cursor: default; }
.btn.ghost { background: transparent; color: var(--muted); }

7 lines. There is a second trap in WPF. When you click a row in a DataGrid, the highlight colour comes from a Windows system brush, and you cannot restyle it with a simple trigger. The trick is to override the system brush keys inside the style's own Style.Resources. (Do not write <Setter Property="Resources">: Resources is not a dependency property, and Visual Studio rejects it with error XDG0041.)

             WPF GOTCHA: this must be <Style.Resources>. You cannot write
<Setter Property="Resources">, because Resources is a plain CLR property and
not a dependency property, so a Setter can't target it. The XAML then throws
"Set property 'System.Windows.Setter.Property' threw an exception" at load time.
Style.Resources is the supported way to give a style its own resources. -->
<Style.Resources>
<SolidColorBrush x:Key="{x:Static SystemColors.HighlightBrushKey}" Color="#24406B" />
<SolidColorBrush x:Key="{x:Static SystemColors.InactiveSelectionHighlightBrushKey}" Color="#24406B" />
<SolidColorBrush x:Key="{x:Static SystemColors.HighlightTextBrushKey}" Color="White" />
<SolidColorBrush x:Key="{x:Static SystemColors.InactiveSelectionHighlightTextBrushKey}" Color="White" />
</Style.Resources>

In CSS, selection is a class called selected. Done.

Here is the honest balance, because it is easy to hear that as “CSS wins.” CSS wins on speed to a good-looking result. WPF wins on consistency: WPF controls behave like every other Windows app, with keyboard navigation, screen reader support and high contrast mode built in. On the web side you can absolutely reach the same place, but you have to build it.

Counting the markup, styles and view logic, with comments and blank lines excluded, the UI layer in my demo is 318 lines in WPF and 187 lines in Hybrid.

Round 3: State and logic

This is where the two styles feel most different.

In WPF you use MVVM: a ViewModel holds the state and the screen binds to it. CommunityToolkit.Mvvm writes most of the plumbing for you:

    // [ObservableProperty] generates a public "SearchText" property with change notification.
[ObservableProperty]
private string _searchText = "";

// NotifyCanExecuteChangedFor re-evaluates the Restock button when the selection changes.
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(RestockCommand))]
private InventoryItem? _selectedItem;

// Runs automatically whenever SearchText changes (generated partial method hook).
partial void OnSearchTextChanged(string value) => ApplyFilter();

The box on the screen binds to it, and the part after the comma is easy to forget:

Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged}"

Without UpdateSourceTrigger=PropertyChanged, WPF waits until the box loses focus before it filters. In Blazor, the same idea is one attribute on a plain field:

@bind="_search" @bind:event="oninput" />

oninput means every keystroke, and _search is just a field. Now the Restock action. In WPF:

    private bool CanRestock() => SelectedItem is not null;

[RelayCommand(CanExecute = nameof(CanRestock))]
private void Restock()
{
if (SelectedItem is null) return;

var id = SelectedItem.Id;
_service.Restock(id, 10);

// InventoryItem is a plain class (it lives in Core, which knows nothing about WPF),
// so the DataGrid can't see Quantity change. We rebuild the list and restore selection.
// HONEST NOTE: in a bigger app you would make the item observable instead.
ApplyFilter();
SelectedItem = Items.FirstOrDefault(i => i.Id == id);

OnPropertyChanged(nameof(TotalValue));
OnPropertyChanged(nameof(LowStockCount));
OnPropertyChanged(nameof(OutOfStockCount));
}

Because InventoryItem is a plain class in Core, the grid cannot see the quantity change, so the ViewModel rebuilds the list and restores the selection. In a bigger app you would make the item observable instead. In Blazor:

    private void Restock()
{
if (_selectedId is int id)
Inventory.Restock(id, 10); // Quantity changes -> Blazor re-renders -> table updates. No refresh code.
}

Change the quantity and Blazor re-renders after the event. There is no refresh code at all.

Get VectoArt’s stories in your inbox

Join Medium for free to get updates from this writer.

To be fair to WPF, that ceremony is what keeps a big app organised. Bindings, commands and change notifications give you a clean separation that scales. Blazor’s simplicity is wonderful for small screens, but on a large app you bring your own structure. That is completely doable. It is just not handed to you.

Round 4: Native power, the underrated part

A web page cannot open a Windows Save dialog. A Blazor Hybrid app can, because it is a desktop app wearing a web page’s UI. The pattern is: define an interface, implement it with real WPF code, and inject it into the Razor component.

The component asks for the interface and has no idea what is behind it:

@inject IInventoryService Inventory
@inject IFileExporter Exporter

The implementation is ordinary WPF code:

public interface IFileExporter
{
/// <returns>The saved file path, or null if the user cancelled.</returns>
Task<string?> SaveCsvAsync(string csv, string suggestedFileName);
}

public sealed class WpfFileExporter : IFileExporter
{
public Task<string?> SaveCsvAsync(string csv, string suggestedFileName)
{
// Blazor event handlers run on the WPF UI thread in BlazorWebView,
// so opening a WPF dialog here is safe.
var dialog = new SaveFileDialog
{
Filter = "CSV files (*.csv)|*.csv",
FileName = suggestedFileName
};

if (dialog.ShowDialog(Application.Current.MainWindow) != true)
return Task.FromResult<string?>(null);

File.WriteAllText(dialog.FileName, csv);
return Task.FromResult<string?>(dialog.FileName);
}
}

And one line connects the two:

services.AddSingleton<IFileExporter, WpfFileExporter>();

The component then calls it like any other service:

    private async Task ExportAsync()
{
// Calls straight into native WPF code through the injected IFileExporter.
var path = await Exporter.SaveCsvAsync(CsvExporter.ToCsv(Filtered), "inventory.csv");
_savedMessage = path is null ? null : $"Saved to {path}";
}

That pattern works for anything native: files, notifications, hardware, the registry. It is the single best reason to choose Blazor Hybrid over a plain website.

Round 5: Startup and memory, measured honestly

I am not going to quote numbers from my laptop as if they were true for yours. Startup and memory depend on your machine, your build, and whether it is the first launch or the tenth. So I built the measurement into the project and wrote its limits right into the code.

My method, which you can repeat: a Release build, run outside the debugger, five launches each, take the median.

Two honest warnings about the in-app readout:

  • Memory covers the host process only. The Hybrid app also runs the web engine in separate processes, and the in-app number does not count them. Use Task Manager, expand the app, and add up the whole tree.
  • Hybrid’s startup number is a lower bound. It is stamped after Blazor’s first render on the .NET side, a few milliseconds before the browser paints.

I mention both because the easy way to make one side win is to quietly leave those caveats out. As a rule of thumb, expect Hybrid to carry the extra weight of a web engine, and measure your own app before you decide.

Debugging the “Loading…” screen

Here is the mistake I promised. Delete the script tag from wwwroot/index.html:

    <div id="app">Loading...</div>

<!-- This file is served by the WebView host from the NuGet package; it is not on disk in wwwroot. -->
<script src="_framework/blazor.webview.js"></script>

Run the app and you get “Loading…” forever. No exception and no crash. In a Debug build, turn on the developer tools:

#if DEBUG
// Lets you press F12 inside the app to open the browser DevTools.
// This is how you debug the "Loading..." screen: read the Console tab.
services.AddBlazorWebViewDeveloperTools();
#endif

Then press F12 inside the app and read the Console tab. It is the same DevTools you know from the browser. The other usual causes are the wrong SDK, a selector that does not match the element id in index.html, or a duplicated wwwroot include. The symptom is the same every time, so the fix is always the same: F12, Console, read the real error.

The verdict: ask who will build the UI

Do not ask “which is better?” Ask: who is going to build and maintain this UI?

Choose WPF when:

  • your team knows XAML, or wants to learn it
  • the app is data-heavy, with big grids, many windows and keyboard-driven workflows
  • you want native accessibility and a Windows look and feel with no extra work
  • you cannot rely on a web engine being present

Choose Blazor Hybrid when:

  • your team already lives in HTML and CSS
  • you want the same UI components in a desktop app and on a website
  • you want rich visuals, like charts and animation, that the web ecosystem has already solved
  • you may go cross-platform later, since Razor components can move into a .NET MAUI app

Or do both. You do not have to choose for the whole app. Put one Blazor panel inside an existing WPF window: WPF for the shell and the heavy grids, Blazor for the dashboard that needs to look great.

One last honest point: hosted in WPF, both of these are Windows only. If you need macOS or Linux, look at Avalonia instead.

A footnote on licensing, in the spirit of this channel: WPF, CommunityToolkit.Mvvm and the Razor components are open source and free. The WebView2 runtime that Blazor Hybrid depends on is also free to use, but it ships under Microsoft’s own license terms rather than an open-source license like MIT, so read it before you ship.

Five things to remember

  1. Blazor Hybrid is native .NET code with a web engine for pixels, not a website.
  2. WPF gives you native behaviour for free. Hybrid gives you web styling speed.
  3. Keep your logic in a shared class library and you can change your mind later.
  4. Measure your own numbers, and count the web engine’s processes.
  5. The deciding question is who builds the UI, not which technology is more fashionable.

Watch the full walkthrough

I show every step on screen, including the deliberate “Loading…” break and the live comparison, in the video: Blazor Hybrid vs WPF on YouTube. The source for both apps and the shared library is linked in the description, with a README that lists the honest limitations.

Your turn: if you have shipped a real desktop app in WPF or Blazor Hybrid, what is the one thing you wish someone had told you before you started? Leave it in the comments. The best answers will go into a follow-up.

If this saved you from a wrong choice, clap for it and follow VectoArt for more .NET UI tutorials with real code and honest tradeoffs.

--

--

VectoArt
VectoArt