Skip to main content

Avoiding Common Blazor Server Pitfalls: Scalability and Reconnection Strategies for Production

Blazor Server promises a rich, interactive UI without writing JavaScript, but that convenience comes with a catch: every user interaction depends on an open, persistent SignalR connection to the server. In production, this architecture introduces failure modes that don't exist in traditional server-rendered apps or client-side Blazor WebAssembly. Teams often discover these issues only after deployment—when circuits drop, memory climbs, or the app stops responding under load. This guide focuses on the most common pitfalls we see in Blazor Server projects and gives you practical, tested strategies to avoid them. Why Scalability and Reconnection Matter for Blazor Server Blazor Server applications run all UI logic on the server. Each user session—called a circuit—holds a dedicated copy of the component tree and its state in server memory.

Blazor Server promises a rich, interactive UI without writing JavaScript, but that convenience comes with a catch: every user interaction depends on an open, persistent SignalR connection to the server. In production, this architecture introduces failure modes that don't exist in traditional server-rendered apps or client-side Blazor WebAssembly. Teams often discover these issues only after deployment—when circuits drop, memory climbs, or the app stops responding under load. This guide focuses on the most common pitfalls we see in Blazor Server projects and gives you practical, tested strategies to avoid them.

Why Scalability and Reconnection Matter for Blazor Server

Blazor Server applications run all UI logic on the server. Each user session—called a circuit—holds a dedicated copy of the component tree and its state in server memory. A typical production app with 1,000 concurrent users might consume several gigabytes of RAM just for circuit state, even before business logic adds its own allocations. That's the scalability challenge: memory grows linearly with active users, and each circuit also consumes a thread-pool thread for its synchronization context.

The reconnection problem is equally critical. Blazor Server relies on a persistent SignalR connection. If that connection drops—due to network blips, server restarts, or load balancer timeouts—the user sees a "Reconnecting..." overlay. If reconnection fails, the circuit is destroyed and the user loses all unsaved state. In production, network interruptions are inevitable. Without a robust reconnection strategy, users face data loss and frustration.

Many teams assume that Blazor Server's built-in reconnection UI is sufficient. It's not. The default experience gives users a few seconds to reconnect, but it doesn't attempt to preserve state across server restarts or handle long-lived disconnections gracefully. Similarly, scaling out Blazor Server across multiple servers requires careful configuration of a backplane or Azure SignalR Service; otherwise, reconnections fail because the new server doesn't have the user's circuit state.

Understanding these constraints early helps you make informed decisions about hosting model, state management, and infrastructure. If your application targets thousands of concurrent users or must survive network hiccups without data loss, you need to plan for both scalability and reconnection from the start.

Core Idea: Circuit Lifetime and State Management

The fundamental unit of a Blazor Server application is the circuit. When a user opens a Blazor Server page, the server creates a circuit—a server-side object that holds the component tree, event handlers, and any injected services scoped to that circuit. The circuit lives until the user closes the browser, navigates away, or the connection drops beyond a timeout. During that lifetime, every button click, form input, and UI update travels over the SignalR channel.

This model has two immediate implications. First, server memory is tied to active circuits. If you store large data sets in scoped services or component state, every user consumes proportional memory. Second, the circuit's existence depends on the SignalR connection. If the connection breaks, the server waits for a configurable period (default 120 seconds) before disposing the circuit. During that window, the user sees a reconnection UI. If they reconnect, the circuit resumes; if not, state is lost.

Most production pitfalls stem from mismanaging these two realities. A common mistake is to store per-user data in singleton or scoped services without considering disposal. For example, a scoped service that caches a user's search results might hold onto a large dataset for the entire circuit lifetime, even after the user navigates to a different page. Another frequent issue is ignoring circuit disposal events, which can lead to unclosed database connections, file handles, or background tasks that keep the server alive unnecessarily.

To avoid these problems, adopt a clear state management strategy. Use transient services for short-lived data, scoped services for per-circuit state (but keep them lean), and register services that implement IDisposable or IAsyncDisposable to clean up resources when the circuit ends. The CircuitHandler class lets you hook into circuit lifecycle events—OnCircuitOpenedAsync, OnConnectionUpAsync, OnConnectionDownAsync, and OnCircuitClosedAsync—to manage resources and log diagnostics.

Another key insight is that Blazor Server reconnection does not restore client-side JavaScript state (like canvas contexts or WebSocket connections). If your app relies on JS interop to set up non-trivial client state, you must handle rehydration manually in the OnAfterRenderAsync lifecycle method. This is a subtle but common source of bugs: after reconnection, the server re-renders the component tree, but the browser's JS state is gone, leaving UI elements uninitialized.

How It Works Under the Hood: SignalR, Circuits, and the Render Tree

To debug Blazor Server issues, you need a mental model of the runtime. When a user loads a Blazor Server page, the browser establishes a SignalR connection (WebSocket, Server-Sent Events, or Long Polling) to the server. The server then creates a CircuitHost object, which owns the circuit. The CircuitHost holds a reference to the root component, a Renderer that manages the render tree, and a SynchronizationContext that ensures all UI updates happen on a single logical thread.

Each user interaction sends a message over SignalR to the server. The server processes the event (e.g., a button click), updates the component tree, computes the diff between the old and new render tree, and sends back a batch of UI updates (render batches). The browser applies these updates to the DOM. This round-trip happens for every interaction, which means latency is a factor. In production, high latency or packet loss can make the app feel sluggish, and SignalR may fall back to a less efficient transport.

Memory management is handled by the .NET garbage collector, but circuits themselves are not collected until the circuit is disposed. If you hold references to large objects in component fields or scoped services, those objects survive until the circuit ends. A common scalability killer is storing IEnumerable results from database queries in component state without pagination or disposal. Over time, these accumulate and cause slow memory growth, eventually leading to out-of-memory exceptions on the server.

Reconnection works as follows: when the SignalR connection drops, the client enters a reconnection loop. The server keeps the circuit alive for a configurable period (CircuitOptions.DisconnectedCircuitMaxRetainedMs, default 120 seconds). During this window, if the client reconnects—using the same circuit ID—the server resumes the circuit. If the client fails to reconnect within the window, the circuit is disposed. The client also has a maximum reconnection attempt count (CircuitOptions.MaxDisconnectedCircuitRetries), after which it gives up and reloads the page.

One underappreciated detail: the circuit ID is tied to the SignalR connection ID. If your application runs behind a load balancer that terminates WebSocket connections or uses sticky sessions (required for in-process state), a server restart or scaling event can invalidate the circuit ID. That's why scaling out Blazor Server requires either a SignalR backplane (like Redis) or Azure SignalR Service to share circuit state across servers.

Worked Example: Configuring Reconnection and Circuit Handlers

Let's walk through a concrete example. Suppose you have a Blazor Server app that displays real-time dashboard data. Users expect to see updates every few seconds, and they don't want to lose their filter selections if the network blips. We'll configure two things: a custom reconnection UI and a circuit handler that saves and restores state.

First, override the default reconnection experience. Create a ReconnectionHandler.razor component in your Pages folder:

@implements IDisposable
@inject NavigationManager Navigation

<div id="components-reconnect-modal" class="my-reconnect-modal">
    @if (_state == ReconnectState.Reconnecting)
    {
        <p>Connection lost. Attempting to reconnect...</p>
    }
    else if (_state == ReconnectState.Failed)
    {
        <p>Reconnection failed. <a href="" @onclick="Reload">Reload the page</a>.</p>
    }
    else if (_state == ReconnectState.Rejected)
    {
        <p>Server rejected reconnection. <a href="" @onclick="Reload">Reload the page</a>.</p>
    }
</div>

@code {
    private ReconnectState _state = ReconnectState.Initial;

    protected override void OnInitialized()
    {
        Navigation.RegisterLocationChangingHandler(context =>
        {
            // Prevent navigation during reconnection
            if (_state == ReconnectState.Reconnecting)
                context.PreventNavigation();
            return ValueTask.CompletedTask;
        });
    }

    private void Reload() => Navigation.NavigateTo(Navigation.Uri, forceLoad: true);

    public void Dispose() { }
}

Then, in App.razor, wrap your Router with this component. The CSS class components-reconnect-modal is used by Blazor's JavaScript to show/hide the overlay. You can style it to match your app.

Next, implement a custom CircuitHandler to persist user state on disconnection. Create a class that implements CircuitHandler and register it in Program.cs:

public class PersistingCircuitHandler : CircuitHandler
{
    private readonly IUserStateStore _store;
    private readonly ILogger<PersistingCircuitHandler> _logger;

    public PersistingCircuitHandler(IUserStateStore store, ILogger<PersistingCircuitHandler> logger)
    {
        _store = store;
        _logger = logger;
    }

    public override async Task OnConnectionDownAsync(Circuit circuit, CancellationToken cancellationToken)
    {
        // Save critical state (e.g., filters, form data) to a persistent store
        var circuitId = circuit.Id;
        var state = await GetStateFromCircuitAsync(circuit);
        await _store.SaveStateAsync(circuitId, state);
        _logger.LogInformation("Saved state for circuit {CircuitId}", circuitId);
    }

    public override async Task OnConnectionUpAsync(Circuit circuit, CancellationToken cancellationToken)
    {
        // Restore state on reconnection
        var circuitId = circuit.Id;
        var state = await _store.GetStateAsync(circuitId);
        if (state != null)
        {
            await RestoreStateToCircuitAsync(circuit, state);
            await _store.DeleteStateAsync(circuitId);
            _logger.LogInformation("Restored state for circuit {CircuitId}", circuitId);
        }
    }

    private Task<Dictionary<string, object>> GetStateFromCircuitAsync(Circuit circuit) => ...
    private Task RestoreStateToCircuitAsync(Circuit circuit, Dictionary<string, object> state) => ...
}

Register the handler in Program.cs:

builder.Services.AddScoped<PersistingCircuitHandler>();
builder.Services.AddScoped<CircuitHandler>(sp => sp.GetRequiredService<PersistingCircuitHandler>());

This approach ensures that even if the connection drops and the user reconnects, their dashboard filters and selections are preserved. The state store could be in-memory (for single-server) or a distributed cache (for scaled-out deployments).

Edge Cases and Exceptions

No strategy covers every scenario. Here are edge cases we've seen trip up teams:

Server Restart During Active Session

If the server restarts—due to deployment or crash—all in-process circuit state is lost. Clients will see the reconnection UI, but the server cannot restore circuits because the CircuitHost objects are gone. The only way to survive a restart is to persist critical state externally (as in the example above) and have the client reload after reconnection fails. Even then, the user will lose any unsaved work that wasn't persisted.

Network Partitions and Long Disconnections

The default 120-second circuit retention window may be too short for users on unreliable networks (e.g., mobile users in tunnels). You can increase DisconnectedCircuitMaxRetainedMs in CircuitOptions, but doing so ties up memory longer. A better approach is to combine a longer retention window with a warning to the user that their session may expire.

Multiple Browser Tabs

Each browser tab creates a separate circuit. If a user opens the same app in two tabs, each tab has its own state. That's expected, but it doubles memory consumption. If your app uses ProtectedLocalStorage or cookies to share state, be aware that circuits don't synchronize with each other.

Load Balancer and Sticky Sessions

Without a SignalR backplane, you must configure sticky sessions (also called session affinity) on your load balancer. Otherwise, a reconnection attempt may land on a different server that doesn't have the circuit. Sticky sessions work, but they reduce resilience: if a server goes down, all its circuits are lost. Azure SignalR Service is the recommended solution for multi-server deployments because it decouples circuit state from the server instance.

JavaScript Interop Reinitialization

As mentioned earlier, after reconnection, JavaScript interop objects like chart instances or map widgets need to be recreated. The OnAfterRenderAsync method runs after each render, including after reconnection. Use a flag to detect reconnection and reinitialize JS state:

private bool _isReconnected;

protected override async Task OnAfterRenderAsync(bool firstRender)
{
    if (firstRender)
    {
        await InitializeChart();
    }
    else if (_isReconnected)
    {
        await InitializeChart();
        _isReconnected = false;
    }
}

Set _isReconnected in your circuit handler's OnConnectionUpAsync method.

Limits of the Approach

The strategies described here address common pitfalls, but they have limits. Circuit handlers add complexity. You must design a state serialization scheme that works across restarts, which may involve JSON serialization of complex objects. That can be brittle if your component state includes delegates, streams, or non-serializable types.

Memory management remains a challenge. Even with lean state, Blazor Server's per-circuit overhead (about 50–100 KB per circuit) adds up. For apps with tens of thousands of concurrent users, the memory footprint may still be too high. In those cases, consider Blazor WebAssembly (client-side) or a hybrid model where heavy components run on the server and light ones on the client.

Reconnection is not seamless for all scenarios. If the circuit is disposed before the user reconnects, there's no way to recover state without a full page reload. The user's unsaved form data is lost. To mitigate this, you can auto-save form progress to a server-side store on each change, but that increases network traffic and server load.

Scalability also has a ceiling. Azure SignalR Service helps by offloading connection management, but the app servers still hold circuit state in memory. If you need to handle millions of concurrent users, Blazor Server is probably not the right choice. In that case, consider a client-rendered framework or a microservices architecture where only certain interactions go through Blazor Server.

Finally, testing reconnection scenarios is harder than testing normal flows. You need to simulate network failures, server restarts, and load balancer timeouts. Tools like Fiddler, Network Link Conditioner, or custom middleware that drops connections can help, but they add to your QA effort.

Reader FAQ

How do I increase the reconnection timeout?

Set CircuitOptions.DisconnectedCircuitMaxRetainedMs in Program.cs. For example, builder.Services.AddServerSideBlazor().AddCircuitOptions(options => options.DisconnectedCircuitMaxRetainedMs = 300_000); extends the window to 5 minutes. Also increase MaxDisconnectedCircuitRetries if needed.

Can I use Blazor Server without sticky sessions?

Not if you have multiple servers and no backplane. Without sticky sessions, a reconnection may hit a different server that doesn't have the circuit. Use Azure SignalR Service or a Redis backplane to share circuit state across servers.

What is the best way to scale Blazor Server?

For most production apps, use Azure SignalR Service (or a Redis backplane) and scale out app servers behind a load balancer. Each app server handles a subset of circuits, but SignalR manages the connections. Monitor memory and CPU per server, and set autoscaling rules based on circuit count.

How do I detect circuit disposal in my services?

Register a CircuitHandler and override OnCircuitClosedAsync. Alternatively, inject IOptions<CircuitOptions> to read the retention window, but the handler is cleaner.

Should I use Blazor Server or Blazor WebAssembly for my app?

Blazor Server is best for intranet apps, low-latency networks, and scenarios where you want to keep code server-side. Blazor WebAssembly is better for public-facing apps, offline scenarios, or when server memory is a concern. You can also mix both in a single app using the InteractiveServer and InteractiveWebAssembly render modes in .NET 8+.

Practical Takeaways

Building a production-ready Blazor Server app requires planning for circuit lifetime, memory, and reconnection. Here are the key actions to take:

  1. Implement a custom reconnection UI that gives users clear feedback and a reload option. Don't rely solely on the default overlay.
  2. Use a circuit handler to persist critical state on disconnection and restore it on reconnection. Keep the state store external (database, Redis) if you need to survive server restarts.
  3. Monitor server memory and circuit counts. Set up alerts for high memory usage and circuit disposal rates. Use Application Insights or Prometheus to track circuit lifetime metrics.
  4. Choose the right state management pattern: transient for short-lived data, scoped for per-circuit state (but keep it small), and avoid singletons that accumulate user-specific data.
  5. Test reconnection scenarios early. Simulate network drops, server restarts, and load balancer failovers. Verify that JS interop state is reinitialized after reconnection.
  6. Consider Azure SignalR Service if you plan to scale beyond a single server. It simplifies connection management and removes the need for sticky sessions.
  7. Evaluate your hosting model periodically. If your app's user base grows beyond Blazor Server's practical limits, plan a migration to Blazor WebAssembly or a hybrid approach.

Blazor Server is a productive framework, but its production demands are different from traditional server-rendered apps. By addressing these pitfalls early, you can deliver a reliable, scalable experience that meets user expectations.

Share this article:

Comments (0)

No comments yet. Be the first to comment!