Blazor Error Boundaries: Stop One Bug From Killing Your App

Blazor Error Boundaries: Stop One Bug From Killing Your App

Add global and per‑component error boundaries, custom error UI, logging, and safe recovery so one bug never knocks out your Blazor app.

.NET Development Blazor·By amarozka · October 23, 2025

Blazor Error Boundaries: Stop One Bug From Killing Your App

Ever had a single widget blow up and the whole page went blank? In Blazor Server it can even kill the user’s circuit. Let’s fix that today so one bad component can’t take down your app.

Why it matters

  • Prevents one exception from blanking the page or killing the circuit (Blazor Server).
  • Keeps key actions alive while a broken widget shows a safe fallback.
  • Gives you a single, testable place to log exceptions with route and component context.
  • Lets users recover with a Retry button instead of a full reload and lost state.
  • Isolates 3rd‑party or flaky data sources-great for dashboards and micro‑frontends.
  • Plays better with SSR: your shell still renders even if a child fails (mind render‑mode limits covered below).
  • Cuts support noise and speeds up root‑cause analysis.

On a client’s dashboard, one feed threw NullReferenceException at random. After wrapping each card, only that card showed a friendly error and a Retry. The rest of the page kept working.

Quick primer: what an error boundary is

An error boundary is a component that wraps other components. When a child throws an unhandled exception, the boundary catches it, logs it (optional), and renders fallback UI instead of crashing the whole page.

The core idea

<ErrorBoundary>
    <ChildContent>
        <CriticalWidget />
    </ChildContent>
    <ErrorContent>
        <div class="alert alert-danger" role="alert">
            Oops, this section failed. Please try again.
        </div>
    </ErrorContent>
</ErrorBoundary>
  • ChildContent is the normal content.
  • ErrorContent is shown when a child throws.
  • You can get the exception via @context (careful: don’t leak stack traces in production).
<ErrorBoundary>
    <ChildContent>
        <CriticalWidget />
    </ChildContent>
    <ErrorContent Context="ex">
        <div class="alert alert-danger" role="alert">
            We had a problem. Reference: @ex.GetType().Name
        </div>
    </ErrorContent>
</ErrorBoundary>

Global vs. granular boundaries

Global (layout-level)

Wrap the layout body so any page shows friendly error UI instead of blowing up.

MainLayout.razor

<article class="content p-3">
    <ErrorBoundary @ref="_layoutBoundary">
        @Body
    </ErrorBoundary>
</article>

@code {
    private ErrorBoundary? _layoutBoundary;

    // Clear the previous error when the route changes
    protected override void OnParametersSet()
        => _layoutBoundary?.Recover();
}

When should you go global?

  • You want a safety net for pages you don’t control yet
  • Early in a project while you add finer boundaries later

Trade‑offs: a layout boundary shows the same error panel on every page after the first failure until you call Recover(). Clear it on route change (as shown).

Granular (component-level)

Wrap risky parts only: a table that loads remote data, a chart, a widget that talks to JS.

Why I prefer this: the rest of the page keeps working; users can still submit a form, open a menu, etc.

Example: a dashboard with independent cards.

<div class="row g-3">
  <div class="col-md-6">
    <ErrorBoundary>
      <ChildContent>
        <SalesByRegionCard />
      </ChildContent>
      <ErrorContent>
        <CardError title="Sales by region" />
      </ErrorContent>
    </ErrorBoundary>
  </div>
  <div class="col-md-6">
    <ErrorBoundary>
      <ChildContent>
        <LatestOrdersCard />
      </ChildContent>
      <ErrorContent>
        <CardError title="Latest orders" />
      </ErrorContent>
    </ErrorBoundary>
  </div>
</div>

Custom error UI that fits your design

Most teams want error UI to match their design system. Here’s a reusable component.

CardError.razor

@inherits LayoutComponentBase

<div class="card border-danger-subtle shadow-sm">
  <div class="card-body">
    <h5 class="card-title text-danger">@Title</h5>
    <p class="card-text">Something went wrong in this section.</p>
    @if (ShowRetry && Retry is not null)
    {
        <button class="btn btn-outline-primary" @onclick="Retry">Try again</button>
    }
  </div>
</div>

@code {
    [Parameter] public string Title { get; set; } = "Error";
    [Parameter] public bool ShowRetry { get; set; } = true;
    [Parameter] public EventCallback Retry { get; set; }
}

Use it inside an error boundary and wire up retry with Recover() (more on that next).

Let users recover (without reload)

An error boundary keeps state in an “error” mode until you reset it. That’s what Recover() does. Two common ways:

1) Reset on route change

We already did this in MainLayout.razor with OnParametersSet(). That clears the old error when the user moves to another page.

2) Reset with a Retry button

CustomerList.razor (wrapped in a boundary)

<ErrorBoundary @ref="_boundary">
    <ChildContent>
        @if (_loading)
        {
            <p>Loading customers…</p>
        }
        else if (_error is null)
        {
            <table class="table table-sm">
                @foreach (var c in _customers)
                {
                    <tr><td>@c.Name</td><td>@c.City</td></tr>
                }
            </table>
        }
        else
        {
            <CardError Title="Customers" ShowRetry="true" Retry="EventCallback.Factory.Create(this, Retry)" />
        }
    </ChildContent>
</ErrorBoundary>

@code {
    private ErrorBoundary? _boundary;
    private bool _loading;
    private Exception? _error;
    private List<Customer> _customers = new();

    protected override async Task OnInitializedAsync()
        => await LoadAsync();

    private async Task LoadAsync()
    {
        try
        {
            _loading = true;
            _error = null;
            _customers = await CustomerApi.GetAllAsync();
        }
        catch (Exception ex)
        {
            _error = ex;
            // Let the boundary render the fallback (CardError)
            throw; // rethrow to trip the boundary
        }
        finally { _loading = false; }
    }

    private async Task Retry()
    {
        _boundary?.Recover();   // clear error state in the boundary
        await LoadAsync();      // try again
    }
}

Notes:

  • We rethrow in catch so the boundary flips to error mode.
  • On retry we first call Recover() and then load again.
  • Don’t call Recover() inside rendering loops; use a user action or route change.

Log errors where they happen (without losing context)

The cleanest way to log is to derive from ErrorBoundary and override OnErrorAsync.

CustomErrorBoundary.razor

@inherits ErrorBoundary
@inject ILogger<CustomErrorBoundary> Logger

@if (CurrentException is null)
{
    @ChildContent
}
else if (ErrorContent is not null)
{
    @ErrorContent(CurrentException)
}

@code {
    protected override Task OnErrorAsync(Exception ex)
    {
        // Add tags you care about
        var route = NavigationContext.Path; // available in Blazor Web App/SSR
        Logger.LogError(ex, "UI error at {Route}", route);
        return Task.CompletedTask;
    }
}

Now use CustomErrorBoundary instead of ErrorBoundary.

<CustomErrorBoundary>
    <ChildContent>
        <RiskyChart />
    </ChildContent>
    <ErrorContent>
        <CardError Title="Chart" />
    </ErrorContent>
</CustomErrorBoundary>

Server vs. WASM logging tips

  • Blazor Server: logs go to the server. Add Serilog (or another provider) with sinks (Seq, Elastic, AI). Example in Program.cs:
builder.Host.UseSerilog((ctx, lc) => lc
    .ReadFrom.Configuration(ctx.Configuration)
    .Enrich.FromLogContext());
  • Blazor WASM: logs stay in the browser by default. Options:
  • POST to your API:
public class BrowserLogger : ILogger
{
    private readonly HttpClient _http;
    private readonly string _name;
    public BrowserLogger(string name, HttpClient http) { _name = name; _http = http; }
    public IDisposable BeginScope<TState>(TState state) => default!;
    public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Error;
    public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? ex, Func<TState, Exception?, string> formatter)
        => _ = _http.PostAsJsonAsync("/api/logs", new { Name = _name, Level = logLevel.ToString(), Message = formatter(state, ex), Exception = ex?.ToString() });
}
  • Use the JS Application Insights SDK and push key events via JS interop.

Real project pattern: isolate every card on a dashboard

This small layout kept my page alive even when third‑party feeds misbehaved.

@* Dashboard.razor *@
<div class="row g-3">
  @foreach (var card in Cards)
  {
    <div class="col-md-4">
      <CustomErrorBoundary>
        <ChildContent>
          @card.Render()
        </ChildContent>
        <ErrorContent>
          <CardError Title="@card.Title" />
        </ErrorContent>
      </CustomErrorBoundary>
    </div>
  }
</div>
  • Each card is isolated.
  • One failure does not block others.
  • You still get logs with the exact card title and route.

Async error handling: what boundaries catch (and what they don’t)

Caught if thrown by a wrapped component during rendering or in an event that runs inside that interactive subtree.

Not caught:

  • Exceptions in background tasks started by your component (timers, fire‑and‑forget tasks)
  • Exceptions in awaited JS calls that fault later if you don’t handle the failed Task
  • Errors outside the interactive subtree when using render modes in Blazor Web App

Practical rules

  • Wrap risky UI, but still use try/catch around awaited calls (HttpClient, JS, DB) and log.
  • For fire‑and‑forget work, attach handlers like TaskScheduler.UnobservedTaskException (WASM is limited) and record the error.
  • In Blazor Web App with render modes: place the boundary inside the interactive island or make the app interactive at the root. Otherwise the boundary won’t trip for button‑click errors.

Advanced: one place to process errors without changing every component

Sometimes you want to call a helper from catch blocks and centralize logging/UX. A light pattern is a cascaded error processor.

ProcessError.razor

@inject ILogger<ProcessError> Logger

<CascadingValue Value="this">
    @ChildContent
</CascadingValue>

@code {
    [Parameter] public RenderFragment? ChildContent { get; set; }
    public void Log(Exception ex, string? hint = null)
        => Logger.LogError(ex, "UI error {Hint}", hint ?? "");
}

Wrap your router or page with it and pull it as a [CascadingParameter].

@* App.razor or Routes.razor *@
<ProcessError>
    <Router AppAssembly="typeof(App).Assembly" />
</ProcessError>

Use in a page:

@code {
    [CascadingParameter] public ProcessError? Processor { get; set; }

    private async Task LoadAsync()
    {
        try
        {
            await Service.CallAsync();
        }
        catch (Exception ex)
        {
            Processor?.Log(ex, "LoadAsync");
            throw; // still let the boundary show fallback UI
        }
    }
}

JS interop notes

  • If a JS call fails synchronously (bad args, serialization), it throws a .NET exception right away. Catch it.
  • If it fails later (JS promise rejected), the Task faults. Use try/catch around await js.InvokeAsync(...).
  • Set realistic timeouts or handle OperationCanceledException when calls time out.

Blazor Web App (render modes) specifics

If you use the new render modes:

  • A boundary in a static layout only trips during static server render. It won’t catch a button click error later.
  • Place the boundary inside an interactive component or set a global interactive render mode at the root (HeadOutlet and Routes).

This detail bites a lot of teams on first upgrade.

Checklist for production

  • [ ] Add a layout boundary with Recover() on route change
  • [ ] Wrap risky components with granular boundaries
  • [ ] Use a derived boundary to log with OnErrorAsync
  • [ ] Show friendly custom error UI (no stack traces)
  • [ ] Provide a Retry path that calls Recover()
  • [ ] Use try/catch around awaited calls and JS interop
  • [ ] Decide how to send logs from WASM to the server
  • [ ] In Blazor Web App, place boundaries inside interactive islands or enable global interactivity

FAQ: error boundaries in the real world

Do boundaries catch errors from event handlers?

They catch errors thrown by components they wrap when the component is interactive. For background work and fire‑and‑forget tasks, use try/catch and central logging.

Can I show the exception text?

Only in development. In production, show a generic message. If you need a support code, generate a short id and log it.

Do I still need try/catch?

Yes. Boundaries protect the UI, but you should still guard network/IO and surface friendly messages.

What about SSR and the new render modes?

Place boundaries inside interactive components or set a global interactive mode. A static layout boundary won’t catch later click errors.

How do I test this?

Temporarily throw inside OnInitializedAsync or a click handler of a wrapped component and confirm you see your error UI and logs. Then wire up the Retry button and confirm Recover() works.

Conclusion: keep the page alive, even when parts fail

A tiny wrapper plus a bit of logging gives you crash‑proof pages, better support data, and a quick Retry path. Start by adding a layout boundary, then wrap the risky bits. Your users keep working; ops gets clean logs. Win‑win.

Leave a Reply

Your email address will not be published. Required fields are marked *