Topic: Memory Leak in Blazor Server App with Radzen Blazor–Generated Code on Ubuntu – Continuous Memory Increase During Page Navigation Between Home and ItemMasters Pages

1. Blazor Server, .NET 10, EF Core 10, PostgreSQL, Radzen Blazor 8.7.3

2. Only 1 table is used.

3. All code is generated by Radzen Blazor Studio without any modifications.

4. Publish in Release mode and deploy to Ubuntu.

5. Monitor the memory usage of the Blazor Server app every 2 seconds on Ubuntu.

6. Switch back and forth between the Home and ItemMasters pages.

7. The memory usage of the Blazor Server app continuously increases, indicating a memory leak somewhere.

8. Why did I perform this test? Because my production environment deployed at a client’s site crashed. The reason was that multiple clients were switching pages, causing the Blazor Server memory to keep increasing, with the app using up to 3.5GB of memory.

9. I cannot determine the exact cause, which is why I conducted this test!

Hi @robbiexgithub,

We cannot determine the cause either by just looking at a screenshot. Fortunately you can use the Radzen.Blazor source code and troubleshoot.

Thanks for the suggestion. Based on the RadzenBlazor source code, I modified the Dispose()method of RadzenDataGrid, and the memory leak is now fixed。 also please help all of us review this.

public override void Dispose()
{
base.Dispose();

if (groups != null)
{
    groups.CollectionChanged -= GroupsCollectionChanged;
}

if (IsJSRuntimeAvailable && JSRuntime != null)
{
    foreach (var column in allColumns.ToList().Where(c => c.GetVisible()))
    {
        JSRuntime.InvokeVoid("Radzen.destroyPopup", $"{PopupID}{column.GetFilterProperty()}");
    }
}

if (expandedItems != null)
{
    expandedItems.Clear();
}

if (childData != null)
{
    childData.Clear();
}

if (selectedItems != null)
{
    selectedItems.Clear();
}

if (rowSpans != null)
{
    rowSpans.Clear();
}

if (columns != null)
{
    columns.Clear();
}

if (allPickableColumns != null)
{
    allPickableColumns.Clear();
}


_value = null;
Data = null;

GC.SuppressFinalize(this);

}

Thanks @robbiexgithub! You can submit pull request!

Dear Enchev, I am convinced that there is a memory leak in the Radzen Blazor Component, with two main manifestations: 1. Repeatedly opening and closing the same page leads to a continuous increase in memory usage. 2. Memory is not released after closing the browser. Both tests were conducted using automatically generated code from Radzen Blazor Studio, without any other modifications. I scanned the entire project with Cursor and had Cursor fix the memory leak points. Cursor provided the following repair report for reference. Meanwhile, if you can confirm the memory leak and fix it, I would be very grateful!

Below is the fixed report for your reference!

Full Fix Report (Memory Leak Sweep + Test Stabilization)

Scope

A full deep scan of Radzen.Blazor components, services, base classes, and demo layouts. The sweep focused on memory leak vectors including: event subscriptions, timers, DotNetObjectReference lifecycle, cancellation tokens, and JS interop resources. Test failures on Windows were also stabilized.

1) Radzen.Blazor/PagedDataBoundComponent.cs

Issue: Old INotifyCollectionChanged subscription was not removed when Data changed.

Before

set
{
    if (_data != value)
    {
        _data = value;

        if (_data != null && _data is INotifyCollectionChanged)
        {
            ((INotifyCollectionChanged)_data).CollectionChanged += OnCollectionChanged;
        }

        OnDataChanged();
        StateHasChanged();
    }
}

After

set
{
    if (_data != value)
    {
        if (_data != null && _data is INotifyCollectionChanged oldCollection)
        {
            oldCollection.CollectionChanged -= OnCollectionChanged;
        }

        _data = value;

        if (_data != null && _data is INotifyCollectionChanged newCollection)
        {
            newCollection.CollectionChanged += OnCollectionChanged;
        }

        OnDataChanged();
        StateHasChanged();
    }
}

2) Radzen.Blazor/RadzenLayout.cs

Issue: ThemeService.ThemeChanged subscribed but never unsubscribed.

Before

public partial class RadzenLayout : RadzenComponentWithChildren
{
    protected override void OnInitialized()
    {
        themeService = ServiceProvider?.GetService<ThemeService>();

        if (themeService != null)
        {
            themeService.ThemeChanged += OnThemeChanged;
        }

        base.OnInitialized();
    }

    private void OnThemeChanged()
    {
        StateHasChanged();
    }
}

After

public partial class RadzenLayout : RadzenComponentWithChildren, IDisposable
{
    protected override void OnInitialized()
    {
        themeService = ServiceProvider?.GetService<ThemeService>();

        if (themeService != null)
        {
            themeService.ThemeChanged += OnThemeChanged;
        }

        base.OnInitialized();
    }

    private void OnThemeChanged()
    {
        StateHasChanged();
    }

    public override void Dispose()
    {
        if (themeService != null)
        {
            themeService.ThemeChanged -= OnThemeChanged;
        }

        base.Dispose();
        GC.SuppressFinalize(this);
    }
}

3) Radzen.Blazor/RadzenToc.razor.cs

Issue: Base RadzenComponent cleanup not executed.

Before

public async ValueTask DisposeAsync()
{
    await UnregisterScrollListenerAsync();
}

After

public async ValueTask DisposeAsync()
{
    await UnregisterScrollListenerAsync();
    Dispose();
}

4) Radzen.Blazor/CookieThemeService.cs

Issue: ThemeService.ThemeChanged never unsubscribed in scoped service.

Before

public class CookieThemeService
{
    public CookieThemeService(IJSRuntime jsRuntime, ThemeService themeService, IOptions<CookieThemeServiceOptions>? options)
    {
        this.jsRuntime = jsRuntime;
        this.themeService = themeService;

        if (themeService != null)
        {
            themeService.ThemeChanged += OnThemeChanged;
        }

        _ = InitializeAsync();
    }
}

After

public class CookieThemeService : IDisposable
{
    public CookieThemeService(IJSRuntime jsRuntime, ThemeService themeService, IOptions<CookieThemeServiceOptions>? options)
    {
        this.jsRuntime = jsRuntime;
        this.themeService = themeService;

        if (themeService != null)
        {
            themeService.ThemeChanged += OnThemeChanged;
        }

        _ = InitializeAsync();
    }

    public void Dispose()
    {
        themeService.ThemeChanged -= OnThemeChanged;
        GC.SuppressFinalize(this);
    }
}

5) Radzen.Blazor/RadzenAIChat.razor.cs

Issue: Old CancellationTokenSource canceled but not disposed.

Before

IsLoading = true;
#if NET8_0_OR_GREATER
await cts.CancelAsync();
#else
cts.Cancel();
#endif
cts = new CancellationTokenSource();

After

IsLoading = true;
var previousCts = cts;
#if NET8_0_OR_GREATER
await previousCts.CancelAsync();
#else
previousCts.Cancel();
#endif
previousCts.Dispose();
cts = new CancellationTokenSource();

6) RadzenBlazorDemos/Shared/MainLayout.razor

Issue: NavigationManager.LocationChanged not unsubscribed.

Before

@inherits LayoutComponentBase

@code {
    protected override void OnInitialized()
    {
        UriHelper.LocationChanged += OnLocationChanged;
    }
}

After

@inherits LayoutComponentBase
@implements IDisposable

@code {
    protected override void OnInitialized()
    {
        UriHelper.LocationChanged += OnLocationChanged;
    }

    public void Dispose()
    {
        UriHelper.LocationChanged -= OnLocationChanged;
    }
}

7) RadzenBlazorDemos/Shared/HomeLayout.razor

Issue: Same as MainLayout.

Before

@inherits LayoutComponentBase

@code {
    protected override void OnInitialized()
    {
        UriHelper.LocationChanged += OnLocationChanged;
    }
}

After

@inherits LayoutComponentBase
@implements IDisposable

@code {
    protected override void OnInitialized()
    {
        UriHelper.LocationChanged += OnLocationChanged;
    }

    public void Dispose()
    {
        UriHelper.LocationChanged -= OnLocationChanged;
    }
}

8) Radzen.Blazor/RadzenNotificationMessage.razor

Issue: Timer handler not detached; delayed close could run after disposal.

Before

Timer? timer;

protected override void OnInitialized()
{
    if (Message?.ShowProgress == true)
    {
        timer = new Timer() { Enabled = true };
        timer.Elapsed += (sender, args) =>
        {
            progress = progress + 100;
            InvokeAsync(StateHasChanged);
        };
    }
    Task.Delay(Convert.ToInt32(Message?.Duration ?? 3000))
        .ContinueWith(r => InvokeAsync(Close));
}

public void Dispose()
{
    timer?.Stop();
    timer?.Dispose();
}

After

Timer? timer;
ElapsedEventHandler? timerElapsedHandler;
CancellationTokenSource? closeCts;

protected override void OnInitialized()
{
    if (Message?.ShowProgress == true)
    {
        timer = new Timer() { Enabled = true };
        timerElapsedHandler = OnTimerElapsed;
        timer.Elapsed += timerElapsedHandler;
    }

    closeCts = new CancellationTokenSource();
    _ = CloseAfterDelayAsync(Convert.ToInt32(Message?.Duration ?? 3000), closeCts.Token);
}

private void OnTimerElapsed(object? sender, ElapsedEventArgs args)
{
    progress = progress + 100;
    _ = InvokeAsync(StateHasChanged);
}

private async Task CloseAfterDelayAsync(int duration, CancellationToken token)
{
    try
    {
        await Task.Delay(duration, token);
        if (!token.IsCancellationRequested)
        {
            await InvokeAsync(Close);
        }
    }
    catch (TaskCanceledException)
    {
        // Ignore
    }
}

public void Dispose()
{
    if (closeCts != null)
    {
        closeCts.Cancel();
        closeCts.Dispose();
        closeCts = null;
    }

    if (timer != null && timerElapsedHandler != null)
    {
        timer.Elapsed -= timerElapsedHandler;
        timerElapsedHandler = null;
    }

    timer?.Stop();
    timer?.Dispose();
    timer = null;
}

9) Radzen.Blazor/Debouncer.cs

Issue: Timer handler not detached; replaced timers could retain references.

Before

private System.Timers.Timer? timer;

public void Debounce(int interval, Func<Task> action)
{
    timer?.Stop();
    timer = null;

    timer = new System.Timers.Timer() { Interval = interval, Enabled = false, AutoReset = false };
    timer.Elapsed += (s, e) =>
    {
        if (timer == null) return;
        timer?.Stop();
        timer = null;
        Task.Run(action);
    };

    timer.Start();
}

public void Dispose()
{
    if (timer != null)
    {
        timer.Stop();
        timer.Dispose();
        timer = null;
    }
}

After

private System.Timers.Timer? timer;
private System.Timers.ElapsedEventHandler? timerElapsedHandler;

public void Debounce(int interval, Func<Task> action)
{
    ClearTimer();

    timer = new System.Timers.Timer() { Interval = interval, Enabled = false, AutoReset = false };
    timerElapsedHandler = (s, e) =>
    {
        if (timer == null) return;
        timer?.Stop();
        timer = null;
        Task.Run(action);
    };
    timer.Elapsed += timerElapsedHandler;

    timer.Start();
}

public void Dispose()
{
    ClearTimer();
}

private void ClearTimer()
{
    if (timer == null) return;

    if (timerElapsedHandler != null)
    {
        timer.Elapsed -= timerElapsedHandler;
        timerElapsedHandler = null;
    }

    timer.Stop();
    timer.Dispose();
    timer = null;
}

10) Radzen.Blazor/Rendering/DialogContainer.razor

Issue: DotNetObjectReference created but not disposed.

Before

public void Dispose()
{
    if (Service != null)
    {
        Service.OnRefresh -= OnRefresh;
    }

    if (Dialog != null)
    {
        Dialog.PropertyChanged -= OnPropertyChanged;
    }

    if (Dialog?.Options != null)
        Dialog.Options.PropertyChanged -= OnPropertyChanged;
}

After

public void Dispose()
{
    if (Service != null)
    {
        Service.OnRefresh -= OnRefresh;
    }

    if (Dialog != null)
    {
        Dialog.PropertyChanged -= OnPropertyChanged;
    }

    if (Dialog?.Options != null)
        Dialog.Options.PropertyChanged -= OnPropertyChanged;

    dotNetReference?.Dispose();
    dotNetReference = null;
}

11) Radzen.Blazor/RadzenDialog.razor

Issue: JS interop handle not reliably disposed; async cleanup not wired.

Before

@implements IDisposable
public void Dispose()
{
    if (Service is null) return;
    Service.OnOpen -= OnOpen;
    Service.OnClose -= OnClose;
    Service.OnSideOpen -= OnSideOpen;
    Service.OnSideClose -= OnSideClose;
}

// existed but component did not implement IAsyncDisposable:
public async ValueTask DisposeAsync()
{
    try
    {
        if (sideDialogResizeHandleJsModule != null)
        {
            await sideDialogResizeHandleJsModule.InvokeVoidAsync("dispose");
        }
    }
    catch { /* Ignore */ }
}

After

@implements IDisposable
@implements IAsyncDisposable
public void Dispose()
{
    if (Service != null)
    {
        Service.OnOpen -= OnOpen;
        Service.OnClose -= OnClose;
        Service.OnSideOpen -= OnSideOpen;
        Service.OnSideClose -= OnSideClose;
    }

    _ = DisposeSideDialogResizeHandleAsync();
}

public async ValueTask DisposeAsync()
{
    Dispose();
    await DisposeSideDialogResizeHandleAsync();
}

private async Task DisposeSideDialogResizeHandleAsync()
{
    if (sideDialogResizeHandleJsModule == null) return;

    try
    {
        await sideDialogResizeHandleJsModule.InvokeVoidAsync("dispose");
    }
    catch { /* Ignore */ }

    try
    {
        await sideDialogResizeHandleJsModule.DisposeAsync();
    }
    catch { /* Ignore */ }

    sideDialogResizeHandleJsModule = null;
}

12) Radzen.Blazor/RadzenDataGrid.razor.cs

Issue: sortDescriptors.CollectionChanged not detached in Dispose().

Before

public override void Dispose()
{
    base.Dispose();

    if (groups != null)
    {
        groups.CollectionChanged -= GroupsCollectionChanged;
    }

    // ...
}

After

public override void Dispose()
{
    base.Dispose();

    if (groups != null)
    {
        groups.CollectionChanged -= GroupsCollectionChanged;
    }

    if (sortDescriptors != null)
    {
        sortDescriptors.CollectionChanged -= SortsCollectionChanged;
    }

    // ...
}

13) Radzen.Blazor.Tests/Markdown/XmlVisitor.cs

Issue: Markdown tests failed on Windows due to \r\n vs \n.

Before

writer = XmlWriter.Create(xml, new XmlWriterSettings { OmitXmlDeclaration = true, Indent = true, IndentChars = "    ", });

After

writer = XmlWriter.Create(xml, new XmlWriterSettings
{
    OmitXmlDeclaration = true,
    Indent = true,
    IndentChars = "    ",
    NewLineChars = "\n",
    NewLineHandling = NewLineHandling.Replace
});

Testing

  • Command: dotnet test "Radzen.Blazor.Tests/Radzen.Blazor.Tests.csproj"
  • Result: Passed
  • Summary: 1625 passed, 0 failed, 0 skipped

Thanks for your suggestions! I would like to point once again that the fastest and easiest way to fix these is to submit pull requests instead posting peaces of code in the forums.