Blazor Server Architecture best practices are about three things: state, rendering, and lifecycles. Get those right and your application stays responsive, memory-safe, and easy to reason about. Get them wrong and you will chase ghost updates, leaking circuits, and render storms that no performance tool can explain.
This guide compresses the lessons from production Blazor Server codebases into rules you can apply today. The examples use .NET 10 and C# 14, and they mirror the patterns shipped in the Indotalent product line — Blazor CRM, HRM, and WMS all run on these conventions.
Blazor Server Architecture State Management Done Right
In Blazor Server, the source of truth for interactive state is server memory inside the circuit. But not all state belongs there. Categorize state by lifetime: component state lives in the component, circuit state lives in a scoped service, application state lives in a singleton or a cache, and durable state lives in the database.
- Component state: fields in @code, alive while the component instance exists
- Circuit state: scoped services, shared across components within one circuit
- Application state: singletons, memory caches, distributed stores
- Durable state: EF Core and SQL Server, the source of truth for anything that must survive restarts
The most common bug is storing durable state in a scoped service. A circuit is not a database. If the user refreshes, the scoped service is gone. Persist early, and treat scoped services as caches over the real store rather than as the store itself.
Rendering Control in Blazor Server Architecture
Every re-render sends a diff over SignalR. The cheapest render is the one you never do. Use ShouldRender to skip renders when nothing relevant changed, and split large pages into small components so updates stay localized to the subtree that actually changed.
A search box is the classic example. Re-rendering the entire page on every keystroke is wasteful; re-rendering only the results region is cheap. Here is the pattern:
@inject ICustomerLookup Customers
@implements IDisposable
@code {
private string _search = "";
private string _applied = "";
private CancellationTokenSource? _cts;
protected override bool ShouldRender() => _search != _applied;
private async Task OnSearchChanged(string value)
{
_search = value;
_cts?.Cancel();
_cts = new CancellationTokenSource();
await Task.Delay(300, _cts.Token);
_applied = _search;
Results = await Customers.SearchAsync(_search, _cts.Token);
StateHasChanged();
}
public void Dispose() => _cts?.Dispose();
}
Returning true from ShouldRender only when the search text actually changed prevents the component from re-rendering for unrelated events such as timer ticks or parent re-renders. The cancellation token source is cancelled on every keystroke, so a slow lookup that returns late cannot overwrite newer results.
Component Lifecycle Discipline
Server-side components live as long as the circuit, which can be hours. That makes lifecycle discipline non-negotiable. Create resources in OnInitializedAsync and dispose them in IDisposable or IAsyncDisposable. Do not run blocking work on the UI thread, and do not re-subscribe to the same event on every render.
Two lifecycle mistakes cause most production incidents. The first is a subscription created in the render body, which accumulates one subscription per render until the circuit dies. The second is an async operation that continues after the component has been disposed, which throws ObjectDisposedException deep inside a background continuation. Cancel those operations with the token shown above.
Circuit Resilience and Reconnection
Blazor Server circuits drop when the network blips. By default the client shows a reconnect dialog and the server keeps the circuit alive for a short grace period. Configure the wait to match your application: allow 60 to 120 seconds for reconnection, and persist any critical state so a recovered circuit can resume without data loss.
SignalR lifecycle events such as OnCircuitOpenedAsync and OnCircuitClosedAsync let you track connected users and release circuit-level resources. Use them sparingly; most applications only need the defaults plus CircuitOptions.DetailedErrors enabled during development.
Performance Traps to Avoid
- Never query the database inside the render flow without caching or memoization
- Avoid rendering thousands of rows at once; use paging or virtualization
- Do not leak subscriptions from events, JavaScript interop, or timers
- Keep Singleton dependencies free of scoped state, or you create captive dependencies
Applied together, these practices keep the SignalR payload small, the circuit memory bounded, and the application predictable under load. They are the same practices used to keep Indotalent's CRM, HRM, and WMS responsive in real deployments.
Key Takeaways
- Store state at the right lifetime: component, circuit, application, or database
- Use ShouldRender to skip renders that change nothing
- Dispose every resource you create in a server-side component
- Debounce and cancel long-running operations with a per-circuit cancellation token
- Audit the DI graph and reconnection settings before production
FAQ
When should a component call StateHasChanged?
When it mutates its own state outside the render pipeline, for example after an async result arrives. Inside event handlers the framework re-renders for you, so calling StateHasChanged in OnInitializedAsync is unnecessary and can double-render.
What is a render storm?
A cascade of re-renders triggered by a parent re-rendering children that have not actually changed. ShouldRender guards and component splitting fix it by localizing the diff to the subtree that owns the change.
How do you persist state across a circuit drop?
Store critical data in the database or in a server-backed store before risky operations, then restore it in OnInitializedAsync of the recovered circuit. Treat scoped services as throwaway caches, not durable storage.
Is MudTable virtualization enough for large grids?
For most enterprise grids, yes. Pair it with server-side filtering and paging so only the visible page is fetched, and combine it with ShouldRender guards on the row components.