Blazor Server can feel almost magical.
You write a Razor component:
<button @onclick="IncrementCount">
Count: @count
</button>
@code {
private int count;
private void IncrementCount()
{
count++;
}
}
The user clicks the button. C# runs on the server. The browser updates. No APIs, no controllers, no custom JavaScript handlers, no explicit fetch calls. It just works.
Under the hood, though, there's a specific piece of architecture holding this together: the Blazor circuit.
If you're building server-side Blazor apps without a firm grasp on how circuits function, you're flying blind on state management, memory overhead, connection drops, and scaling strategy.
Here is what is actually happening under the hood—and how to design around it.
What Is a Blazor Circuit?
At its core, a circuit is the server-side memory state allocated for a single interactive user session.
While your HTML is rendered in the user's browser, the component instances, event delegates, render trees, and scoped dependency injection containers all live in memory on your ASP.NET Core server. The browser and server talk back and forth over a real-time SignalR channel.
Browser
│
│ SignalR Connection
▼
ASP.NET Core Server
└── Circuit
├── Component instances
├── Render tree & event delegates
├── Scoped DI services
└── In-memory session state
This setup explains why private int count = 10; holds its value across multiple interactions. The component object hasn't been garbage collected—it's still sitting in server RAM awaiting the next event.
The Critical Distinction: Circuit vs. Connection
It’s easy to treat "Circuit" and "SignalR Connection" as synonyms, but confusing the two will lead to architectural blind spots.
The Circuit is the logical application session holding state on the server.
The SignalR Connection is merely the active network pipe transporting messages back and forth.
Circuit (Persists in Server Memory)
└── uses ──> Active SignalR Connection (Transient Network Transport)
Think of it like driving through a dead zone: your phone loses its cell connection, but the app you were using doesn't immediately crash and delete your work.
When a user's Wi-Fi drops, the SignalR connection dies, but Blazor holds the circuit in memory for a configurable grace period. If the browser reconnects before that window expires, it binds to the exact same circuit, and the user picks up right where they left off.
[ Normal State ]
Browser <=== SignalR Connection ===> Existing Circuit
[ Network Drop ]
Browser X (SignalR Lost) X Existing Circuit (Held in Memory)
[ Reconnected ]
Browser <=== New SignalR Conn =====> Existing Circuit (State Intact)
How Scoped Services Actually Work in Server-Side Blazor
If you come from ASP.NET Core MVC or Minimal APIs, you're used to this mental model:
In interactive Blazor Server, that model breaks. A DI Scope is tied to the lifetime of the Circuit, not an individual interaction.
MVC/API Request: [Request Starts] ──> Create Scope ──> [Request Ends] ──> Dispose Scope
Blazor Circuit: [App Opens] ──> Create Scope ══════════════════════> [App Closes] ──> Dispose Scope
When you register a service as AddScoped<T>(), that instance lives as long as the circuit remains alive.
What happens with multiple tabs?
Every browser tab opens a completely new SignalR connection and initializes a brand-new circuit.
Browser Tab 1 ──> Circuit A ──> Scoped Instance A (e.g., Cart State A)
Browser Tab 2 ──> Circuit B ──> Scoped Instance B (e.g., Cart State B)
Tab 1 and Tab 2 operate in completely isolated memory spaces. They do not automatically share scoped DI instances. If you need tab-to-tab synchronization, you have to build it intentionally using tools like the BroadcastChannel API, server-side pub/sub, or dedicated SignalR hubs.
Circuit State Is RAM, Not Persistence
Because circuit state is so effortless to use, it’s tempting to store everything directly in memory. That works fine until real-world hosting scenarios kick in.
Circuit state vanishes if:
The user hits F5 (a hard refresh tears down the old circuit and spins up a new one).
The circuit times out after an extended network disconnection.
The application server restarts or redeploys.
An unhandled exception crashes the circuit.
Treat circuit state like volatile RAM, not a database.
| Good for Circuit State (RAM) | Requires Durable Storage (Database/Cache) |
| Active UI tab selections | Unsaved form drafts |
| Grid pagination & active filters | User preferences & profile settings |
| Multi-step wizard UI progress | Completed domain transactions |
| Temporary UI-only flags | Financial or order processing data |
Memory Overhead and Scaling Realities
In a stateless Web API, 5,000 concurrent requests hit the server, return payloads, and release their allocations.
In Blazor Server, 5,000 active users mean 5,000 active circuits held concurrently in server memory.
ASP.NET Core Server Memory
├── Circuit 1 (Components + RenderTree + Scoped Services)
├── Circuit 2 (Components + RenderTree + Scoped Services)
├── ...
└── Circuit 5000 (Components + RenderTree + Scoped Services)
If a scoped state service blindly buffers a 20 MB dataset per user, 1,000 concurrent active tabs will consume 20 GB of RAM just holding view models. Keep your circuit objects light: store IDs, lightweight DTOs, and pagination limits rather than raw entity graphs.
Common Pitfalls & Architectural Anti-Patterns
1. Injecting DbContext Directly into Components
Injecting a standard scoped DbContext into a long-lived component means that single DbContext instance remains alive for the duration of the circuit. It will track every entity queried, bloat memory, and fail if concurrent asynchronous operations run on the same context.
The Fix: Use IDbContextFactory<TContext> to instantiate short-lived contexts right when you need to execute a query, disposing of them immediately after.
public class CustomerService(IDbContextFactory<AppDbContext> factory)
{
public async Task<Customer?> GetCustomerAsync(int id)
{
await using var db = await factory.CreateDbContextAsync();
return await db.Customers.FindAsync(id);
}
}
2. Leaking User State via Singletons
Because all circuits execute inside the same host process, registering user-specific data as AddSingleton<T>() exposes that state to every single active user on the server. Never store user-specific or session-specific data inside singletons.
3. Letting Unhandled Exceptions Terminate the Circuit
In traditional MVC, an unhandled exception returns a 500 Internal Server Error page, but the user can hit "Back" and keep going. In Blazor Server, an unhandled exception inside a component event handler can leave the circuit's render tree in an undefined state, causing Blazor to terminate the entire circuit.
The Fix: Wrap risky component branches in <ErrorBoundary> tags and handle operational exceptions inside try/catch blocks rather than letting them bubble up unhandled.
<ErrorBoundary>
<ChildContent>
<CustomerEditor />
</ChildContent>
<ErrorContent>
<div class="alert alert-danger">
Unable to load editor. Please refresh or contact support.
</div>
</ErrorContent>
</ErrorBoundary>
Observing Circuit Lifecycles
If you need to track connected sessions, manage custom cleanup, or log connection health, subclass CircuitHandler:
using Microsoft.AspNetCore.Components.Server.Circuits;
public sealed class MetricsCircuitHandler(ILogger<MetricsCircuitHandler> logger) : CircuitHandler
{
public override Task OnCircuitOpenedAsync(Circuit circuit, CancellationToken cancellationToken)
{
logger.LogInformation("Circuit opened: {CircuitId}", circuit.Id);
return Task.CompletedTask;
}
public override Task OnConnectionUpAsync(Circuit circuit, CancellationToken cancellationToken)
{
logger.LogInformation("SignalR connection established for circuit: {CircuitId}", circuit.Id);
return Task.CompletedTask;
}
public override Task OnConnectionDownAsync(Circuit circuit, CancellationToken cancellationToken)
{
logger.LogWarning("SignalR connection lost for circuit: {CircuitId}", circuit.Id);
return Task.CompletedTask;
}
public override Task OnCircuitClosedAsync(Circuit circuit, CancellationToken cancellationToken)
{
logger.LogInformation("Circuit destroyed: {CircuitId}", circuit.Id);
return Task.CompletedTask;
}
}
Register it in your DI container:
builder.Services.AddScoped<CircuitHandler, MetricsCircuitHandler>();
Summary Checklist for Production Readiness
When building out server-side Blazor features, run through these quick design checks:
State Scope: Is this state safe in RAM, or does it need to survive an
F5reload or server redeploy?Data Weight: Am I storing entire dataset tables in circuit memory, or just key IDs and lightweight view state?
EF Core Management: Am I using
IDbContextFactoryinstead of holding long-lived scopedDbContextinstances?Resiliency: Is critical component code wrapped in error boundaries and proper
try/catchblocks so an unexpected error doesn't kill the whole session?Tab Isolation: Does this feature depend on state being shared across tabs? If so, have I implemented an explicit messaging layer?

Comments
Post a Comment