Are you sure your Blazor Server app actually forgets users after they close the browser tab? If your process RAM goes up all day and never really comes back down, there is a good chance you have circuit leaks.
In this article I want to walk you through how Blazor Server uses memory, what a “circuit leak” really is, how to spot leaks, and how to fix them with simple patterns and configuration. I will share the mistakes I made in real projects so you do not have to repeat them.
Quick refresher: how Blazor Server uses memory
Blazor Server is different from a classic MVC/Razor Pages app:
- Each browser connection gets a circuit on the server.
- The component tree, component fields, and some DI services live in memory on the server.
- UI updates are sent over SignalR as small diffs.
So instead of short-lived HTTP requests, you have long-lived in-memory sessions.
That means:
- If a circuit stays alive, its state stays in memory.
- If many circuits stay alive, memory grows with every user and never really shrinks.
This is the root of most “Blazor Server eats all RAM” stories.
What is a circuit leak?
By “circuit leak” I mean a situation where:
- The user left (closed the tab, lost network, navigated away from your site).
- But the server still keeps the circuit and its state reachable, so the GC cannot collect it.
Typical symptoms:
- Process working set grows with traffic and does not fall during quiet periods.
- GC Gen 2 size keeps growing.
- After a few hours or days of traffic, the app becomes slow or the pod/container gets restarted by the host.
In my experience, leaks usually come from references that live longer than they should:
- Static fields with references to per-circuit objects.
- Singleton services that store references to components or per-user state.
- Events, timers,
Taskcontinuations that keep a component or scoped service alive.
The good news: you can fix most of them with a bit of discipline and IDisposable.
Typical memory leak patterns in Blazor Server
Let me show you the patterns I keep seeing in audits.
Event subscriptions without unsubscribe
You have a service like this:
public class NotificationService
{
public event EventHandler<string>? MessageReceived;
public void Publish(string message)
=> MessageReceived?.Invoke(this, message);
}And a component that listens for messages:
@page "/notifications"
@implements IDisposable
@inject NotificationService Notifications
<ul>
@foreach (var message in _messages)
{
<li>@message</li>
}
</ul>
@code {
private readonly List<string> _messages = new();
protected override void OnInitialized()
{
Notifications.MessageReceived += OnMessageReceived;
}
private void OnMessageReceived(object? sender, string message)
{
_messages.Add(message);
InvokeAsync(StateHasChanged);
}
public void Dispose()
{
Notifications.MessageReceived -= OnMessageReceived;
}
}This code is fine only because we implement IDisposable and unsubscribe.
If you forget Dispose here, every component instance stays in the event delegate list of the singleton NotificationService. That singleton lives for the whole app lifetime, so circuits are never collected.
I once debugged an app where one missing -= caused RAM to grow by ~100 MB per hour under modest load.
Singleton keeps per-user state forever
Another classic leak:
public class UserStateStore
{
// bad: keeps references to per-user state in a singleton
private readonly ConcurrentDictionary<string, UserState> _users = new();
public UserState GetOrCreate(string userId)
=> _users.GetOrAdd(userId, _ => new UserState());
}
public class UserState
{
public List<string> Notifications { get; } = new();
}If you never remove entries from _users, then every user who ever connects stays in memory. In Blazor Server this is especially painful because UserState may hold references to components, timers, and other objects.
Instead, prefer scoped services for per-circuit state.
public class CircuitUserState
{
public List<string> Notifications { get; } = new();
}// Program.cs / Startup.cs
builder.Services.AddScoped<CircuitUserState>();Now each circuit gets its own CircuitUserState. When the circuit finishes, the scope is released and the GC can collect everything.
Singletons may still be fine for caches or configuration, but do not store live per-user objects there.
Timers, CTS, and streams not disposed
Any object that holds a callback or references back to your component can keep it alive:
System.Threading.TimerCancellationTokenSourceIAsyncEnumerable<T>with an open stream
Example with a timer:
@implements IDisposable
<p>Now: @_now</p>
@code {
private Timer? _timer;
private DateTime _now = DateTime.Now;
protected override void OnInitialized()
{
_timer = new Timer(_ =>
{
_now = DateTime.Now;
InvokeAsync(StateHasChanged);
}, null, TimeSpan.Zero, TimeSpan.FromSeconds(1));
}
public void Dispose()
{
_timer?.Dispose();
}
}If you skip _timer?.Dispose(), the timer keeps running after the user closes the page, and it keeps the component and circuit in memory.
JS interop object references not released
When you pass .NET objects to JavaScript via IJSRuntime, Blazor can create long-lived references on both sides. If you create many of them and never release, you can leak.
Pattern to watch:
DotNetObjectReference.Create(this)in a component.- No call to
Dispose()on that reference.
Correct pattern:
@implements IAsyncDisposable
@inject IJSRuntime JS
<div id="chat"></div>
@code {
private IJSObjectReference? _module;
private DotNetObjectReference<ChatPanel>? _objRef;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
_module = await JS.InvokeAsync<IJSObjectReference>("import", "./chat.js");
_objRef = DotNetObjectReference.Create(this);
await _module.InvokeVoidAsync("initChat", _objRef);
}
[JSInvokable]
public Task OnMessageFromJs(string message)
{
// ...
return Task.CompletedTask;
}
public async ValueTask DisposeAsync()
{
if (_module is not null)
{
await _module.DisposeAsync();
}
_objRef?.Dispose();
}
}The key parts:
- Component implements
IAsyncDisposable. - JavaScript module is disposed.
DotNetObjectReferenceis disposed.
Implementing IDisposable in Blazor components
Blazor calls Dispose / DisposeAsync for components that are part of the circuit when the circuit ends. You just have to implement the interface.
Synchronous dispose
Use IDisposable when you only need synchronous cleanup (unsubscribe, stop timers, free small managed resources):
@implements IDisposable
@code {
private readonly CancellationTokenSource _cts = new();
protected override async Task OnInitializedAsync()
{
await LongRunningWorkAsync(_cts.Token);
}
public void Dispose()
{
_cts.Cancel();
_cts.Dispose();
}
private async Task LongRunningWorkAsync(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
// do some work...
await Task.Delay(TimeSpan.FromSeconds(5), token);
}
}
}Asynchronous dispose
Use IAsyncDisposable when cleanup needs await (for example, disposing JS modules or async streams):
@implements IAsyncDisposable
@inject IJSRuntime JS
@code {
private IJSObjectReference? _module;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
_module = await JS.InvokeAsync<IJSObjectReference>("import", "./clock.js");
await _module.InvokeVoidAsync("startClock");
}
public async ValueTask DisposeAsync()
{
if (_module is not null)
{
await _module.InvokeVoidAsync("stopClock");
await _module.DisposeAsync();
}
}
}Combine both
If you need both, you can implement both interfaces and route them to one place:
@implements IDisposable
@implements IAsyncDisposable
@code {
private bool _disposed;
public void Dispose()
{
DisposeAsync().AsTask().GetAwaiter().GetResult();
}
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
// async cleanup here
await Task.CompletedTask;
}
}This pattern is handy when a component might be used in places that call only IDisposable.
Structuring services to avoid leaks
A lot of leaks come from service design, not the components themselves. Some simple rules help a lot.
Use scoped for per-circuit state
If the state belongs to a user session (cart, filters, step in a wizard), make the service scoped:
public class CartState
{
public List<CartItem> Items { get; } = new();
}
builder.Services.AddScoped<CartState>();Then inject it in components:
@inject CartState Cart
<p>Total items: @Cart.Items.Count</p>When the circuit ends, the whole scope goes away, including CartState.
Keep singletons stateless or use weak references
Singletons are good for:
- Configuration
- Shared caches with size limits
- Pure services without user-specific fields
If you really must store something that points to per-user objects, think twice. At minimum, track lifetimes and remove entries actively, or use WeakReference.
But most of the time, you can redesign the code and move per-user state to a scoped service.
Be careful with static events and static caches
Static events are global and live until process exit. If you add handlers from components or scoped services, they will leak.
Static caches can be useful (for example, cache lookup data from database), but:
- Limit size.
- Do not store per-user objects.
- Do not store whole component instances.
Detecting memory problems in Blazor Server
You do not need a full profiler to see that something is wrong. Start with simple checks.
Monitor process memory
Add a simple background service that logs memory usage:
public class MemoryLogService : BackgroundService
{
private readonly ILogger<MemoryLogService> _logger;
public MemoryLogService(ILogger<MemoryLogService> logger)
{
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var process = Process.GetCurrentProcess();
var workingSet = process.WorkingSet64 / 1024 / 1024;
var gcMemory = GC.GetTotalMemory(false) / 1024 / 1024;
_logger.LogInformation(
"Memory: WorkingSet={WorkingSet}MB, GCTotal={GcTotal}MB",
workingSet, gcMemory);
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
}
}
}Then register it:
builder.Services.AddHostedService<MemoryLogService>();If your logs show a continuous upward trend even when the system is idle, you likely have a leak.
Use dotnet-counters and friends
On a developer machine or staging environment you can use:
dotnet-countersto watch GC heaps and allocations.- Visual Studio diagnostic tools or JetBrains dotMemory to inspect object graphs.
A simple way to test:
- Start the app.
- Warm it up with some traffic (you can use a simple script that opens multiple tabs and performs a few actions).
- Close all clients.
- Wait a few minutes and watch GC and process memory.
If things do not come down to a steady level, check for the patterns described earlier.
Dumping the heap
If you are comfortable with memory tools, take a heap dump when the app is large and another one after a forced GC, then compare.
Look for:
- Many instances of your components still alive.
- Many
CancellationTokenSource,Timer, or service instances which should be gone.
This is where you usually find the static events, singletons with per-user dictionaries, or forgotten timers.
Configuring circuit limits for production
Even with perfect code, you should set limits so a single bad client or wave of traffic cannot break the server.
Blazor Server gives you CircuitOptions that you can configure when you add server-side Blazor.
Basic circuit options
In Program.cs:
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddServerSideBlazor()
.AddCircuitOptions(options =>
{
// keep less disconnected circuits to control memory
options.DisconnectedCircuitMaxRetained = 50; // default is higher
options.DisconnectedCircuitRetentionPeriod = TimeSpan.FromMinutes(2);
// avoid unbounded pending renders if client stops acknowledging
options.MaxBufferedUnacknowledgedRenderBatches = 10;
// turn off detailed errors in production (smaller payloads)
options.DetailedErrors = false;
// avoid hanging forever on broken JS interop calls
options.JSInteropDefaultCallTimeout = TimeSpan.FromSeconds(10);
});Key ideas:
DisconnectedCircuitMaxRetainedandDisconnectedCircuitRetentionPeriodcontrol how many disconnected circuits the server keeps in memory for fast reconnection. In production, keep these values modest.MaxBufferedUnacknowledgedRenderBatchesstops the server from buffering unlimited UI diffs if the client does not confirm them.JSInteropDefaultCallTimeoutprevents stuck JS calls from holding circuits forever.
SignalR transport settings
Under the hood, Blazor Server uses a SignalR hub. You can also tune its options:
builder.Services
.AddSignalR()
.AddHubOptions<Microsoft.AspNetCore.Components.Server.Circuits.CircuitHub>(options =>
{
options.MaximumParallelInvocationsPerClient = 4;
options.EnableDetailedErrors = false;
});This helps limit how much work a single client can push to the server at once.
Environment-specific configuration
Use your configuration system to set different values for development and production.
Example using appsettings.json:
{
"Blazor": {
"DisconnectedCircuitMaxRetained": 200,
"DisconnectedCircuitRetentionMinutes": 15
}
}Read these values in Program.cs and apply to CircuitOptions. In development you might keep more circuits and for longer to make browser refresh and reconnect smoother. In production, you keep them lower to control RAM.
Practical checklist for your Blazor Server app
Here is a checklist I now run for every Blazor Server project:
Components
- Any component that subscribes to events implements
IDisposableorIAsyncDisposable. - Any component that creates timers,
CancellationTokenSource, orDotNetObjectReferencedisposes them.
Services
- Per-user state is in scoped services, not singletons.
- Singletons do not store component instances or long-lived per-user objects.
- Static caches have size limits and store only data, not live objects.
JS interop
- Every
DotNetObjectReference.Createhas a matchingDispose. - JS modules (
IJSObjectReference) are disposed when the component is removed.
Configuration
CircuitOptionsare set with realistic limits.JSInteropDefaultCallTimeoutis set to a sensible value.
Diagnostics
- Background service logs memory over time.
- There is a simple plan to take heap dumps or run
dotnet-counterswhen needed.
If you go through this list carefully, you will catch 90% of circuit leaks.
FAQ: Blazor Server memory and circuit leaks
Yes, but you must respect the fact that each user consumes server memory for the whole session. If you keep components lean, put per-user state into scoped services, and configure circuit limits, Blazor Server can handle serious load. You also need enough servers or instances to match your expected concurrent users.
Short spikes are normal. What should worry you is:
– A steady increase in working set and GC heap over many hours.
– No clear “plateau” even during periods of low or zero traffic.
If memory keeps climbing with no sign of stabilizing, take it as a leak until you prove otherwise.
Dispose on Blazor components myself?No. The framework calls Dispose / DisposeAsync when the component is removed from the render tree or when the circuit ends. Your job is only to implement the interface and free resources there.
HttpClient in Blazor Server without leaks?Yes. The recommended approach is to register HttpClient as a singleton using the factory, and use it for many requests. Just make sure you dispose any streams you get from responses and do not store large response content in long-lived objects.
Not always. Many small objects from thousands of circuits can hurt just as much. For example, small per-user lists or view models that never get collected will slowly use more and more RAM.
Blazor WebAssembly moves most state to the browser, so server memory usage is lower per user. But it also has its own limits and trade-offs. If your Blazor Server app leaks, I strongly suggest fixing the leaks instead of switching platform just to mask them.
Conclusion: keep your circuits tidy and your RAM happy
Blazor Server makes it really easy to build rich apps fast, but the price is that you must think about memory like a backend developer and a frontend developer at the same time.
If you:
- Make components clean up after themselves with
IDisposable/IAsyncDisposable. - Keep per-user state in scoped services instead of singletons and statics.
- Configure circuit limits and monitor memory.
then you can run Blazor Server safely in production without watching your pods fall over from memory pressure.
My suggestion: pick one Blazor Server app you own today and run through the checklist from this article. How many places did you find where an event subscription or timer is never disposed? Share your findings and tricks in the comments – other developers will thank you.
