With .NET 10 Previews 4 and 5, Blazor gets Navigation Manager improvements, easier support for custom 404 components, and notable JavaScript Interop enhancements.
As we hurtle into the second half of the year, the .NET 10 preview releases keep coming. Previews 4 and 5 bring lots of small tweaks, but a few significant improvements too.
Here’s what’s new in the latest previews.
Read more: A roundup of the first three preview releases.
When you’re running a Blazor app in production, it can be tricky to diagnose performance issues and decipher how your components are actually running.
Say, for example, you start getting reports that your app is “running slow.” Without more details and data, you’re left to guess what might be causing the issue.
.NET 10 brings some visibility to your Blazor app and its performance, in the form of new metrics and traces.
No, not a bat. This is an example graph showing the performance of a test Blazor app over a period of 1 minute.
Specifically, this is using the update_parameters
metric which, as the name suggests, tracks how long it takes for your Blazor components to process component parameters.
In this case, I’ve used .NET Aspire to visualize the metrics, and to configure that was a case of adding the following code to the ServiceDefaults configuration for Open Telemetry:
Join us for a blog series intro to .NET Aspire.
// ...
// add this
builder.Services.ConfigureOpenTelemetryMeterProvider(meterProvider =>
{
meterProvider.AddMeter("Microsoft.AspNetCore.Components");
meterProvider.AddMeter("Microsoft.AspNetCore.Components.Lifecycle");
meterProvider.AddMeter("Microsoft.AspNetCore.Components.Server.Circuits");
});
builder.Services.ConfigureOpenTelemetryTracerProvider(tracerProvider =>
{
tracerProvider.AddSource("Microsoft.AspNetCore.Components");
});
// existing code
builder.AddOpenTelemetryExporters();
As you can see from the names of the meters here, there are also metrics for such things as the number of open, active and/or connected circuits.
You’ll notice there’s also a new tracer source. This means you can see component lifecycle events in the traces for your app too.
This can be especially useful if you’re trying to see how a specific component interacts with other resources (network requests for example), or trying to decipher which component lifecycle methods are called when and in what order.
These new metrics and traces are great for application-level observability.
But there’s another angle you might need to investigate when it comes to performance…
One of the challenges of running your web applications using Blazor WebAssembly is that you have very little visibility into how your application is actually running when it’s in the browser.
New in .NET 10 are a number of low-level runtime performance tools that you can use to discover how your app is performing in the browser.
These runtime diagnostics can collect CPU performance profiles, memory dumps, performance counters and metrics, and Native WebAssembly performance data.
You can then enable various diagnostics via these MSBuild properties:
<WasmPerfTracing>
<WasmPerfInstrumentation>
<EventSourceSupport>
<MetricsSupport>
Check out the full list (and how to configure them) in the official release notes here.
Here’s an example of enabling performance tracing.
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<!-- add this -->
<WasmPerfTracing>true</WasmPerfTracing>
</PropertyGroup>
</Project>
It’s worth noting this warning from the official release notes:
Note that enabling runtime diagnostics may negatively impact app size and performance. Publishing an app to production with profilers enabled is not recommended.
But with that caveat out of the way, how do we actually access this data?
Well, for that we need to use a little bit of JavaScript. With that, you can launch your Blazor WASM app in the browser, then head to the console and execute the relevant JS to download the performance data as a .nettrace
file.
globalThis.getDotnetRuntime(0).collectCpuSamples({durationSeconds: 60});
This will get a performance profile using CPU sampling for 60 seconds.
But what if you don’t wish to bother with a .nettrace
file and just want to see the performance data in the browser? For that, you can use this MSBuild property (use this instead of WasmPerfTracing
).
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<!-- add this -->
<WasmProfilers>browser</WasmProfilers>
</PropertyGroup>
</Project>
With that, you can see this performance data when you head to the Performance tab (in browser developer tools) and record runtime performance there.
A useful quality of life improvement is that you can now easily configure a 404 page for your Blazor apps. Specifically, you can provide a component to be used in the event that a route is not found.
Routes.razor
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>
Here we’ve set the NotFound
parameter to a NotFound
component in the Pages
folder.
@page "/not-found"
<PageTitle>Not Found</PageTitle>
<div class="container text-center mt-5">
<h1 class="display-4">404 - Page Not Found</h1>
<p class="lead">The page you're looking for doesn't exist or has been moved.</p>
<a href="/" class="btn btn-primary mt-3">Go Home</a>
</div>
Here’s how that will render when the user attempts to visit a route that doesn’t exist.
One gotcha to watch out for: As of Preview 5, you will need to add one extra line to program.cs for this to work.
// add this line
app.UseStatusCodePagesWithReExecute("/not-found", "?statusCode={0}");
// existing code
app.UseAntiforgery();
Hand in hand with the new 404 component support, you can now trigger a 404 programmatically using NavigationManager
.
@page "/nothingtoseehere"
@inject NavigationManager NavMan
<! -- Existing Code -->
@code {
protected override void OnInitialized()
{
NavMan.NotFound();
}
}
It’s worth noting this currently only works for static server-side rendering or apps with global interactivity enabled, but support for apps running per-component interactivity should arrive in a later preview.
If you’re using static server-side rendering this will set 404 as the status code.
For global interactive rendering, you can set the component to render using the new NotFound
setting on the interactive router (see above).
You can also write your own code to handle when NotFound
is triggered via NavigationManager
using the new OnNotFound
method.
@code {
protected override void OnInitialized()
{
NavMan.OnNotFound += HandleNotFound;
NavMan.NotFound();
}
private void HandleNotFound(object? sender, NotFoundEventArgs e)
{
Console.WriteLine("Eeek, something wasn't found");
}
}
One small but welcome change is in a tweak to NavigationManager
.
NavigateTo
in .NET 8 and .NET 9 throws an exception when used during static server-side rendering. This has proved tricky, especially when debugging as you get an exception running in static server-side rendering, but the same code executes without exception when run interactively (server or WASM).
In the latest .NET 10 releases, this no longer occurs, and it will behave consistently with interactive rendering, navigating without throwing an exception.
While you can achieve a lot with Blazor by itself, sometimes you will find yourself needing to turn to JavaScript.
JS Interop in previous versions of .NET is pretty robust, but with one or two limitations.
Take, for example, this JavaScript code.
wwwroot/JS/calculator.js
export class Calculator {
constructor(name, precision = 2) {
this.name = name;
this.precision = precision;
this.history = [];
}
add(a, b) {
const result = parseFloat((a + b).toFixed(this.precision));
this.history.push(`${a} + ${b} = ${result}`);
return result;
}
get lastOperation() {
return this.history[this.history.length - 1] || "No operations yet";
}
clear() {
this.history = [];
}
}
In previous versions of .NET, it was tricky to work with JS like this. In this example, we want to be able to create an instance of the JS class, then interact with that instance from a razor component.
.NET 10 makes this much easier.
Here, for example, is a simple Razor component that instantiates, then interacts with the JS calculator (above).
Calculator.razor
@page "/calculator"
@inject IJSRuntime JSRuntime
@rendermode InteractiveServer
<h2>Calculator</h2>
@if (!_isInitialized)
{
<p>Loading...</p>
}
else
{
<div>
<label for="num1">Number 1:</label>
<input id="num1" type="number" @bind="_num1"/>
</div>
<div>
<label for="num2">Number 2:</label>
<input id="num2" type="number" @bind="_num2"/>
</div>
<div>
<button @onclick="PerformAddition">Add</button>
</div>
<div>
<p>Result: @_result</p>
</div>
<div>
<button @onclick="GetLastOperation">View Last Operation</button>
</div>
<div>
<p>Last Operation: @_lastOperation</p>
</div>
}
@code {
private double _num1;
private double _num2;
private double _result;
private string _lastOperation = "No operations yet";
private IJSObjectReference? _calculatorInstance;
private bool _isInitialized;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && !_isInitialized)
{
var module = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/calculator.js");
_calculatorInstance = await module.InvokeNewAsync("Calculator", "TestCalculator");
_isInitialized = true;
StateHasChanged();
}
}
private async Task PerformAddition()
{
_result = await _calculatorInstance!.InvokeAsync<double>("add", _num1, _num2);
}
private async Task GetLastOperation()
{
_lastOperation = await _calculatorInstance!.GetValueAsync<string>("lastOperation");
}
}
The important code is found in these lines:
// instantiate the calculator
_calculatorInstance = await module.InvokeNewAsync("Calculator", "TestCalculator");
// invoke the add method
await _calculatorInstance!.InvokeAsync<double>("add", _num1, _num2);
Here we’ve been able to instantiate an instance of the calculator and assign a reference to it to _calculatorInstance
(which is an IJSObjectReference
).
From there, we can invoke functions on that instance of our calculator directly using InvokeAsync
and/or access getters using GetValueAsync
.
You can also now grab references to JS functions, then pass them around as you wish.
Here’s a contrived example of a calculator which forgoes our previous object approach and instead exposes logic via functions.
Calculator.js (functions version)
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
export function dynamicOperation(callback, a, b) {
return callback(a, b);
}
No class this time, but some functions we might want to call from our Razor component.
In .NET 10 we can grab references to these functions and pass them around, like this:
var addFunction = await _module.GetValueAsync<IJSObjectReference>("add");
var subtractFunction = await _module.GetValueAsync<IJSObjectReference>("subtract");
These function references are now available in our Razor component, ready to do with as we please.
For example, we can decide which one of them to use, then pass the chosen one to that dynamicOperation
function we saw in the JavaScript (above).
Calculator.razor
@code {
private string _selectedOperation = "add";
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && !_isInitialized)
{
_module = await JsRuntime.InvokeAsync<IJSObjectReference>("import", "./js/calculator.js");
}
}
private async Task UseDynamicOperationCallback()
{
if (_module != null)
{
// Get the add and subtract function references
var addFunction = await _module.GetValueAsync<IJSObjectReference>("add");
var subtractFunction = await _module.GetValueAsync<IJSObjectReference>("subtract");
// Select the appropriate function based on the operation
var selectedFunction = _selectedOperation.ToLower() switch
{
"add" => addFunction,
"subtract" => subtractFunction,
_ => throw new ArgumentException("Invalid operation")
};
// Pass the selected function as a callback to the JavaScript function
_result = await _module.InvokeAsync<double>("dynamicOperation", selectedFunction, _num1, _num2);
}
}
}
This creates references to the add and subtract functions. It then determines whether to use the subtract
or add
function (we can assume this component lets the user choose which one).
Finally, it passes that function reference as a callback when invoking the dynamicOperation
function in our JS module.
There are a few other changes too.
Blazor static assets are now automatically preloaded. In earlier versions of .NET, the browser would download the HTML page for your site, parse the HTML to discover what JS/WASM files it needed, then download those before starting your app. Now, using Link
headers, the browser is told to start loading those resources even before the initial page fetch and render occurs.
The Blazor WebAssembly Standalone App template has received some updates. Notably changes needed to preload the framework assets, generate a JavaScript import map and enable fingerprinting for blazor.webassembly.js (so users won’t end up with a stale version when it changes).
The Blazor boot manifest has been merged into dotnet.js
. and is no longer a separate file (blazor.boot.json
), to reduce the number of HTTP requests and improve performance.
Navigation Manager improvements, easier support for custom 404 components, and notable JavaScript Interop enhancements lead the charge in .NET 10 Previews 4 and 5.
.NET 10 remains on track for release in November 2025.
Jon spends his days building applications using Microsoft technologies (plus, whisper it quietly, a little bit of JavaScript) and his spare time helping developers level up their skills and knowledge via his blog, courses and books. He's especially passionate about enabling developers to build better web applications by mastering the tools available to them. Follow him on Twitter here.