Diagnosing Blazor Server UI Freeze After Network Interruption
Learn how to identify why a Blazor Server app shows stale or frozen UI after a brief network drop, diagnose circuit reconnection issues, and apply fixes that preserve scoped state.
27 Jul 2026, 13:45 UTC

Recognizable condition
Users report that after a short network hiccup (e.g., Wi‑Fi toggle, VPN reconnect, or cellular drop) the Blazor Server UI stops responding or displays outdated values. In the browser console you may see repeated SignalR reconnection attempts, while server logs contain entries such as CircuitFailed or CircuitClientDisconnected. The page does not reload; instead the existing circuit is considered lost and a new circuit is created, causing any scoped services to lose their state.
Cause / diagnostic table
| Observed symptom | Likely cause | What to check |
|---|---|---|
| UI freezes or shows stale data after network drop | SignalR circuit not reconnected within DisconnectedCircuitMaxRetry attempts; server disposes the circuit and creates a new one | Enable Blazor server‑side logging at Debug level; look for CircuitFailed or CircuitClientDisconnected messages and note the time between disconnect and reconnect attempts |
| Repeated reconnect attempts in browser console but UI never recovers | Client‑side SignalR retry delay too short or server‑side retry limits exhausted | Inspect browser console for signalr: Connection disconnected with error and subsequent Attempting to reconnect logs; compare timestamps with server logs |
| Scoped service values reset (e.g., a counter returns to zero) | New circuit creates fresh DI scope; previous scoped instances are lost | Verify that a scoped service (e.g., ICounterService) holds a value before the drop and is reset after reconnection |
Ordered checks
- Enable detailed logging
In
appsettings.Development.json(or production equivalent) set:{ "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore.Components.Server": "Debug" } } }Restart the application and reproduce the network drop.
- Capture server‑side circuit events
Implement a temporary
CircuitHandlerto log lifecycle events:public class DebugCircuitHandler : CircuitHandler { private readonly ILogger _logger; public DebugCircuitHandler(ILogger logger) => _logger = logger; public override Task OnConnectionUpAsync(Circuit circuit, CancellationToken cancellationToken) { _logger.LogInformation("Circuit {CircuitId} connected", circuit.Id); return base.OnConnectionUpAsync(circuit, cancellationToken); } public override Task OnConnectionDownAsync(Circuit circuit, CancellationToken cancellationToken) { _logger.LogWarning("Circuit {CircuitId} disconnected", circuit.Id); return base.OnConnectionDownAsync(circuit, cancellationToken); } }Register it in
Startup.ConfigureServices:services.AddServerSideBlazor() .AddCircuitOptions(options => { /* defaults */ }) .AddHubOptions<CircuitHub>(opts => { opts.AddFilter<DebugCircuitHandler>(); });Review logs for the sequence: disconnect → reconnect attempts → circuit disposal.
- Measure reconnection timing
From the logs, note:
- Time of
CircuitClientDisconnected - Time of first reconnect attempt (client side)
- Time when server logs show a new circuit ID (indicating disposal)
If the interval exceeds the product of
DisconnectedCircuitMaxRetry×DisconnectedCircuitRetryDelay, the server has given up. - Time of
- Verify client‑side SignalR state
Open browser dev tools → Network → filter by
signalr. Look for:- A
disconnectevent followed by multiplePOST negotiateorGET /blazor/hubrequests. - If the requests stop after a few seconds, the client has exhausted its retries.
- A
Fixes tied to findings
1. Adjust circuit retry options
If logs show the server disposed the circuit after a predictable number of retries, increase the allowed attempts or the delay between them.
services.AddServerSideBlazor()
.AddCircuitOptions(options =>
{
// Default: MaxRetry = 10, RetryDelay = 2s
options.DisconnectedCircuitMaxRetry = 20; // try twice as long
options.DisconnectedCircuitRetryDelay = TimeSpan.FromSeconds(5);
});
This gives the client more time to recover from a transient drop before the server abandons the circuit.
2. Preserve scoped state across circuits
When increasing retries is insufficient (e.g., users experience longer outages), store scoped state in an external cache and rehydrate it on a new circuit.
Example: using Redis
- Add the Redis cache package:
- Configure Redis in
Program.cs: - Create a scoped service that reads/writes to Redis:
- Register it as scoped:
- In a component, inject
ICounterServiceand callIncrementAsyncon user actions. The value survives circuit recreation because it is persisted in Redis.
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.0" />
var redisConnection = builder.Configuration.GetConnectionString("Redis");
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = redisConnection;
options.InstanceName = "BlazorServer:";
});
public class CounterService : ICounterService
{
private readonly IDistributedCache _cache;
private const string KeyPrefix = "counter:";
public CounterService(IDistributedCache cache) => _cache = cache;
public async Task GetValueAsync(string userId)
{
var bytes = await _cache.GetAsync(KeyPrefix + userId);
return bytes != null ? BitConverter.ToInt32(bytes) : 0;
}
public async Task IncrementAsync(string userId)
{
var current = await GetValueAsync(userId);
var next = current + 1;
var bytes = BitConverter.GetBytes(next);
await _cache.SetAsync(KeyPrefix + userId, bytes, new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1)
});
}
}
builder.Services.AddScoped<ICounterService, CounterService>();
Limitations: Serialization overhead (only primitive or simple DTOs should be cached), and you must ensure thread‑safe access if multiple users share the same key. Avoid storing large object graphs; instead store IDs and reload heavy data from the primary data store.
3. Implement a custom CircuitHandler for state rehydration
If you prefer to keep the rehydration logic centralized, a custom CircuitHandler can load scoped state from Redis when a circuit comes up and store it when the circuit goes down.
public class StatePreservingCircuitHandler : CircuitHandler
{
private readonly IDistributedCache _cache;
private readonly IServiceScopeFactory _scopeFactory;
private const string StatePrefix = "circuit-state:";
public StatePreservingCircuitHandler(IDistributedCache cache, IServiceScopeFactory scopeFactory)
{
_cache = cache;
_scopeFactory = scopeFactory;
}
public override async Task OnConnectionUpAsync(Circuit circuit, CancellationToken cancellationToken)
{
// Attempt to restore any scoped state stored for this circuit ID
var bytes = await _cache.GetAsync(StatePrefix + circuit.Id, cancellationToken);
if (bytes != null)
{
using var scope = _scopeFactory.CreateScope();
var stateService = scope.ServiceProvider.GetRequiredService();
await stateService.RestoreAsync(bytes, cancellationToken);
}
await base.OnConnectionUpAsync(circuit, cancellationToken);
}
public override async Task OnConnectionDownAsync(Circuit circuit, CancellationToken cancellationToken)
{
using var scope = _scopeFactory.CreateScope();
var stateService = scope.ServiceProvider.GetRequiredService();
var bytes = await stateService.CaptureAsync(cancellationToken);
await _cache.SetAsync(StatePrefix + circuit.Id, bytes, new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30)
}, cancellationToken);
await base.OnConnectionDownAsync(circuit, cancellationToken);
}
}
Register the handler:
builder.Services.AddServerSideBlazor()
.AddHubOptions<CircuitHub>(opts =>
{
opts.AddFilter<StatePreservingCircuitHandler>();
});
Escalation criteria
- If increasing
DisconnectedCircuitMaxRetryandDisconnectedCircuitRetryDelaydoes not reduce the frequency ofCircuitFailedlogs after realistic network throttling (e.g., Slow 3G, 50% packet loss), consider the client‑side network environment as the primary factor and advise users on improving connectivity. - When scoped state must survive outages longer than the adjusted retry window (e.g., >30 seconds), the external‑cache approach is required. If introducing Redis adds unacceptable latency (>100 ms per read/write) or operational complexity, evaluate alternative durable storage (e.g., SQL Server, Cosmos DB) or accept full page reload as a fallback.
- If after implementing a custom
CircuitHandler you still observe state loss, verify that the scoped service you are persisting is truly scoped (not singleton) and that the cache key is unique per user or per circuit. Mis‑scoped caching can cause cross‑user data leakage.
Practical verification
- Reproduce the issue: open Chrome DevTools → Network → set throttling to Slow 3G, toggle the network offline/online, and perform a UI action (e.g., click a button that increments a counter).
- Observe the browser console for SignalR reconnection attempts and the server logs for
CircuitClientDisconnectedfollowed by either a new circuit ID (indicating disposal) or a successful reconnect. - After applying a fix, repeat the steps and confirm:
- The counter retains its pre‑drop value without a full page reload.
- Server logs show no
CircuitFailedentries and a singleCircuitUpafter reconnection. - If using Redis, you can inspect the cache (e.g.,
REDISCLI GET BlazorServer:counter:<userId>) to see the persisted value.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.