DEV Community

Cover image for Blazor in .NET 10: The Features That Actually Matter
Mashrul Haque
Mashrul Haque

Posted on

Blazor in .NET 10: The Features That Actually Matter

I remember when Blazor was the weird kid at the .NET party. "You want to run C# in the browser? That's adorable." Fast forward to 2025, and Blazor in .NET 10 is no longer the underdog—it's a genuine contender.

Blazor has always worked. But some patterns required more ceremony than they should.

.NET 10 finally streamlines the rough edges.

The Boilerplate Problem

Here's what many developers experienced. You start a Blazor project. You're excited. You write components. They work. Life is good.

Then your boss says: "Why does the page flash white during navigation?"

And you discover prerendering. And hydration. And the double-render problem. The framework handled all of this—but you had to write the wiring yourself. Serializing state manually, juggling OnInitializedAsync vs OnAfterRenderAsync, building abstractions that felt like they should've been built-in.

Six months later, your Program.cs:

builder.Services.AddScoped<IStateContainer, StateContainer>();
builder.Services.AddScoped<IPreRenderStateService, PreRenderStateService>();
builder.Services.AddScoped<IHydrationHelper, HydrationHelper>();
builder.Services.AddScoped<IComponentStateManager, ComponentStateManager>();
builder.Services.AddScoped<ICircuitHandler, CustomCircuitHandler>();
builder.Services.AddSingleton<IReconnectionStateTracker, ReconnectionStateTracker>();
// Services that worked great, but shouldn't have been your job to write
Enter fullscreen mode Exit fullscreen mode

The component that fetches weather data? It fetches it twice. Once during prerender, once during interactive mode. Your API team hates you.

.NET 10: The "We Actually Fixed State" Release

Let me show you what's new. And why it matters.

Persistent State That Doesn't Make You Cry

Before .NET 10:

@inject PersistentComponentState ApplicationState

@code {
    private WeatherForecast[]? forecasts;
    private PersistingComponentStateSubscription _subscription;

    protected override async Task OnInitializedAsync()
    {
        _subscription = ApplicationState.RegisterOnPersisting(PersistData);

        if (!ApplicationState.TryTakeFromJson<WeatherForecast[]>("forecasts", out forecasts))
        {
            forecasts = await FetchForecasts();
        }
    }

    private Task PersistData()
    {
        ApplicationState.PersistAsJson("forecasts", forecasts);
        return Task.CompletedTask;
    }

    public void Dispose()
    {
        _subscription.Dispose();
    }
}
Enter fullscreen mode Exit fullscreen mode

That's 25 lines of ceremony just to not double-fetch data. Every. Single. Component.

After .NET 10:

@code {
    [PersistentState]
    public WeatherForecast[]? Forecasts { get; set; }

    protected override async Task OnInitializedAsync()
    {
        Forecasts ??= await FetchForecasts();
    }
}
Enter fullscreen mode Exit fullscreen mode

One attribute. That's it. The runtime handles serialization, hydration, and cleanup. Your API team might even invite you to lunch again.

Circuit State Persistence (The "My WebSocket Died" Problem)

You know the scenario. User fills out a complex form. Their internet hiccups. WebSocket dies. Blazor Server reconnects.

All their data? Gone. They start over. They leave your app. They write a bad review.

.NET 10 fixes this:

// When the user goes idle or connection is unstable
Blazor.pause();

// Later, when they're back
await Blazor.resume();
Enter fullscreen mode Exit fullscreen mode

When you call Blazor.pause() before the connection drops, .NET 10 persists the circuit state to browser storage. You can resume it later—even if the original circuit was evicted on the server. In practice, how long this works depends on your server settings and the browser's storage lifecycle. Think "much more resilient" rather than "infinite session immortality."

This makes Blazor Server significantly more resilient for demanding enterprise scenarios.

Performance: The Numbers That Actually Matter

Here's the before and after for a typical Blazor Web App (your exact numbers will vary based on configuration and app shape):

Asset .NET 9 .NET 10 Improvement
blazor.web.js ~183 KB ~43 KB ~76% smaller
Boot manifest Separate file Inlined in dotnet.js 1 fewer request
Asset delivery Embedded resources Static with fingerprinting Better caching

That JavaScript bundle? It went on a serious diet. Your lighthouse scores will thank you.

But wait, there's more. .NET 10 preloads Blazor framework assets automatically via Link headers in Blazor Web Apps and high-priority downloads in standalone WebAssembly. The runtime downloads while your page is rendering instead of afterwards.

First paint happens. User sees content. Meanwhile, Blazor runtime downloads in the background. No special component required—the framework handles it.

It's the difference between "this app feels slow" and "this app feels instant."

JavaScript Interop That Doesn't Feel Like 2015

Old JS interop was... functional. You could call functions. You could pass primitives. You could pray your JSON serialization didn't explode.

.NET 10 adds actual object semantics:

// Create a JS object instance
var chart = await JS.InvokeConstructorAsync<IJSObjectReference>(
    "Chart",
    canvasElement,
    chartConfig
);

// Read properties
var currentValue = await chart.GetValueAsync<int>("currentValue");

// Set properties
await chart.SetValueAsync("animationDuration", 500);

// For performance-critical code (in-process only)
var syncValue = chart.GetValue<int>("dataLength");
Enter fullscreen mode Exit fullscreen mode

You're working with actual objects now. Not just strings and prayers. For deeply nested property paths, you'll typically still write a small JS helper and call that from .NET.

The Real Win: Constructor Support

Before, creating a JS object looked like this:

await JS.InvokeVoidAsync("eval", "window.myChart = new Chart(canvas, config)");
var reference = await JS.InvokeAsync<IJSObjectReference>("eval", "window.myChart");
Enter fullscreen mode Exit fullscreen mode

Eval. In production code. I've seen it. I've written it. We all have.

Now:

var myChart = await JS.InvokeConstructorAsync<IJSObjectReference>("Chart", canvas, config);
Enter fullscreen mode Exit fullscreen mode

No eval. No global pollution. Just clean interop.

Hot Reload That Actually Works for WebAssembly

Hot Reload in Blazor has always been a bit of a mixed bag. Server-side? Pretty good. WebAssembly? Required manual setup and sometimes just... didn't.

.NET 10 migrated to a general-purpose Hot Reload implementation for WebAssembly. The SDK now handles it automatically.

<!-- This is now true by default for Debug builds -->
<PropertyGroup>
  <WasmEnableHotReload>true</WasmEnableHotReload>
</PropertyGroup>
Enter fullscreen mode Exit fullscreen mode

You don't need to add this. It's already on. Edit a .razor file, save, and see the changes instantly. No rebuild. No browser refresh. No configuration.

For teams with custom build configurations:

<!-- Enable for non-Debug configurations -->
<PropertyGroup Condition="'$(Configuration)' == 'Staging'">
  <WasmEnableHotReload>true</WasmEnableHotReload>
</PropertyGroup>
Enter fullscreen mode Exit fullscreen mode

The workflow is finally what it should be: edit code, save, see changes. The inner dev loop for Blazor WASM is now almost on par with what JavaScript frameworks have had for years.

Form Validation Gets a Source Generator

This one's subtle but important.

Before .NET 10: Validation used reflection. Every form submission, the runtime reflected over your model, found attributes, built validators. It worked, but it wasn't AOT-friendly. And it wasn't fast.

After .NET 10:

// Program.cs
builder.Services.AddValidation();

// Your model
[ValidatableType]  // New: enables source-generated validators in .NET 10
public class OrderForm
{
    [Required]
    public string CustomerName { get; set; }

    [Range(1, 100)]
    public int Quantity { get; set; }

    [ValidateComplexType]  // Now works with the source generator for nested objects
    public Address ShippingAddress { get; set; }

    [ValidateEnumeratedItems]  // Collection items validated via the generator
    public List<LineItem> Items { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

The source generator runs at compile time. It creates optimized validators. No reflection at runtime. AOT-compatible. Faster.

And finally—finally—nested object validation just works. No more custom ValidationAttribute hacks.

404 Handling That Makes Sense

How did we handle 404s before?

@page "/products/{id}"

@if (_notFound)
{
    <NotFoundPage />
}
else if (_loading)
{
    <Loading />
}
else
{
    <ProductDetails Product="_product" />
}

@code {
    private bool _loading = true;
    private bool _notFound = false;
    private Product? _product;

    protected override async Task OnInitializedAsync()
    {
        _product = await ProductService.GetById(Id);
        _notFound = _product == null;
        _loading = false;
    }
}
Enter fullscreen mode Exit fullscreen mode

Every component. Every page. Manual state tracking for "does this thing exist."

.NET 10:

@page "/products/{id}"
@inject NavigationManager Nav

@code {
    private Product? _product;

    protected override async Task OnInitializedAsync()
    {
        _product = await ProductService.GetById(Id);

        if (_product is null)
        {
            Nav.NotFound();  // That's it. That's the whole thing.
            return;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Works in SSR. Works in interactive mode. Works everywhere. The framework handles the 404 response, the page display, everything.

New templates even include a NotFound.razor component out of the box. Customize it once, use it everywhere.

Navigation That Respects Your Users

This one drove me crazy for years.

User is reading a long page. They click a filter that updates the query string. Blazor navigates to the "new" URL. Page scrolls to top. User loses their place. User curses your name.

Fixed in .NET 10. Same-page navigation no longer scrolls to top. Query string changes preserve viewport. Fragment changes work correctly.

Also:

<NavLink href="/products?category=electronics" Match="NavLinkMatch.All">
    Electronics
</NavLink>
Enter fullscreen mode Exit fullscreen mode

With NavLinkMatch.All, this stays active even when you add &sort=price to the URL. Query strings and fragments are ignored for matching purposes.

It's the little things.

Reconnection UI That Doesn't Embarrass You

The default reconnection modal in Blazor Server has always looked... fine. Generic. Like it was designed by committee in 2019 (because it was).

.NET 10 templates include a new ReconnectModal component:

Components/
├── ReconnectModal.razor
├── ReconnectModal.razor.css    # Collocated styles
└── ReconnectModal.razor.js     # Collocated scripts
Enter fullscreen mode Exit fullscreen mode

It's CSP-compliant. It's customizable. It actually looks like it belongs in your app.

And there's a new event to hook into:

document.addEventListener('components-reconnect-state-changed', (e) => {
    console.log(`Reconnection state: ${e.detail.state}`);
    // States: 'connecting', 'connected', 'failed', 'retrying' (new!)
});
Enter fullscreen mode Exit fullscreen mode

The retrying state is new. Now you can show "Reconnection attempt 3 of 10..." instead of just spinning forever.

Passkeys: Because Passwords Are So 2020

ASP.NET Core Identity now supports WebAuthn/FIDO2. In English: fingerprint and face login. Hardware security keys. Phishing-resistant authentication.

// Program.cs
builder.Services.AddIdentityCore<ApplicationUser>(options =>
{
    options.SignIn.RequireConfirmedAccount = true;
    options.Stores.SchemaVersion = IdentitySchemaVersions.Version3;  // Enables passkeys
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddSignInManager()
.AddDefaultTokenProviders();
Enter fullscreen mode Exit fullscreen mode

The Blazor Web App template scaffolds the passkey endpoints and UI—most of the work is configuration rather than hand-rolling WebAuthn:

  • Register a passkey
  • List registered passkeys
  • Remove passkeys
  • Login with passkey
  • Passwordless account creation (yes, fully passwordless flows are supported)

No third-party libraries. No complex WebAuthn implementation. Just... it works.

QuickGrid Gets Useful Updates

If you're using QuickGrid (and you should be—it's excellent), two quality-of-life improvements:

<QuickGrid Items="_orders" RowClass="@GetRowClass">
    <PropertyColumn Property="@(o => o.Id)" />
    <PropertyColumn Property="@(o => o.Status)" />
</QuickGrid>

@code {
    private string GetRowClass(Order order) => order.Status switch
    {
        OrderStatus.Overdue => "row-danger",
        OrderStatus.Pending => "row-warning",
        _ => ""
    };
}
Enter fullscreen mode Exit fullscreen mode

Dynamic row styling based on data. Finally.

And for column options:

private async Task ApplyFilter()
{
    // Apply your filter logic
    await _grid.HideColumnOptionsAsync();  // Close the popup programmatically
}
Enter fullscreen mode Exit fullscreen mode

Small things. Big productivity gains.

WebAssembly: The Stuff That Makes WASM Actually Usable

Improved Service Validation

You know what's fun? Registering a service with the wrong lifetime. Then discovering it at runtime. In production. On a Friday.

// Misconfigured lifetimes are now caught with clear diagnostics
builder.Services.AddScoped<MySingletonDependency>();  // Oops, should be Singleton
builder.Services.AddSingleton<MyService>();  // Depends on scoped service
Enter fullscreen mode Exit fullscreen mode

In .NET 10 WebAssembly, misconfigured lifetimes like a singleton depending on a scoped service are caught with clear diagnostics instead of blowing up at runtime. Your build pipeline catches it. Not your users.

Response Streaming by Default

var response = await Http.GetAsync("/api/large-dataset");
var stream = await response.Content.ReadAsStreamAsync();

// In .NET 10, this uses BrowserHttpReadStream automatically
// Memory efficient. No massive allocations.
Enter fullscreen mode Exit fullscreen mode

Large file downloads no longer eat all your memory. The browser's streaming capabilities are used properly.

Performance Diagnostics

New diagnostic tools for WASM:

  • CPU performance profiles
  • Memory dumps
  • Performance counters
  • Native WebAssembly metrics

Finally, you can figure out why your app is slow instead of just knowing that it is.

The Migration Checklist

Upgrading from .NET 9? Here's what to check:

  • [ ] Update TFM to net10.0
  • [ ] Add builder.Services.AddValidation() if using new validation
  • [ ] Review reconnection UI—new component is available but not auto-applied
  • [ ] Test navigation behavior—same-page navigation no longer scrolls
  • [ ] Consider [PersistentState] to replace manual state persistence
  • [ ] Check JS interop—new APIs available for cleaner object handling
  • [ ] Test passkey support if using Identity

Common Mistakes (Already)

1. Overusing [PersistentState]

// Don't do this
[PersistentState]
public DateTime LastClick { get; set; }  // Why are you persisting this?

[PersistentState]
public bool IsMenuOpen { get; set; }  // UI state doesn't need persistence
Enter fullscreen mode Exit fullscreen mode

Persist data. Not UI state. The serialization overhead isn't free.

2. Forgetting the New Validation Registration

// This won't work with [ValidatableType]
builder.Services.AddRazorComponents();

// You need this too
builder.Services.AddValidation();
Enter fullscreen mode Exit fullscreen mode

The source generator creates the validators. AddValidation() registers them.

3. Assuming Passkeys Work Everywhere

// Check for support first
if (await PasskeyService.IsSupported())
{
    // Show passkey options
}
else
{
    // Fallback to password
}
Enter fullscreen mode Exit fullscreen mode

Safari on old iOS? Some Android browsers? Corporate networks with weird policies? Passkey support varies. Always have a fallback.

4. Ignoring Circuit Pause/Resume

// The connection died, but you didn't pause first
// State is lost. User is sad.

// Do this instead
window.addEventListener('beforeunload', () => {
    Blazor.pause();
});
Enter fullscreen mode Exit fullscreen mode

Circuit state persistence only works if you actually tell Blazor to persist it.

When to Upgrade

Situation Recommendation
New project Start with .NET 10. Obviously.
.NET 9, happy Consider waiting for the first .NET 10 servicing update if you're conservative about upgrades.
.NET 9, hitting state issues Upgrade now. [PersistentState] alone is worth it.
.NET 8 LTS You have until November 2026. But 10 is also LTS. Plan the move.
.NET 6 or earlier What are you doing? Upgrade yesterday.

.NET 10 is an LTS release. Three years of support. This is the one to target for production apps.

Final Thoughts

Every Blazor release, I look for the improvements that make the development experience smoother and the rough edges fewer.

.NET 10 delivers. Substantially.

Blazor has been production-ready for years—plenty of enterprises have been running it successfully since 2019. But .NET 10 takes it further. The state management story is now elegant instead of just functional. Performance is excellent. The JavaScript interop feels modern. The developer experience has matured significantly.

Is it perfect? No. The debugging story still needs work. The component library ecosystem is still smaller than React's. Some edge cases in WASM still require workarounds.

But .NET 10 removes many of the caveats I used to mention when recommending Blazor. It's not just a solid choice for .NET teams—it's a genuinely competitive option against any modern web framework.

.NET 10 raised the bar.


About me

I'm a Systems Architect who is passionate about distributed systems, .NET clean code, logging, performance, and production debugging.

Check out my projects on GitHub: mashrulhaque

Follow me here on dev.to for more .NET posts

Top comments (0)