Blazor Server Circuit Recovery: Tuning Reconnection and Retention Windows
Blazor Server keeps component state in a server-side circuit. See how client retry limits and server retention periods interact, and how to verify recovery behaviour.
19 Oct 2025, 23:18 UTC

What you are trying to achieve
In a Blazor Server app, every connected browser tab gets its own circuit: a server-side object that holds component state and carries UI updates and event callbacks over a SignalR connection. When the connection drops — laptop sleep, Wi-Fi handoff, a flaky VPN — you want the user to come back to the same screen with the same in-progress state, and you want the server to release the circuit if they never come back. Those are two separate timeouts controlled in two different places, which is where most of the confusion starts.
The short version: the browser decides how long it keeps retrying, and the server decides how long it keeps a disconnected circuit in memory. Tune both, and treat circuit state as volatile rather than durable.
Prerequisites and version assumptions
- .NET 8 SDK, with either a Blazor Web App using the Interactive Server render mode or a Blazor Server app from .NET 6/7. The API names below come from the .NET 6–8 line; confirm them against your target framework before relying on defaults.
- A project you can run locally with
dotnet run. No elevated permissions are required. - Browser developer tools, including the Network tab and the ability to set the tab offline.
Step 1: Separate the two timeouts
Two independent mechanisms decide what happens after a dropped connection:
- Client retry loop. The Blazor script in the browser retries the SignalR connection on an interval. While it retries, the framework shows the built-in reconnect modal. When retries run out, the modal switches to a failed state and the user must reload.
- Server retention. The server keeps a disconnected circuit in memory for a retention period so a reconnect can resume it. When that period expires, or when the retained-circuit cap is reached, the circuit is disposed and a reconnect attempt is rejected.
If the client gives up before the server does, the user sees a reload prompt even though the server still had the circuit. If the server drops the circuit first, the client's retry is rejected and no amount of retrying brings the state back. Aligning the two windows is the whole exercise.
Step 2: Configure server-side retention
Circuit options are registered where the server-side Blazor services are added. In a Blazor Server app, that is AddServerSideBlazor in Program.cs:
builder.Services.AddServerSideBlazor()
.AddCircuitOptions(options =>
{
options.DisconnectedCircuitRetentionPeriod = TimeSpan.FromMinutes(3);
options.DisconnectedCircuitMaxRetained = 100;
});Where to run: edit Program.cs in the project root and restart the app with dotnet run. The values shown are the documented defaults in the .NET 6–8 line; check your framework version's documentation before treating them as fixed. In a .NET 8 Blazor Web App the registration call differs because interactive server components are added through AddInteractiveServerComponents; verify how your template wires circuit options rather than copying this snippet blindly.
Risk: raising DisconnectedCircuitRetentionPeriod keeps abandoned circuits and their component state in memory longer. Worst-case memory scales with the number of users who disconnect without returning, so measure circuit count under load before extending it.
Step 3: Control how long the browser retries
The client retry loop is configured in the host page by starting Blazor manually instead of letting it autostart:
<script src='_framework/blazor.server.js' autostart='false'></script>
<script>
Blazor.start({
reconnectionOptions: {
maxRetries: 30,
retryIntervalMilliseconds: 2000
}
});
</script>maxRetries and retryIntervalMilliseconds are the documented knobs. The product of the two is not a precise wall-clock window — the retry loop is bounded, but exact timing depends on the framework version, so verify empirically rather than promising users a specific number of seconds. For more control, such as saving a draft before the modal appears, supply a custom reconnectionHandler instead of the default options.
Step 4: Make the reconnect state visible
The framework injects an element with the id components-reconnect-modal and toggles state classes on it: components-reconnect-show, components-reconnect-hide, components-reconnect-failed, and components-reconnect-rejected. The distinction that matters to users:
- failed — retries were exhausted; the network may still be down. A reload can work once connectivity returns.
- rejected — the server no longer has the circuit (restart, recycle, or retention expired). Reloading starts a brand-new circuit, so any unsaved in-memory state is gone.
You can supply your own markup with that id in the layout and style it through those classes. The framework already handles visibility, so target the classes for appearance and wording rather than forcing display yourself, then confirm the behaviour in the browser.
Step 5: Observe the lifecycle with a CircuitHandler
A CircuitHandler registered as a singleton receives callbacks for every circuit. This is the cheapest way to see whether a reconnect actually resumed the same circuit:
using Microsoft.AspNetCore.Components.Server.Circuits;
public sealed class LoggingCircuitHandler : CircuitHandler
{
private readonly ILogger<LoggingCircuitHandler> _logger;
public LoggingCircuitHandler(ILogger<LoggingCircuitHandler> logger) => _logger = logger;
public override Task OnCircuitOpenedAsync(Circuit circuit, CancellationToken ct)
{
_logger.LogInformation("Circuit {CircuitId} opened", circuit.Id);
return Task.CompletedTask;
}
public override Task OnConnectionDownAsync(Circuit circuit, CancellationToken ct)
{
_logger.LogWarning("Circuit {CircuitId} connection down", circuit.Id);
return Task.CompletedTask;
}
public override Task OnConnectionUpAsync(Circuit circuit, CancellationToken ct)
{
_logger.LogInformation("Circuit {CircuitId} reconnected", circuit.Id);
return Task.CompletedTask;
}
public override Task OnCircuitClosedAsync(Circuit circuit, CancellationToken ct)
{
_logger.LogWarning("Circuit {CircuitId} closed", circuit.Id);
return Task.CompletedTask;
}
}Register it in Program.cs:
builder.Services.AddSingleton<CircuitHandler, LoggingCircuitHandler>();Keep these callbacks fast. They run on the circuit's path, and blocking work in OnConnectionDownAsync or OnCircuitClosedAsync delays cleanup for that circuit.
Verification
- Run the app with
dotnet runand open it in a browser. - Open developer tools, go to the Network tab, and filter for WebSocket traffic.
- Set the tab offline. Within a moment the reconnect modal should appear, and the log should show the connection-down callback for the current circuit id.
- Restore the network before the retry loop gives up. The modal should hide, and the log should show a reconnect for the same circuit id — that is the evidence that state was preserved.
- Repeat, but stay offline past the server retention period. The modal should move to the rejected state, the circuit-closed callback should fire, and a reload should produce a new circuit id.
Expected checks: circuit ids match across a successful reconnect and differ after a rejection; the modal class changes match the states described above; no unhandled exceptions appear in the server console during either path.
Limitations and things that will still lose state
- Circuit state lives in server memory. An app restart, a recycle, or an explicit connection shutdown discards it regardless of retention settings. Persist anything the user would be upset to retype.
- Interactive WebAssembly components do not share a server circuit. If a page mixes render modes, do not assume state flows between them.
- In a scaled-out deployment behind Azure SignalR Service or a Redis backplane, a reconnect must reach the server instance that owns the circuit. The circuit is not shared state, so verify your routing and backplane configuration rather than assuming any instance can resume it.
- Exact default values and option names vary across .NET versions. Treat the numbers here as documented defaults for the .NET 6–8 line and confirm them in your target framework — this is the item most worth a second look before you ship.
Recovery options
- Reload to a fresh circuit. Simplest and always available; the user loses in-memory state, so pair it with a clear message.
- Save a draft on disconnect. In
OnConnectionDownAsync, or in a customreconnectionHandler, copy form values tolocalStorageor post them to the server so they survive a rejected reconnect. - Re-hydrate on reconnect. In
OnConnectionUpAsync, re-fetch server data or restore the client-side draft and re-render. - Friendly expiry page. Route users to a page that explains what happened and offers a restart link, instead of leaving them on the rejected modal.
Quick reference
| Setting | Where it lives | Documented default (.NET 6–8) | Effect |
|---|---|---|---|
DisconnectedCircuitRetentionPeriod | Server, CircuitOptions | 3 minutes | How long a disconnected circuit is kept so a reconnect can resume it. |
DisconnectedCircuitMaxRetained | Server, CircuitOptions | 100 | Cap on disconnected circuits held in memory. |
maxRetries, retryIntervalMilliseconds | Client, Blazor.start | Framework defaults | How long the browser keeps retrying before showing the failed state. |
components-reconnect-modal | Client DOM | Injected by framework | User-visible state during an outage; classes signal show, failed, or rejected. |
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.