Senior 10 min · March 06, 2026

Blazor Server Circuit Hangs — Scoped DbContext Pitfalls

Scoped DbContext survives Blazor Server renders, causing 30-second hangs after DB failover.

N
Naren Founder & Principal Engineer

20+ years shipping production .NET services in enterprise systems. Drawn from code that ran under real load.

Follow
Production
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Blazor lets you build interactive web UIs entirely in C#, eliminating JavaScript context-switching
  • Components are .razor files mixing HTML + C# logic, self-contained and reusable
  • Two-way binding with @bind syncs form fields to C# properties automatically
  • Blazor Server runs C# on the server with SignalR; WebAssembly runs it in the browser
  • Biggest mistake: thinking Blazor Server and WebAssembly need different coding patterns — they share the same component model
✦ Definition~90s read
What is Blazor Basics?

Blazor Server is a hosting model where your C# component code runs entirely on the server inside an ASP.NET Core process, communicating with the browser over a persistent SignalR WebSocket connection. Each open browser tab gets its own 'circuit' — a server-side session that holds the component tree, DI container scopes, and UI state.

Imagine your TV remote.

When that circuit hangs, it means the SignalR connection is alive but the server-side render loop has stalled, often because a long-running synchronous operation or a deadlocked database query is blocking the thread that processes UI events. This is fundamentally different from a timeout or a dropped connection — the circuit is still allocated on the server, consuming memory and holding open resources like scoped DbContext instances, but it's unresponsive to user input.

Scoped DbContexts are the most common culprit behind circuit hangs in Blazor Server. By default, EF Core DbContext is registered as scoped, which in a typical web API means one context per HTTP request. But in Blazor Server, a circuit can live for minutes or hours, and the scoped DbContext inside that circuit's DI container is never disposed until the user closes the tab or navigates away.

If your component injects a scoped DbContext and performs a query that takes 30 seconds — or worse, acquires a database lock that another circuit's query is waiting on — the entire circuit freezes. No other event handlers on that circuit will execute until the blocking operation completes, because Blazor Server processes all UI events for a circuit on a single synchronization context.

The fix is to avoid injecting scoped DbContexts directly into Blazor components. Instead, inject IServiceScopeFactory and create short-lived scopes for each database operation, or use DbContextFactory to create contexts that you dispose explicitly. For read-only operations, consider registering your DbContext as transient and relying on the factory pattern.

The key insight is that Blazor Server circuits are long-lived sessions, not short-lived HTTP requests — treat your DI lifetimes accordingly. If you're seeing intermittent hangs that correlate with database queries, profile your circuit's thread pool usage and check for unclosed DbContext instances in memory dumps; they're almost always the smoking gun.

Plain-English First

Imagine your TV remote. Normally you need a special remote (JavaScript) to change the channel on a specific TV (the browser). Blazor is like a universal remote that lets you use the same language you already know (C#) to control the browser directly. You stop learning two different languages and just use the one you love — C# does the clicking, the form-filling, and the live updates, all without writing a single line of JavaScript.

What Blazor Server Circuit Hangs Really Are

Blazor Server runs your UI logic on the server over a persistent SignalR connection. Each client gets a circuit — a server-side session that holds component state, DI scopes, and the render tree. The circuit is the unit of failure: if it hangs, the user sees a frozen UI with no error. The root cause is often a scoped DbContext that outlives its intended lifetime. In Blazor Server, the scoped service lifetime matches the circuit, not the request. A DbContext registered as scoped lives for the entire circuit duration — minutes or hours, not milliseconds. This means connections stay open, transactions can stall, and locks accumulate. When the pool exhausts, the circuit blocks waiting for a connection, and the SignalR heartbeat fails. The server sees a disconnected client and tears down the circuit. The user gets a reconnect dialog. The fix is to register DbContext as transient or use IDbContextFactory<T> to create short-lived instances per operation. Never let a scoped DbContext span user interactions.

Scoped != Request in Blazor Server
In ASP.NET Core MVC, scoped matches an HTTP request. In Blazor Server, scoped matches the circuit — which can live for hours. That's the trap.
Production Insight
A team deployed a dashboard that opened a scoped DbContext on page load and kept it alive for the entire user session. After 50 concurrent users, the SQL connection pool (default 100) was exhausted. The 51st user's circuit hung for 30 seconds until the SignalR heartbeat timeout killed it. Rule: never hold a DbContext across async UI events — create and dispose per operation.
Key Takeaway
Scoped DbContext in Blazor Server lives as long as the circuit, not the request.
Always use IDbContextFactory<T> and create short-lived contexts per operation.
A hung circuit is almost always a resource leak — connections, transactions, or locks.
Blazor Server Circuit Hangs: Scoped DbContext Pitfall THECODEFORGE.IO Blazor Server Circuit Hangs: Scoped DbContext Pitfall Flow from circuit creation to hang due to scoped DbContext misuse Blazor Server Circuit Start Per-user circuit with scoped services Scoped DbContext Injection DbContext scoped to circuit lifetime Long-Running Circuit Operations Async tasks hold DbContext open DbContext Disposal Delayed Connection pool exhaustion risk Circuit Hangs UI freezes, no response Fix: Use DbContextFactory Create short-lived DbContext per operation ⚠ Scoped DbContext in Blazor Server causes connection leaks Always inject IDbContextFactory and create instances per operation THECODEFORGE.IO
thecodeforge.io
Blazor Server Circuit Hangs: Scoped DbContext Pitfall
Blazor Basics

Blazor Components — The Single File That Does Everything

In Blazor, a component is a .razor file that contains three things in one place: HTML markup that defines the structure, a @code block that holds C# logic, and optional CSS isolation. This is the core mental model shift — stop thinking in terms of separate HTML files, JavaScript files, and C# controllers. A Blazor component is a self-contained unit of UI.

Every component has a lifecycle. When Blazor first renders a component it calls OnInitializedAsync — this is where you load data from an API or a database. After every parameter change, OnParametersSetAsync fires. The framework tracks which properties are bound to the UI and re-renders only the affected DOM nodes when those values change.

Components are also reusable by design. You define a ProductCard component once and drop <ProductCard Product="myProduct" /> anywhere in your app. Parameters flow down from parent to child via [Parameter] attributes, keeping your UI predictable and testable.

The key reason Blazor components beat raw Razor Pages for interactive UIs is that a Razor Page requires a full HTTP round-trip to reflect any change. A Blazor component updates in-place, in memory, without reloading the page — just like a React component does, but entirely in C#.

ProductCard.razorCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
@* ProductCard.razor — a reusable component that displays one product *@
@* Drop it anywhere with: <ProductCard Product="someProduct" OnAddToCart="HandleAdd" /> *@

<div class="product-card">
    <h3>@Product.Name</h3>
    <p class="price">@Product.Price.ToString("C")</p>   @* 'C' formats as currency *@

    @if (Product.StockCount > 0)
    {
        <!-- Button is only rendered when stock exists -->
        <button class="btn-primary" @onclick="AddToCart">Add to Cart</button>
    }
    else
    {
        <span class="out-of-stock">Out of Stock</span>
    }
</div>

@code {
    // [Parameter] marks a public property that a parent component can set
    [Parameter]
    public required ProductModel Product { get; set; }

    // EventCallback lets the child notify the parent without tight coupling
    [Parameter]
    public EventCallback<ProductModel> OnAddToCart { get; set; }

    // This method is called when the button fires its @onclick event
    private async Task AddToCart()
    {
        // Invoke the parent's handler and pass the product as the argument
        await OnAddToCart.InvokeAsync(Product);
    }

    // Lifecycle hook — runs once after the component first renders
    protected override void OnInitialized()
    {
        // Good place for lightweight sync setup (no data fetching here)
        Console.WriteLine($"ProductCard initialized for: {Product.Name}");
    }
}

// --- ProductModel.cs (put this in your Models folder) ---
public class ProductModel
{
    public int    Id         { get; set; }
    public string Name       { get; set; } = string.Empty;
    public decimal Price     { get; set; }
    public int    StockCount { get; set; }
}
Output
Console (browser dev tools or terminal for Blazor Server):
ProductCard initialized for: Mechanical Keyboard
Rendered HTML (when StockCount > 0):
<div class="product-card">
<h3>Mechanical Keyboard</h3>
<p class="price">$129.99</p>
<button class="btn-primary">Add to Cart</button>
</div>
Pro Tip: EventCallback vs Action
Always use EventCallback<T> instead of Action<T> for component events. EventCallback automatically calls StateHasChanged on the parent after the handler runs, so the parent's UI updates without you lifting a finger. Action<T> skips that step and your UI appears to ignore the event.
Production Insight
EventCallback<T> is critical when the parent updates its own state after a child event. Without it, parents stay stale.
A common production bug: a parent shows an outdated product list after a child 'delete' event because Action<T> was used.
Rule: always prefer EventCallback<T> — it's one less thing to debug at 2 AM.
Key Takeaway
A .razor component is a self-contained unit — markup, logic, lifecycle all in one file.
Data flows down via [Parameter], events flow up via EventCallback.
This pattern mirrors React but in C# with full type safety.

Two-Way Data Binding and Real-Time Form State with @bind

One of the first things you'll want to do in any web app is connect a form field to a C# object and have changes reflect immediately — in both directions. Type in the box, the C# property updates. Change the C# property in code, the box updates. Blazor calls this two-way binding and it's powered by the @bind directive.

Under the hood, @bind on an <input> is syntactic sugar for wiring up the value attribute to a property AND attaching an onchange event handler that writes the new value back. You can be explicit about timing using @bind:event="oninput" if you want the property to update on every keystroke rather than on blur.

This matters for real-world scenarios like live search boxes, character counters, or dependent dropdowns where changing one field should immediately filter the options in another. Without two-way binding you'd write the same plumbing logic yourself every time.

Beyond simple inputs, EditForm is Blazor's built-in form component that wraps DataAnnotations validation. You get client-side validation for free from the same [Required] and [Range] attributes you already use on your models — no duplicate validation rules in JavaScript.

CheckoutForm.razorCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
@page "/checkout"
@* This page demonstrates two-way binding, live character count, and form validation *@

<h2>Checkout</h2>

@* EditForm links the form to the C# model and handles validation lifecycle *@
<EditForm Model="order" OnValidSubmit="HandleValidSubmit">
    <DataAnnotationsValidator />   @* Reads [Required], [Range] etc. from OrderModel *@
    <ValidationSummary />          @* Displays all current validation errors *@

    <div class="form-group">
        <label>Full Name</label>
        @* @bind wires value <-> order.CustomerName two-ways *@
        <InputText @bind-Value="order.CustomerName" class="form-control" />
        <ValidationMessage For="@(() => order.CustomerName)" />
    </div>

    <div class="form-group">
        <label>Order Notes (@notesRemaining chars remaining)</label>
        @* @bind:event="oninput" updates on EVERY keystroke, not just on blur *@
        <textarea @bind="order.Notes" @bind:event="oninput"
                  class="form-control" maxlength="200"></textarea>
    </div>

    <div class="form-group">
        <label>Quantity</label>
        <InputNumber @bind-Value="order.Quantity" class="form-control" />
        <ValidationMessage For="@(() => order.Quantity)" />
    </div>

    <button type="submit" class="btn-primary">Place Order</button>
</EditForm>

@if (orderConfirmed)
{
    <p class="success">Order placed for @order.CustomerName! Qty: @order.Quantity</p>
}

@code {
    // The model the form is bound to — all fields live here
    private OrderModel order = new();
    private bool orderConfirmed = false;

    // Computed property — recalculates every render cycle automatically
    private int notesRemaining => 200 - (order.Notes?.Length ?? 0);

    // OnValidSubmit only fires when ALL DataAnnotations pass
    private async Task HandleValidSubmit()
    {
        // In a real app you'd call an API here
        await Task.Delay(500);  // Simulate a network call
        orderConfirmed = true;
    }
}

// --- OrderModel.cs ---
using System.ComponentModel.DataAnnotations;

public class OrderModel
{
    [Required(ErrorMessage = "Name is required")]
    [StringLength(100, ErrorMessage = "Name too long")]
    public string CustomerName { get; set; } = string.Empty;

    public string? Notes { get; set; }

    [Range(1, 99, ErrorMessage = "Quantity must be between 1 and 99")]
    public int Quantity { get; set; } = 1;
}
Output
When user types in Name field and clicks Place Order with empty name:
Name is required
When form is valid and submitted:
Order placed for Jane Smith! Qty: 3
As user types in Notes textarea (live):
Order Notes (183 chars remaining) <- updates on every keystroke
Watch Out: @bind vs @bind-Value
Use @bind on native HTML elements like <input> and <textarea>. Use @bind-Value (capital V) on Blazor component inputs like <InputText> and <InputNumber>. Mixing them up causes a silent compile warning and the two-way sync breaks — your model updates but the UI doesn't reflect changes made in code.
Production Insight
Using @bind:event="oninput" for a large form with many fields can cause noticeable lag because every keystroke triggers a full render cycle.
If you have 20+ input fields, consider debouncing the updates or using onchange for most fields.
Rule: use oninput only for live-feedback fields like search autocomplete or character counts.
Key Takeaway
@bind is syntactic sugar for value + onchange.
Use @bind:event="oninput" for keystroke-level reactivity.
EditForm + DataAnnotations = validation without JavaScript.
Performance trap: too many oninput bindings can slow rendering.

Blazor Server vs Blazor WebAssembly — Choosing the Right Hosting Model

This is the question every Blazor developer faces first and gets wrong most often. Both hosting models use identical component syntax, so the choice is about execution environment and trade-offs, not about learning different APIs.

Blazor Server runs your C# components on the server. When a user clicks a button, a SignalR message travels from the browser to the server, the C# handler runs, the new virtual DOM diff is calculated on the server, and only the minimal HTML patch is sent back to the browser. The browser itself is just a thin rendering surface. This means startup is near-instant (no big download), you can access databases and file systems directly from component code, and your app works on browsers that don't support WebAssembly. The cost is latency sensitivity — every interaction needs a round-trip, and a server outage affects every connected user simultaneously.

Blazor WebAssembly (WASM) downloads the entire .NET runtime and your compiled DLLs to the browser on first load. After that, all component logic runs locally — zero server round-trips for UI interactions, offline capability, and it can be hosted as static files on a CDN. The trade-off is a larger initial download (typically 2-5 MB compressed) and no direct database access from component code.

A third option, Blazor United (now called Blazor Web App with Auto render mode in .NET 8+), starts components as Server for instant interactivity and then seamlessly transitions to WASM once the runtime downloads in the background — giving you the best of both.

WeatherDashboard.razorCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
@* WeatherDashboard.razor — works identically in both Server and WASM hosting *@
@* The component code is the same; only the Program.cs setup differs *@
@page "/weather"
@inject IWeatherService WeatherService   @* Injected service — works in both models *@

<h2>Weather Dashboard</h2>

@if (isLoading)
{
    <p>Loading weather data...</p>  @* Shown during async fetch *@
}
else if (forecasts == null || !forecasts.Any())
{
    <p>No forecast data available.</p>
}
else
{
    <table class="weather-table">
        <thead>
            <tr><th>Date</th><th>Temp (°C)</th><th>Summary</th></tr>
        </thead>
        <tbody>
            @foreach (var forecast in forecasts)
            {
                <tr>
                    <td>@forecast.Date.ToShortDateString()</td>
                    <td class="@GetTempCssClass(forecast.TemperatureC)">
                        @forecast.TemperatureC
                    </td>
                    <td>@forecast.Summary</td>
                </tr>
            }
        </tbody>
    </table>
}

<button @onclick="RefreshForecasts" disabled="@isLoading">
    @(isLoading ? "Refreshing..." : "Refresh")
</button>

@code {
    private List<WeatherForecast>? forecasts;
    private bool isLoading = true;

    // OnInitializedAsync is the correct hook for async data loading
    // It fires after the first render, preventing the UI from blocking
    protected override async Task OnInitializedAsync()
    {
        await LoadForecastsAsync();
    }

    private async Task RefreshForecasts()
    {
        isLoading = true;
        // StateHasChanged() is NOT needed here — Blazor tracks event handlers automatically
        await LoadForecastsAsync();
    }

    private async Task LoadForecastsAsync()
    {
        isLoading = true;
        forecasts = await WeatherService.GetForecastAsync(DateTime.Today, days: 5);
        isLoading = false;
        // For Blazor Server: the SignalR diff is sent to browser automatically after this
        // For Blazor WASM:   the WASM runtime re-renders locally after this
    }

    // Helper to apply a CSS class based on temperature value
    private string GetTempCssClass(int tempC) => tempC switch
    {
        <= 0  => "temp-freezing",
        <= 15 => "temp-cold",
        <= 25 => "temp-mild",
        _     => "temp-hot"
    };
}

// --- WeatherForecast.cs ---
public record WeatherForecast(DateOnly Date, int TemperatureC, string Summary);
Output
Initial render:
Loading weather data...
After data loads:
Weather Dashboard
Date Temp(°C) Summary
01/06/2025 22 Mild
02/06/2025 -3 Freezing <- gets CSS class 'temp-freezing'
03/06/2025 28 Hot <- gets CSS class 'temp-hot'
04/06/2025 12 Cold
05/06/2025 19 Mild
[Refresh] button
Interview Gold: The Hosting Model Question
Interviewers love asking 'When would you NOT use Blazor Server?' The honest answer: when your users have high-latency connections (mobile networks in remote areas), when you expect thousands of simultaneous users and can't afford per-connection server memory, or when you need offline support. These are real architectural trade-offs, not hypotheticals.
Production Insight
Blazor Server's per-user memory is often underestimated. Each SignalR circuit holds about 200-400 KB just for the circuit, plus state.
For 10,000 concurrent users, that's 2-4 GB just for overhead — before any business data.
Rule: profile your app's memory per circuit with dotnet counters before scaling; WASM is cheaper at scale for public apps.
Key Takeaway
Server and WASM share component code — hosting model is a deployment decision, not a coding one.
Server trades per-user memory for near-zero startup latency.
WASM trades initial download for zero latency after load.
Auto mode in .NET 8+ gives best of both for new apps.

Dependency Injection and Service Lifetimes in Blazor Components

Blazor is built on top of ASP.NET Core's dependency injection container, so you get the same AddScoped, AddTransient, and AddSingleton lifetimes — but their behaviour in Blazor is subtly different from MVC and it catches people out.

In Blazor Server, a Scoped service lives for the duration of the SignalR circuit (the user's connection), not just a single HTTP request. This means a scoped service is effectively per-user and can hold state across multiple component renders and navigations — useful for shopping carts or user preferences. A Singleton is shared across all users on the server, so be careful with mutable state there.

In Blazor WebAssembly there's only one user per browser tab, so Scoped and Singleton behave identically in practice — they both live for the app's lifetime.

The @inject directive is how you pull services into a component. You can also use the [Inject] attribute in the @code block for constructor-style injection. The critical rule: never do real work in a component's constructor. Always use OnInitializedAsync for anything that involves services, because the DI container has fully resolved all dependencies by the time that lifecycle hook fires.

ShoppingCartSidebar.razorCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
@* ShoppingCartSidebar.razor *@
@* Demonstrates @inject, a scoped service, and reacting to external state changes *@

@inject ICartService CartService          @* Scoped: one instance per SignalR circuit *@
@inject NavigationManager NavManager     @* Built-in Blazor service for URL navigation *@
@implements IDisposable                  @* We subscribe to an event, so we must unsubscribe *@

<div class="cart-sidebar">
    <h4>Your Cart (@cartItemCount items)</h4>

    @foreach (var item in cartItems)
    {
        <div class="cart-item">
            <span>@item.ProductName</span>
            <span>x@item.Quantity</span>
            <button @onclick="() => RemoveItem(item.ProductId)">Remove</button>
        </div>
    }

    <p class="cart-total">Total: @cartTotal.ToString("C")</p>
    <button @onclick="GoToCheckout" class="btn-checkout">Checkout</button>
</div>

@code {
    private List<CartItem> cartItems = new();

    // Computed properties — Blazor recalculates these each render, no manual sync needed
    private int     cartItemCount => cartItems.Count;
    private decimal cartTotal     => cartItems.Sum(i => i.LineTotal);

    protected override async Task OnInitializedAsync()
    {
        // Load initial cart state from the service
        cartItems = await CartService.GetItemsAsync();

        // Subscribe to cart change events from other components (e.g., ProductCard)
        // This allows the sidebar to update when ANY component adds a product
        CartService.OnCartChanged += HandleCartChanged;
    }

    // This handler is called by the service when cart changes from OUTSIDE this component
    private async void HandleCartChanged()
    {
        cartItems = await CartService.GetItemsAsync();
        // InvokeAsync ensures StateHasChanged is called on the correct sync context
        // This is CRITICAL when the callback comes from a background thread or another component
        await InvokeAsync(StateHasChanged);
    }

    private async Task RemoveItem(int productId)
    {
        await CartService.RemoveItemAsync(productId);
        // CartService will fire OnCartChanged, which calls HandleCartChanged above
    }

    private void GoToCheckout()
    {
        NavManager.NavigateTo("/checkout");
    }

    // IDisposable — always unsubscribe from events to prevent memory leaks
    public void Dispose()
    {
        CartService.OnCartChanged -= HandleCartChanged;
    }
}

// --- ICartService.cs ---
public interface ICartService
{
    event Action OnCartChanged;
    Task<List<CartItem>> GetItemsAsync();
    Task RemoveItemAsync(int productId);
    Task AddItemAsync(ProductModel product);
}

// --- CartItem.cs ---
public record CartItem(int ProductId, string ProductName, int Quantity, decimal UnitPrice)
{
    public decimal LineTotal => Quantity * UnitPrice;
}
Output
Cart sidebar renders:
Your Cart (2 items)
-------------------
Mechanical Keyboard x1 [Remove]
USB-C Hub x2 [Remove]
-------------------
Total: $189.97
[Checkout]
After clicking Remove on Mechanical Keyboard:
Your Cart (1 items)
-------------------
USB-C Hub x2 [Remove]
-------------------
Total: $59.98
[Checkout]
Watch Out: Thread Context and StateHasChanged
If you call StateHasChanged directly inside an async callback that wasn't initiated by a Blazor event (like a Timer tick or a SignalR hub message), you'll get a runtime exception or a silent no-op. Always wrap it as 'await InvokeAsync(StateHasChanged)' — this marshals the call back to Blazor's sync context and is the correct pattern 100% of the time.
Production Insight
Memory leak from event subscriptions is the #1 DI-related bug in Blazor Server. Each time a user opens a page with a component that subscribes to an event on a singleton service, the component's reference is kept alive — the circuit survives even after navigation.
Fix: always implement IDisposable and unsubscribe, as shown above.
Rule: if you use += on an event, you must use -=.
Key Takeaway
Scoped in Blazor Server = per-circuit (per-user), not per-request.
WASM: scoped and singleton behave the same.
Always use InvokeAsync(StateHasChanged) from external callbacks.
Always implement IDisposable when subscribing to events.

JavaScript Interop: When Blazor Needs the Browser APIs

Blazor covers 95% of UI needs in pure C#, but browsers have APIs that .NET doesn't expose natively: clipboard access, geolocation, camera/microphone, third-party JS libraries like Chart.js or Stripe elements. For these cases, Blazor provides JavaScript interop via the IJSRuntime service.

The pattern is simple: inject IJSRuntime into your component, then call InvokeAsync<T> to call a JavaScript function and get a result. You can also pass .NET object references to JavaScript and call back from JS to C# using DotNetObjectReference. This is how you integrate any JS library without leaving the Blazor ecosystem.

A common pitfall: forgetting that JavaScript interop calls in Blazor Server are serialized over SignalR — they add network latency. In WASM, they're synchronous calls within the same runtime. Always minimize the number of JS interop calls per render.

Another gotcha: JavaScript functions are called asynchronously. If you need to call JS during component initialization, use OnInitializedAsync and await the call. Do not block the constructor or OnInitialized with synchronous .Result — it will deadlock Blazor Server.

ClipboardCopy.razorCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@* ClipboardCopy.razor — uses JavaScript interop to copy text to clipboard *@
@* Demonstrates IJSRuntime injection and calling a JS function *@

@inject IJSRuntime JS

<button @onclick="CopyToClipboard">
    @(copied ? "Copied!" : "Copy Code")
</button>

@code {
    [Parameter] public string TextToCopy { get; set; } = string.Empty;
    private bool copied = false;

    private async Task CopyToClipboard()
    {
        // navigator.clipboard.writeText is available in modern browsers
        await JS.InvokeVoidAsync("navigator.clipboard.writeText", TextToCopy);
        copied = true;
        // Reset the message after 2 seconds
        await Task.Delay(2000);
        copied = false;
    }
}

// Alternative: define a JS function in wwwroot/scripts/app.js and call it by name:
// await JS.InvokeVoidAsync("copyToClipboard", TextToCopy);

// JavaScript function in app.js:
// window.copyToClipboard = (text) => navigator.clipboard.writeText(text);
Output
When button is clicked:
- The method calls navigator.clipboard.writeText via JS interop
- The button shows 'Copied!' for 2 seconds, then reverts to 'Copy Code'
- Works in both Server and WASM modes (with same code)
Pro Tip: Always Wrap JS Calls in try-catch
JavaScript interop can fail silently if the JS function throws an exception or if the browser doesn't support the API. Always wrap InvokeAsync in a try-catch block and handle the error gracefully — show a fallback UI or log to your monitoring system. The clipboard API, for example, requires HTTPS or localhost.
Production Insight
In Blazor Server, every JS interop call is a SignalR round-trip. If you call JS in a tight loop (e.g., formatting 100 list items), you add ~100ms per call — easily noticeable lag.
Batch JS calls where possible: create a single JS function that handles multiple operations.
Rule: keep JS interop calls per user interaction below 5, and never inside a rendering loop.
Key Takeaway
Use IJSRuntime.InvokeAsync for browser APIs not available in .NET.
Blazor Server adds network latency per call — batch when possible.
Always handle JS interop failures with try-catch.
For complex integrations, consider wrapping the JS library in a small JavaScript module and calling it via interop.

Forms and Validation — Why Your Blazor App Will Leak Data Without Proper Error Boundaries

Blazor's EditForm looks simple. You drop in some DataAnnotationsValidator, bind a model, and call it a day. That's how you ship a feature that silently accepts invalid data because you forgot the ValidationSummary or you used the wrong trigger for field validation. The WHY: Blazor validation is opt-in per model — not automatic. If your model doesn't inherit from IValidatableObject or lacks [Required] on a property, Blazor won't stop the submit. The HOW: Always pair DataAnnotationsValidator with ValidationSummary for global errors. Use OnValidSubmit and OnInvalidSubmit to separate success from failure paths. Never trust client-side validation alone; re-validate on the server inside your handler. The production trap is that Blazor Server can swallow validation exceptions in the circuit, leaving users with a broken form and no feedback. Add a try-catch around your submit handler that logs the exception and sets a UI error state. This isn't optional — it's how you stop a silent data corruption bug at 3 AM.

OrderSubmitForm.csharpCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// io.thecodeforge — csharp tutorial

@page "/orders/create"
@using System.ComponentModel.DataAnnotations

<EditForm Model="@newOrder" OnValidSubmit="@HandleValidSubmit" OnInvalidSubmit="@HandleInvalidSubmit">
    <DataAnnotationsValidator />
    <ValidationSummary />

    <label>Quantity</label>
    <InputNumber @bind-Value="newOrder.Quantity" />
    <ValidationMessage For="@(() => newOrder.Quantity)" />

    <button type="submit">Submit</button>
</EditForm>

@code {
    private OrderCreateModel newOrder = new();

    private async Task HandleValidSubmit()
    {
        try
        {
            await OrderService.SubmitAsync(newOrder);
            // Clear form or redirect
        }
        catch (Exception ex)
        {
            // Log ex to your telemetry — don't swallow
            Console.WriteLine($"Order submission failed: {ex.Message}");
            // Set a UI-level error state
            errorState = "Submission failed. Please retry.";
        }
    }

    private void HandleInvalidSubmit()
    {
        // Validation errors already rendered by ValidationSummary
        Console.WriteLine("Form submitted with validation errors.");
    }

    public class OrderCreateModel
    {
        [Required(ErrorMessage = "Quantity is required")]
        [Range(1, 1000, ErrorMessage = "Quantity must be between 1 and 1000")]
        public int Quantity { get; set; }
    }

    private string errorState = string.Empty;
}
Output
On invalid submit: ValidationSummary shows 'Quantity is required'. On valid submit: Order submitted or errorState displayed.
Production Trap: Invisible Circuit Failure
If your OnValidSubmit throws an unhandled exception, Blazor Server will terminate the circuit without telling the user. Wrap every handler in try-catch. Log the exception. Set a visible error on the UI. Your users won't thank you — but your on-call rotation will.
Key Takeaway
EditForm validation is only as strong as your error boundary. Always split submit into valid/invalid handlers and catch exceptions at every level.

Security — Why Your Blazor Server App Is One Circuit Away From Data Leakage

Blazor Server runs your C# on the server and sends UI diffs over a SignalR circuit. That circuit is an open pipe. If you don't authenticate every action, a user can call methods on a component they shouldn't see. The WHY: The circuit persists even after navigation. A compromised client can replay SignalR messages to execute component methods on a page the user left. The HOW: Use [Authorize] at the page level and inside every component that touches sensitive data. But that's not enough — you need to enforce authorization inside your service layer, because a user can manually invoke @onclick handlers from dev tools. Never trust the circuit's state to gate access. Add a CheckAccess() call at the start of every method that reads or writes user data. For roles, pass the current user's claims down to your service via AuthenticationStateProvider. The production trap is thinking that hiding a button with @if (user.IsInRole("Admin")) is enough. It's not — that only hides the UI. The method still exists on the server. If someone calls it, it runs. Verify on every server-side call.

SecureDocumentViewer.csharpCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// io.thecodeforge — csharp tutorial

@page "/documents/{DocumentId:int}"
@attribute [Authorize]
@using Microsoft.AspNetCore.Components.Authorization

@inject IDocumentService DocService
@inject AuthenticationStateProvider AuthState

@if (document != null)
{
    <h3>@document.Title</h3>
    <p>@document.Content</p>
}
else
{
    <p>Access denied or document not found.</p>
}

@code {
    [Parameter] public int DocumentId { get; set; }
    private Document? document;

    protected override async Task OnInitializedAsync()
    {
        var authState = await AuthState.GetAuthenticationStateAsync();
        var user = authState.User;

        if (!user.Identity?.IsAuthenticated ?? true)
        {
            // Redirect or show error — don't even call the service
            return;
        }

        // Pass user identity to the service for server-side check
        document = await DocService.GetDocumentIfAuthorized(DocumentId, user);
    }
}

// In IDocumentService:
public class DocumentService : IDocumentService
{
    public async Task<Document?> GetDocumentIfAuthorized(int documentId, ClaimsPrincipal user)
    {
        var doc = await _repo.GetById(documentId);
        if (doc == null) return null;

        // Check ownership OR role — never trust client-provided data
        if (doc.OwnerId != user.FindFirst(ClaimTypes.NameIdentifier)?.Value &&
            !user.IsInRole("Admin"))
        {
            return null; // Return null, not an exception. Avoid information leakage.
        }

        return doc;
    }
}
Output
Authorized user sees document content. Unauthorized user sees 'Access denied or document not found' — no stack trace, no hint of the document's existence.
Never Do This: Trusting the Client
Hiding a button with @if(user.IsInRole("Admin")) is cosmetic. The server method is still callable via SignalR. Always re-verify authorization at the service layer using the server-side ClaimsPrincipal, not the client's state.
Key Takeaway
Blazor Server circuits are authenticated tunnels, not fortress walls. Re-verify every user action server-side. If the UI hides something, assume a malicious client can still call it.

Purpose — Blazor’s Real Job Isn’t What You Think

Most developers chase Blazor for hot-reload or component reuse. The real purpose is eliminating the JavaScript context switch. In Blazor Server, your C# code runs on the server and pushes UI diffs over a persistent SignalR connection. In Blazor WebAssembly, it runs in the browser on a .NET runtime. Both models free you from writing frontend code in a second language, but they solve different latency and data sensitivity problems. Blazor Server keeps business logic behind a firewall, while WebAssembly moves compute to the client. The purpose is not “build a SPA in C#” — it’s “keep your team speaking one language across the stack without sacrificing interactivity.” When your app needs real-time server state or strict data residency, Blazor Server is the choice. When you need offline capability or cheap static hosting, WebAssembly wins. Pick the purpose first, the hosting model second.

BlazorPurpose.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// io.thecodeforge — csharp tutorial

// The purpose is the hosting model decision
bool needsServerSideState = true;
bool needsOfflineSupport = false;

string model = needsServerSideState switch
{
    true when needsOfflineSupport => "Blazor WASM + Server API",
    true => "Blazor Server",
    _ => "Blazor WASM"
};

Console.WriteLine($"Choose: {model}");
Production Trap:
Choosing Blazor Server for static content apps burns server memory on idle circuits. Use WebAssembly when re-rendering rarely changes.
Key Takeaway
Blazor’s purpose is language unification, not SPA replication. Match the hosting model to your state locality needs.

Prefer Visual Studio or the CLI?

Visual Studio gives you scaffolding wizards, integrated debugging, and a component designer. The CLI gives you speed, repeatability, and CI/CD compatibility. For beginners, Visual Studio reduces setup friction — one click creates a Blazor Server or WebAssembly project with proper launch profiles and NuGet restore. For teams shipping daily, the CLI is mandatory: dotnet new blazorserver -o MyApp produces identical output on every machine, no GUI required. The CLI also unlocks custom templates — you can bake in auth, service registrations, and CSS framework choices via -au Individual or -f net8.0. Visual Studio hides these flags behind menus, slowing repeatable builds. The real answer: use Visual Studio for exploration and one-off prototypes. Use the CLI for every project that will be deployed, reviewed, or shared.

CliQuickStart.csCSHARP
1
2
3
4
5
6
7
8
9
10
// io.thecodeforge — csharp tutorial

// Create a new Blazor Server app with individual auth
dotnet new blazorserver -n MyBlazorApp -au Individual

// Add a component scaffold
dotnet new razorcomponent -n CounterPage -o Pages

// Build and run
dotnet run --urls "https://localhost:7001"
Production Trap:
Visual Studio project templates sometimes pin outdated NuGet versions. Always run dotnet list package --outdated after creation.
Key Takeaway
Use the CLI for production projects to guarantee reproducibility and CI compatibility. Reserve Visual Studio for prototyping.

Before You Start — The Real Blazor Prerequisites

Most tutorials gloss over this, but skipping the right foundation is why your Blazor app crashes in production within an hour. You need .NET 8 SDK or later, plus either Visual Studio 2022 (with the ASP.NET and web development workload) or the .NET CLI. For Blazor WebAssembly, you also need the wasm-tools workload. More critical: understand that Blazor Server demands a persistent WebSocket connection. If your network or proxy strips long-lived connections, every user action hangs. You also need a solid grasp of asynchronous C# — Task, ValueTask, and CancellationToken — because every component lifecycle method runs async by default. Finally, know that Blazor components are not self-contained islands; they inherit the HttpContext from the ASP.NET Core pipeline. Without understanding middleware order and circuit lifetime, your authentication and session state will leak data across users. Install the tools, then study the circuit lifecycle before writing your first component.

CheckTools.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// io.thecodeforge — csharp tutorial
// Verify your development environment is ready
using System;

class Program
{
    static void Main()
    {
        string dotnetVersion = Environment.GetEnvironmentVariable("DOTNET_VERSION");
        if (string.IsNullOrEmpty(dotnetVersion))
            Console.WriteLine("Install .NET 8+ SDK. Use 'dotnet --list-sdks'.");
        else
            Console.WriteLine($".NET {dotnetVersion} detected – good to go.");
    }
}
Output
.NET 8.0.404 detected – good to go.
Production Trap:
Developers often forget the wasm-tools workload for WebAssembly. Without it, the AOT compilation fails silently. Always run dotnet workload install wasm-tools before your first publish.
Key Takeaway
Tools alone don't prevent crashes; understanding the circuit lifecycle and async patterns does.

Getting Started — Beyond the 'dotnet new' Illusion

The default Blazor templates promise a working app in seconds, but they hide critical configuration choices that bite you later. When you run dotnet new blazorserver or blazorwasm, you get a scaffold with a Program.cs that wires up services, authentication, and the circuit handler. The real question isn't 'how' but 'why' those defaults exist. For Blazor Server, AddRazorComponents().AddInteractiveServerComponents() configures the circuit hub. If you don't understand that CircuitOptions.DisconnectedCircuitMaxRetained defaults to 100, your app will silently drop users under load. For WebAssembly, the template adds AddBlazorWebAssemblyServices() which inlines all DLLs into the initial download. That kills your first paint time. The correct approach: override these defaults explicitly. Set CircuitOptions.DetailedErrors = true during development. For WebAssembly, lazy-load assemblies using the LazyAssemblyLoader service. The template is a starting line, not a finish line — always audit the Program.cs before adding your first component.

Program.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
// io.thecodeforge — csharp tutorial
// Override default circuit options for production stability
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents(options =>
    {
        options.DetailedErrors = builder.Environment.IsDevelopment();
        options.DisconnectedCircuitMaxRetained = 50; // prevent resource leak
    });
var app = builder.Build();
app.MapRazorComponents<App>();
app.Run();
Output
(App starts with tuned circuit settings)
Production Trap:
Default DisconnectedCircuitMaxRetained of 100 consumes memory per user. Under 1,000 concurrent users, that's 100,000 circuits retained — a guaranteed OOM crash. Lower it to 50 or use DisconnectedCircuitRetentionPeriod.
Key Takeaway
Default templates are safe for demos, lethal for production. Always override circuit and assembly loading defaults.
● Production incidentPOST-MORTEMseverity: high

Production Outage: Blazor Server Circuit Holds After DB Failover

Symptom
Users see 'Connection lost' messages and cannot interact with the app. The app is still running, SignalR connections are up, but no data refreshes.
Assumption
The database query will throw an exception which Blazor will handle gracefully and show an error page.
Root cause
Blazor Server components use scoped DbContext instances. After a database failover, the DbContext's internal connection pool returns a stale connection. The query silently hangs for 30 seconds (connection timeout), but Blazor's event handler never completes — the circuit stays in a pending state. No error is logged because the timeout exception is swallowed in a fire-and-forget task.
Fix
Implement a retry policy in the DbContext factory using Polly. Add a CancellationToken parameter to all async API calls in components and pass it with a 5-second timeout. In Blazor Server, scoped services live for the entire circuit, so any long-lived DbContext needs explicit connection validation before each query.
Key lesson
  • In Blazor Server, scoped services live across multiple renders — don't assume they reconnect automatically after transient failures.
  • Always use CancellationToken with async calls in Blazor components to prevent hanging circuits.
  • Add a health check endpoint that validates the SignalR circuit and database connectivity, and alert if >5% of circuits are in pending state.
Production debug guideSymptom → Action guide for the most common Blazor Server production problems4 entries
Symptom · 01
Users see 'Reconnecting...' message repeatedly
Fix
Check SignalR connection. Run docker logs on the SignalR hub server. Look for Microsoft.AspNetCore.SignalR warnings. Verify WebSocket connections are stable and not blocked by proxy/firewall.
Symptom · 02
Component renders old data after refresh
Fix
Check if the component is using OnInitializedAsync correctly. The method is called each time the component is rendered, including after user navigation. If you cache data in a scoped service, ensure it's cleared when the user's circuit reconnects.
Symptom · 03
StateHasChanged() called but UI doesn't update
Fix
Confirm you are calling StateHasChanged inside a Blazor event handler or lifecycle method. If called from an external event (timer, service callback), wrap it in await InvokeAsync(StateHasChanged). Otherwise the update is silently ignored.
Symptom · 04
Blazor Server app uses too much memory per user
Fix
Check scoped service lifetimes. Any service registered as Scoped in Blazor Server lives for the entire SignalR circuit. If you inject a large data context or cache, it stays in memory for every user. Move to transient for per-call operations or implement IDisposable to release resources.
★ Blazor Server Debugging Quick ReferenceCommands and checks to diagnose the most common Blazor Server issues in production.
SignalR circuit drops frequently
Immediate action
Check WebSocket connectivity between client and server
Commands
Open browser DevTools → Network → WS tab. Filter by 'negotiate'. Look for 101 status code.
On server: `kubectl logs deployment/blazor-server -n production | grep "SignalR"`
Fix now
Increase SignalR keep-alive interval: services.AddSignalR(o => o.KeepAliveInterval = TimeSpan.FromSeconds(15))
Component not updating after async call+
Immediate action
Check if StateHasChanged is called on correct thread
Commands
Add a console log before StateHasChanged: `Console.WriteLine("StateHasChanged triggered from: " + Environment.CurrentManagedThreadId)`
Inspect the caller: if it's a Timer callback or event handler, wrap StateHasChanged with `await InvokeAsync(StateHasChanged)`
Fix now
Wrap all external callbacks with InvokeAsync(StateHasChanged)
Blazor WebAssembly fails on first load+
Immediate action
Check browser console for `.dll` download errors
Commands
Open DevTools → Network tab, filter by `.dll`. Look for 404 or CORS errors.
Verify that the static files middleware is configured: `app.UseBlazorFrameworkFiles()`
Fix now
Add app.UseStaticFiles() before app.MapFallbackToPage("/_Host")
Blazor Hosting Models at a Glance
Feature / AspectBlazor ServerBlazor WebAssembly
Where C# code runsOn the server (IIS, Kestrel)In the browser (WebAssembly runtime)
First load timeNear-instant — no DLL downloadSlower — downloads ~2–5 MB of .NET runtime
UI interaction latencyNetwork round-trip per eventZero latency — runs locally
Database accessDirect — EF Core works in componentsIndirect — must call an HTTP API
Offline supportNone — requires server connectionFull — runs without a server after load
ScalabilityMemory per active user on serverScales to CDN — server-side stateless
DebuggingVisual Studio debugger works normallyBrowser DevTools + VS WASM debugger
Suitable forLine-of-business internal toolsPublic SPAs, PWAs, offline apps
SignalR dependencyRequired — circuit drops = app freezesNot required for UI interactions
.NET 8 Auto modeStarts as Server, migrates to WASMStarts as Server, migrates to WASM

Key takeaways

1
A .razor component is a self-contained unit of UI
markup, logic, and events in one file. This is not just convenience, it's the architecture: components compose into trees, data flows down via [Parameter], and events bubble up via EventCallback.
2
Two-way binding with @bind is syntactic sugar for wiring up both a value attribute and a change event. Use @bind:event='oninput' when you need keystroke-level reactivity (live search, character counters), and leave it as the default 'onchange' for everything else.
3
Blazor Server and Blazor WebAssembly use identical component syntax
the hosting model is a deployment and architecture decision, not a coding decision. The main trade-off is latency vs. server memory: WASM trades initial download size for zero interaction latency; Server trades per-user memory for instant startup.
4
Always use InvokeAsync(StateHasChanged)
never plain StateHasChanged() — when triggering a re-render from a callback, timer, or background task. And always implement IDisposable to unsubscribe from events. These two rules eliminate the most common Blazor bugs in production.
5
JavaScript interop is necessary for browser APIs, but minimize calls per interaction and always handle failures with try-catch. In Blazor Server, each interop call adds network latency
batch calls when possible.

Common mistakes to avoid

4 patterns
×

Calling StateHasChanged after every await

Symptom
Double-renders, flickering UI, and occasionally a threading exception in Blazor Server (invalid cross-thread access).
Fix
Blazor automatically re-renders after any event handler or lifecycle method completes. Only call StateHasChanged explicitly when a state change happens outside of Blazor's control, such as in a Timer callback, a background Task, or a SignalR hub message handler. Everywhere else, delete that call.
×

Fetching data in OnInitialized instead of OnInitializedAsync

Symptom
The component renders with empty data even though the API call succeeded, or you see 'cannot access disposed object' exceptions.
Fix
If your data fetch is async (any Task-returning method), you must use the async version: OnInitializedAsync. Using the sync version and calling .Result or .GetAwaiter().GetResult() deadlocks Blazor Server's synchronisation context. Always await async calls inside OnInitializedAsync.
×

Not implementing IDisposable when subscribing to events or timers

Symptom
Memory leaks, components that keep responding to events after being navigated away from, or ghost UI updates modifying unmounted components.
Fix
Any component that subscribes to a C# event, starts a System.Timers.Timer, or registers a callback with an injected service must implement IDisposable. In the Dispose() method, unsubscribe every event and stop every timer. In .razor files, declare this with '@implements IDisposable' at the top and add a Dispose() method in the @code block.
×

Using Blazor Server for a public-facing consumer app without proper capacity planning

Symptom
App becomes unresponsive after a small marketing campaign drives 500 concurrent users — server memory spikes and circuits start dropping.
Fix
Estimate memory per circuit (profile with dotnet counters), calculate max users per server instance, and use WASM or Auto mode for public-facing apps. Blazor Server is best for internal line-of-business apps with <1000 concurrent users per server.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What is the difference between Blazor Server and Blazor WebAssembly, and...
Q02SENIOR
Explain how Blazor's component rendering pipeline works — what triggers ...
Q03SENIOR
If a Blazor Server app has 10,000 concurrent users, what are the memory ...
Q01 of 03SENIOR

What is the difference between Blazor Server and Blazor WebAssembly, and what are the specific production scenarios where you would choose one over the other?

ANSWER
Blazor Server runs C# on the server with UI updates streamed over SignalR. Blazor WebAssembly downloads the .NET runtime and runs C# in the browser. Choose Server for internal LOB apps where startup speed is critical and server resource is abundant. Choose WebAssembly for public SPAs, offline-capable apps, or when scaling to thousands of users without per-connection server memory. A third option is Auto mode (.NET 8+), which starts as Server and transitions to WASM once the runtime is cached.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Do I still need JavaScript when using Blazor?
02
Is Blazor WebAssembly slower than Blazor Server for page interactions?
03
Can I use Blazor components inside an existing MVC or Razor Pages app?
04
How do I debug a Blazor Server circuit that keeps disconnecting?
05
What is the best way to handle authentication in Blazor?
N
Naren Founder & Principal Engineer

20+ years shipping production .NET services in enterprise systems. Drawn from code that ran under real load.

Follow
Verified
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
🔥

That's ASP.NET. Mark it forged?

10 min read · try the examples if you haven't

Previous
SignalR for Real-time Apps
8 / 14 · ASP.NET
Next
Minimal APIs in ASP.NET Core