Error Handling
By the end of this lesson
Contain component failures instead of breaking the whole page.
Any component can throw. A null reference in a formatting helper, a cast that was safe until the data changed, a service that returns something nobody expected. The question this lesson answers is not how to stop that happening — you cannot — but how much of the page it should cost when it does.
The default answer is more than you would like. In a page-per-request framework, an unhandled exception ends one request: the visitor gets an error page and their next click works normally. Blazor's unit is not a request. In Blazor Server it is a circuit, and an unhandled exception from a component is treated as fatal to it.
That means a null reference in a minor panel does not break the panel. It breaks the page, including the form the reader had half filled in. Containing failures is therefore worth more here than in the frameworks you may be comparing against.
@page "/dashboard"
@rendermode InteractiveServer
<h1>Team dashboard</h1>
<ErrorBoundary>
<ChildContent>
<AbsenceSummary />
</ChildContent>
<ErrorContent>
<div role="alert" class="panel-failed">
<p>The absence summary is not available right now.</p>
<p>Everything else on this page still works.</p>
</div>
</ErrorContent>
</ErrorBoundary>
<ErrorBoundary>
<ChildContent>
<HeadcountByDepartment />
</ChildContent>
<ErrorContent>
<div role="alert" class="panel-failed">
<p>The headcount chart is not available right now.</p>
</div>
</ErrorContent>
</ErrorBoundary>- ErrorBoundary wraps the part of the tree you are willing to lose. An exception thrown inside it — during rendering, in an event handler, or in a lifecycle method — is caught, and ErrorContent is rendered in its place.
- Everything outside the boundary is untouched. That is the entire point: one failing panel costs the reader a panel, not the page.
- Two boundaries rather than one around both panels. A single boundary around the pair would take out the working panel along with the broken one, which is most of the damage you were trying to avoid.
- With no ErrorContent the boundary renders a generic default. It is better than nothing and it tells the reader nothing useful, so supply your own.
- role="alert" so the replacement is announced. A panel that silently becomes an error message is a change a screen reader user is not told about.
- A boundary around the whole page is barely better than no boundary. Put one around each piece that is independently useful, which usually means each panel, each widget, and each optional section.
- Note what a boundary does not catch: work started and not awaited, and failures outside the render pipeline. Those never reach it, which the mistakes below come back to.
// Components/Shared/LoggingErrorBoundary.cs
public sealed class LoggingErrorBoundary : ErrorBoundary
{
[Inject]
private ILogger<LoggingErrorBoundary> Logger { get; set; } = default!;
[Parameter]
public string Area { get; set; } = "unknown";
protected override Task OnErrorAsync(Exception exception)
{
Logger.LogError(exception, "A component in {Area} failed", Area);
return Task.CompletedTask;
}
}
@* Using it, with a way back *@
<LoggingErrorBoundary @ref="absenceBoundary" Area="absence summary">
<ChildContent>
<AbsenceSummary />
</ChildContent>
<ErrorContent>
<div role="alert">
<p>The absence summary is not available right now.</p>
<button type="button" @onclick="RetryAbsence">Try again</button>
</div>
</ErrorContent>
</LoggingErrorBoundary>
@code {
private LoggingErrorBoundary? absenceBoundary;
private void RetryAbsence() => absenceBoundary?.Recover();
}- Deriving from ErrorBoundary and overriding OnErrorAsync is the supported way to add your own handling. The built-in boundary already logs through the host's logger; override it when you want your own category, a correlation identifier, or a different severity.
- The Area parameter turns "something failed" into "the absence summary failed", which is the difference between a log you can act on and a log you can only scroll.
- [Inject] works here because this is a component class like any other, even though it is written in C# rather than as a .razor file.
- Recover resets the boundary so it renders its child content again. Without it, ErrorContent stays until the page is reloaded, and a transient failure has become a permanent one for that reader.
- @ref gives the page a handle on the boundary instance so it can call Recover. The field is nullable because it is not set until after the first render.
- Retry is a decision, not a default. If the failure was a defect in the component, re-rendering fails again immediately and the reader learns nothing new. Offer it when the cause might be transient — a dependency that was briefly unavailable — and be honest in the wording when it might not be.
- OnErrorAsync returns a Task, so a boundary can report to an external service. Keep it quick: it runs while the reader is looking at a failed panel.
Four kinds of failure, and where each one belongs. A boundary is the right answer to exactly one of them:
- An expected outcome
- No employee with that reference, validation did not pass, nothing matched the search. Not exceptions. Return a result and render a state for it, as the API lesson did with its four branches.
- A dependency that failed
- The API returned 500, the database refused the connection. Catch it where you call it, log it, and show the failure state for that piece of the screen. This is handled code, not a defect.
- A defect in your component
- A null reference, a bad cast, an index out of range. You cannot handle these usefully at the point they happen, because you did not know they were possible. A boundary contains the damage and the log entry tells you where to fix it.
- A failure outside the render pipeline
- A background timer, a message from a service, a task started and not awaited. No boundary sees these. They need their own try/catch where the work is started, or they fail invisibly.
{
"DetailedErrors": false,
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore.Components.Server.Circuits": "Warning",
"Microsoft.AspNetCore.SignalR": "Warning"
}
}
}- DetailedErrors false is the production setting, and it is the default. Setting it to true belongs in appsettings.Development.json only, where the browser showing a stack trace is a convenience rather than a disclosure.
- With it off, a circuit failure reaches the browser as a generic message plus an identifier. That identifier appears in your log next to the full exception, so a reader can quote it in a support request and you can find the entry.
- The circuits category is where teardown is reported. Leaving it at Warning keeps the noise down while still recording the events that matter; turn it to Debug when you are investigating disconnections.
- The SignalR category is worth naming for the same reason. At Information it logs every connection and reconnection, which on a busy portal is a great deal of text about nothing being wrong.
- None of this replaces boundaries. Configuration decides what a failure looks like to the reader and what you can find afterwards; the boundary decides how much of the page the failure takes with it.
Summary
- In Blazor Server an unhandled component exception tears down the circuit, so the whole page stops responding and in-progress work is lost
- ErrorBoundary contains a failure to the part of the tree it wraps, which is why one boundary per independently useful panel beats one per page
- Derive from ErrorBoundary to log with your own detail, and call Recover so a transient failure is not permanent for that reader
- Handle predictable failures where you call them, let boundaries contain defects, and do not use exceptions for expected outcomes
- Log the exception object and show the reader a plain sentence plus an identifier; keep detailed errors out of production
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Break one panel, twice
Add a panel component to a page and throw an InvalidOperationException from its OnInitializedAsync. Run the application in Blazor Server and try to use the rest of the page.
Then wrap that panel in an ErrorBoundary and do the same. Note what the reader can still do in each case, and what appeared in the log.
Show solution
Without the boundary the whole page stops. Not the panel — everything. The connection notice usually appears, every button is inert, and a reload is the only route out. If you had text in a form, it is gone. That is circuit teardown, and it is the behaviour this lesson is about.
With the boundary the panel is replaced by your message and the rest of the page keeps working. The exception is still logged, so you have not hidden the defect; you have stopped it spreading.
Try it once more with the throw moved into a click handler on the panel rather than OnInitializedAsync. The boundary catches that too, because event handlers the framework invoked run inside the render pipeline.
Then move it into a task you start and do not await. Nothing catches it and nothing is logged, and the only symptom is that the work did not happen. That comparison is the most useful part of the exercise: a boundary covers the render pipeline, and anything you start outside it remains your responsibility.
@* AbsenceSummary.razor — a panel that fails on purpose *@
@code {
protected override Task OnInitializedAsync() =>
throw new InvalidOperationException("Absence service is unavailable");
}
@* The uncaught version, for comparison: nothing sees this one *@
@code {
private void StartWork()
{
// No await, no try/catch. The exception is never observed.
_ = Task.Run(() => throw new InvalidOperationException("Silent"));
}
}Think about it
Boundary, catch, or neither?
Three failures on the employee screen. The API returns 500 when loading the list. A formatting helper throws a null reference because one employee has no department. A form is submitted with an empty name.
For each, decide whether it belongs to a try/catch, an ErrorBoundary, or neither, and say what the reader should see.
Show solution
The 500 is a try/catch where the call is made. You knew the call could fail, so you handle it, log it, and show the failure state for the list with a retry. A boundary catching this would be a failure of design rather than a use of the feature: you would be letting a predictable outcome escape and then containing the damage.
The null reference is the boundary's job. You did not know a department could be missing, which is exactly what a defect is. The boundary keeps the rest of the page alive, the log tells you which component and which line, and then you fix the data or the helper. Do not add a catch around the helper — that hides the defect and leaves the panel rendering something wrong.
The empty name is neither. Validation is a normal outcome with a defined response: the message next to the field, from the forms lesson. Throwing for it would route a routine event through machinery meant for faults.
The pattern underneath: handle what you can predict, contain what you cannot, and do not use exceptions for the ordinary. Most error-handling code that becomes unmaintainable got there by mixing those three up.
Saved in this browser only.