Blazor Server Circuit Scoping: Architecture, Isolation, and Operational Guardrails
Blazor Server's circuit-scoped DI isolates per-user state—but only if you register services correctly, monitor circuit health, and verify disposal. Covers minimal design, trust boundaries, operational checks, and failure modes to prevent state leaks.
07 Aug 2025, 21:51 UTC

The Problem: Shared State in a Long-Lived Connection
Blazor Server keeps a persistent SignalR connection (a circuit) open for each browser tab. Every component instance, every injected service, and every piece of UI state lives inside that circuit's dependency-injection scope. If the scope leaks—because a scoped service holds a DbContext that never gets disposed, or a singleton accidentally captures per-user data—one user's session can bleed into another's, or the server can run out of memory under load. This article lays out the minimal design that keeps circuits isolated, defines the trust boundaries you must enforce, and gives you the operational checks to catch leaks before they hit production.
Requirements Driving the Design
- Per-connection isolation: No mutable state shared across circuits.
- Deterministic cleanup: When the browser closes or the connection drops, all scoped services must be disposed promptly.
- Observable health: The server must detect dead circuits within seconds, not minutes.
- Graceful degradation: A single misbehaving circuit must not starve the thread pool or exhaust the DI container.
Minimal Circuit-Scope Design
The smallest viable architecture registers only the services that truly need per-circuit lifetime as AddScoped inside Program.cs. Everything else stays transient or singleton.
// Program.cs (ASP.NET Core 8+)
var builder = WebApplication.CreateBuilder(args);
// 1. Framework services
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
// 2. Per-circuit business services
builder.Services.AddScoped<CartService>(); // user-specific basket
builder.Services.AddScoped<UserPreferences>(); // UI theme, locale
builder.Services.AddScoped<NotificationHub>(); // SignalR client proxy
// 3. Shared, stateless services
builder.Services.AddSingleton<IProductCatalog, SqlProductCatalog>();
builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();
// 4. Database context—scoped to the circuit, NOT singleton
builder.Services.AddDbContext<AppDbContext>(opt =
opt.UseSqlServer(builder.Configuration.GetConnectionString("Default")),
ServiceLifetime.Scoped);
var app = builder.Build();
// Circuit health middleware (see Operational Checks)
app.UseMiddleware<CircuitHealthMiddleware>();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.Run();
Key point: AddDbContext defaults to Scoped in 8.0, but being explicit prevents accidental singleton registration when someone copies the line into a different project template.
Trust and Data Boundaries
| Boundary | What Crosses It | Enforcement Mechanism |
|---|---|---|
| Circuit ↔ Server Process | Only serialized render batches and SignalR messages | No direct object references; CircuitHandler mediates lifecycle |
| Circuit ↔ Database | DbContext instance (scoped) |
Connection pool limits; IDbContextFactory for background work |
| Circuit ↔ External API | HttpClient from IHttpClientFactory (transient) |
Per-circuit HttpRequestMessage headers via DelegatingHandler |
| Circuit ↔ Singleton Cache | Read-only lookup keys | Cache stores immutable DTOs; writes go through scoped unit-of-work |
Never inject a singleton service into a scoped service if that singleton captures per-user data. The reverse—scoped into singleton—is a compile-time error in the default container, but custom factories can bypass it. Code-review for IServiceProvider.CreateScope() inside singletons.
Operational Checks You Can Run Today
1. Heartbeat Verification (Client-Side)
Blazor sends a ping every 10 seconds by default. Confirm the client receives pongs:
// wwwroot/_framework/blazor.server.js (debug build)
// Open DevTools → Network → WS filter → look for "ping" frames every ~10s
// If frames stop, the circuit is already dead on the server.
Adjust interval in Program.cs only if you have a proxy that kills idle connections faster:
builder.Services.AddServerSideBlazor()
.AddCircuitOptions(o => o.DetailedErrors = true); // also enables longer timeouts
// For custom interval, implement ICircuitHandler and call
// circuit.Client.InvokeAsync("ping") on your own timer.
2. Server-Side Circuit Counter
Expose a lightweight endpoint for your load balancer or Prometheus scrape:
// CircuitHealthMiddleware.cs
public class CircuitHealthMiddleware
{
private readonly RequestDelegate _next;
private readonly ICircuitTracker _tracker; // custom singleton
public CircuitHealthMiddleware(RequestDelegate next, ICircuitTracker tracker)
{ _next = next; _tracker = tracker; }
public async Task InvokeAsync(HttpContext ctx)
{
if (ctx.Request.Path == "/health/circuits")
{
ctx.Response.ContentType = "application/json";
await ctx.Response.WriteAsync(
$"{\"active\":{_tracker.ActiveCount},\"totalCreated\":{_tracker.TotalCreated}}");
return;
}
await _next(ctx);
}
}
// ICircuitTracker implementation
public interface ICircuitTracker { int ActiveCount { get; } long TotalCreated { get; } }
public sealed class CircuitTracker : ICircuitTracker, IDisposable
{
private int _active;
private long _total;
private readonly CircuitHandler _handler;
public CircuitTracker(IServiceProvider sp)
{
_handler = new TrackingHandler(this);
// Register with Blazor's circuit registry
var registry = sp.GetRequiredService<CircuitRegistry>();
registry.AddCircuitHandler(_handler);
}
public int ActiveCount => _active;
public long TotalCreated => _total;
internal void OnCircuitOpened() { Interlocked.Increment(ref _active); Interlocked.Increment(ref _total); }
internal void OnCircuitClosed() { Interlocked.Decrement(ref _active); }
public void Dispose() => /* remove handler from registry */;
private sealed class TrackingHandler : CircuitHandler
{
private readonly CircuitTracker _owner;
public TrackingHandler(CircuitTracker owner) => _owner = owner;
public override Task OnCircuitOpenedAsync(Circuit circuit, CancellationToken ct)
{ _owner.OnCircuitOpened(); return base.OnCircuitOpenedAsync(circuit, ct); }
public override Task OnCircuitClosedAsync(Circuit circuit, CancellationToken ct)
{ _owner.OnCircuitClosed(); return base.OnCircuitClosedAsync(circuit, ct); }
}
}
Run curl http://localhost:5000/health/circuits during load test. Expect active to match concurrent users ± a few seconds.
3. Scoped Service Disposal Assertion (Integration Test)
// Tests/CircuitDisposalTests.cs (xUnit + Microsoft.AspNetCore.Mvc.Testing)
[Fact]
public async Task ScopedServices_Disposed_WhenCircuitCloses()
{
var factory = new WebApplicationFactory<Program>();
var client = factory.CreateClient();
// Establish a circuit via initial render
var html = await client.GetStringAsync("/");
Assert.Contains("blazor.server.js", html);
// Grab the circuit ID from the response header (requires custom middleware)
// Simulate client disconnect by dropping the HttpClient
client.Dispose();
// Allow DI container to run disposal
await Task.Delay(500);
// Verify your scoped service's Dispose was called
var tracker = factory.Services.GetRequiredService<DisposalTracker>();
Assert.True(tracker.LastDisposed < DateTime.UtcNow.AddSeconds(-1));
}
DisposalTracker is a singleton that your scoped CartService notifies in its Dispose(). If the test flakes, you have a leak.
Failure Modes and Mitigations
| Symptom | Root Cause | Mitigation |
|---|---|---|
| Memory climbs linearly with concurrent users | Scoped service implements IDisposable but never gets disposed (circuit handler not registered, or CircuitClosed not firing) |
Add CircuitTracker above; alert when active > expected peak |
DbContext concurrency exceptions under load |
Scoped DbContext reused across parallel component renders in same circuit |
Use IDbContextFactory<AppDbContext> for any background work; keep UI renders on the circuit's scoped context |
| User sees another user's cart | Singleton CartService registered by mistake |
Unit test: resolve CartService from two scopes, assert different instances |
| Circuit reconnect loops after network blip | Server-side CircuitOptions.ReconnectRetries exhausted; client reconnects with new circuit ID |
Persist critical UI state (form drafts) in ProtectedBrowserStorage so new circuit can restore |
When to Change the Design
- Move to WebAssembly: If circuit count exceeds ~5,000 on a single node and you cannot scale out, the per-connection memory tax becomes the bottleneck. Blazor WebAssembly shifts state to the client.
- Adopt
IDbContextFactoryeverywhere: When background jobs (Hangfire, hosted services) need the same schema, scopedDbContextis the wrong lifetime. RegisterAddDbContextFactoryand inject the factory into scoped services. - Introduce per-tenant isolation: Multi-tenant apps often need a
TenantContextscoped to the circuit and validated on every request. Add a middleware that resolvesTenantContextfrom the circuit'sIServiceProviderand throws if the tenant header doesn't match. - Replace SignalR with gRPC-Web: If you need binary payloads or stricter contract versioning, the circuit abstraction stays but the transport changes. The DI scope model remains identical.
Verification Checklist for Your Next Deploy
grep -r "AddSingleton.*Service" --include="*.cs" | grep -v "Test"— confirm no per-user services are singleton.- Run the integration test
ScopedServices_Disposed_WhenCircuitClosesin CI. - Load test with
hey -c 200 -n 10000 https://staging.example.com/and watch/health/circuits; active should never exceed concurrent users by more than 5%. - Enable
DetailedErrors = truein staging only; verify no stack traces leak to client in production.
Limitations
This design assumes the default ASP.NET Core DI container. Third-party containers (Autofac, DryIoc) may have different scoping semantics—verify their InstancePerLifetimeScope maps 1:1 to Blazor's circuit. The heartbeat interval is not configurable via a public API in .NET 8; the TrackingHandler approach is the supported extension point. Finally, the CircuitHealthMiddleware reads an internal CircuitRegistry; if that type becomes internal in a future release, switch to IServiceProvider.GetService<CircuitRegistry>() and guard with #if NET8_0_OR_GREATER.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.