Why Memory Leaks Are a Silent Killer in Mobile Apps
If you've ever watched your .NET MAUI app slowly grind to a halt after a few minutes of use, there's a decent chance memory leaks are to blame. Unlike desktop apps that can coast on gigabytes of spare RAM, mobile devices run under pretty tight memory constraints. A leak that grows by just a few megabytes per navigation cycle? On older hardware, that'll make your app unusable surprisingly fast.
What makes .NET MAUI particularly tricky is the layered architecture. Your managed C# code runs alongside native platform views through a handler system, and that creates multiple places where references can get retained unexpectedly. On Android, the garbage collector has to coordinate with the Java runtime. On iOS, you're dealing with reference counting semantics that don't natively handle cycles. Fun times.
In this guide, I'll walk you through how memory management actually works in .NET MAUI, how to detect leaks with practical tools, and how to fix the most common causes — with working code for each scenario.
How Memory Management Works in .NET MAUI
Before you go hunting for leaks, it helps to understand how the .NET garbage collector (GC) handles memory in a cross-platform mobile context.
The .NET Garbage Collector on Mobile
The .NET GC uses a generational collection strategy with three generations:
- Generation 0: Short-lived objects like local variables and temporary allocations. Collected frequently.
- Generation 1: Objects that survived one GC cycle. Collected less often.
- Generation 2: Long-lived objects such as singletons and cached data. Collected infrequently with full GC passes.
Here's something that trips people up: the GC can detect and collect cyclic references between managed objects. If ObjectA references ObjectB and vice versa, but nothing else references either, both get collected. So not all circular references cause leaks — that's a common misconception worth clearing up early.
Managed vs. Unmanaged Resources
A memory leak happens when an object can never be collected because a strong reference chain leads back to a GC root (a static field, an active thread, or the app itself). In .NET MAUI, this typically occurs through:
- Managed references: Event subscriptions, binding contexts, and parent/child relationships in the visual tree.
- Unmanaged resources: Native platform views, file handles, and platform API objects that need explicit disposal via
IDisposable.
The Handler Architecture Complication
.NET MAUI maps cross-platform controls to native platform views through handlers. Each handler holds a reference to both the virtual (cross-platform) view and the native (platform) view.
Here's the kicker: the DisconnectHandler method — which cleans up native resources — is intentionally not called automatically by .NET MAUI. You have to invoke it yourself at the right point in your app's lifecycle. I honestly think this is one of the most surprising design decisions for developers coming from other frameworks.
Common Causes of Memory Leaks in .NET MAUI
Based on .NET MAUI GitHub issues, community discussions, and patterns I've seen in production apps, these are the most frequent culprits.
1. Event Subscriptions to Long-Lived Objects
When you subscribe to an event, the event source holds a strong reference to the subscriber through its delegate. If the event source outlives the subscriber, that subscriber can never be garbage collected.
// BAD: MessagingCenter (or WeakReferenceMessenger) subscription in a page
// that is never unsubscribed
public partial class DetailPage : ContentPage
{
public DetailPage()
{
InitializeComponent();
// App-level event — the App lives forever, so this page will too
App.Current.RequestedThemeChanged += OnThemeChanged;
}
private void OnThemeChanged(object sender, AppThemeChangedEventArgs e)
{
// Handle theme change
}
}
Now, subscribing to a Button.Clicked event on the same page is totally fine — both the button and the page share the same lifetime, and the GC handles the cycle. The problem comes when you subscribe to events on long-lived objects like App.Current, static services, or singleton ViewModels.
2. Visual Tree Reference Propagation
This one is honestly the most insidious cause of leaks in .NET MAUI. Views hold references to their parent via the Parent property, and containers hold references to children via Content, Children, and ItemsSource. The nasty part? If any single view on a page leaks, the entire page and all its controls stay in memory.
A single leaky third-party control or an unfixed framework bug can root an entire page tree. This is exactly why monitoring matters — you need to catch these early before they snowball.
3. Missing DisconnectHandler Calls
Certain .NET MAUI controls — especially ListView, CollectionView, CarouselView, WebView, and Border — subscribe to platform-level events in their handlers. These subscriptions only get cleaned up when DisconnectHandler() is explicitly called.
// The CarouselView handler subscribes to native scroll events.
// Without explicit disconnection, the native view holds a reference
// to the managed handler, which holds a reference to the page.
// This keeps the entire page in memory.
4. Static and Singleton References
Any reference from a static field or a singleton service to a page, view, or ViewModel creates a GC root that prevents collection. This one seems obvious, but it shows up in production code more often than you'd think.
// BAD: Static reference to a page-scoped ViewModel
public static class ViewModelLocator
{
// This ViewModel — and everything it references — lives forever
public static DetailViewModel Detail { get; set; }
}
5. BindingContext Not Cleared
When a page gets popped from the navigation stack, its BindingContext retains a reference to the ViewModel. If that ViewModel subscribes to services or holds large data collections, everything stays in memory until the page itself is collected — which may never happen if another leak is holding the page. It's a cascading problem.
How to Detect Memory Leaks in .NET MAUI
You can't fix what you can't see. Detection is always the first step, so let's cover the most practical approaches.
Method 1: Finalizer Logging
The simplest and (in my experience) most effective starting point is adding destructors to your pages and ViewModels. If the finalizer never runs after navigating away, the object is leaking. Simple as that.
public partial class DetailPage : ContentPage
{
public DetailPage()
{
InitializeComponent();
}
~DetailPage()
{
Debug.WriteLine($"[GC] DetailPage finalized at {DateTime.UtcNow}");
}
}
public class DetailViewModel
{
~DetailViewModel()
{
Debug.WriteLine($"[GC] DetailViewModel finalized at {DateTime.UtcNow}");
}
}
To trigger collection and actually see results, add a debug button that forces garbage collection:
private void OnForceGCClicked(object sender, EventArgs e)
{
for (int i = 0; i < 5; i++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
}
Debug.WriteLine($"[GC] Forced collection complete. Total memory: {GC.GetTotalMemory(false) / 1024}KB");
}
Why five iterations? The GC is non-deterministic and may need several passes to finalize all eligible objects. Five is a reliable sweet spot based on community testing.
Method 2: Visual Studio Diagnostic Tools
When running on Windows or the Windows target, go to Debug > Windows > Diagnostic Tools in Visual Studio. Take memory snapshots before and after navigating to a page, then compare heap sizes. You're looking for objects that should've been collected but are still hanging around in the newer snapshot.
Method 3: dotnet-gcdump on Android
For Android profiling, you can capture GC dumps using the .NET diagnostic tooling:
# Install the diagnostic tools
dotnet tool install --global dotnet-dsrouter
dotnet tool install --global dotnet-gcdump
# Start the diagnostic router (Android)
dotnet-dsrouter android
# In another terminal, capture the GC dump
dotnet-gcdump collect -p
# Analyze the .gcdump file in Visual Studio or PerfView
Pro tip: before capturing the dump, invoke GC.Collect() and GC.WaitForPendingFinalizers() multiple times. That way you're seeing actual leaks, not just objects waiting for their turn in the collection queue.
Method 4: .NET Meteor + Heapview (VS Code)
If you're a VS Code user, the .NET Meteor extension can capture memory dumps from running MAUI apps. Pair it with dotnet-heapview to visualize the object graph and trace reference chains back to GC roots. It's not as polished as the Visual Studio tooling, but it gets the job done.
Method 5: MemoryToolkit.Maui (Recommended)
MemoryToolkit.Maui is, in my opinion, the most practical tool available for managing memory leaks in .NET MAUI right now. It monitors views as they're unloaded and alerts you immediately when a leak is detected. I'll cover the full setup below.
Step-by-Step: Fixing the Most Common Leaks
Alright, let's get to the fixes. Each one below includes before/after code so you can apply the pattern directly in your codebase.
Fix 1: Unsubscribe from Events in Loaded/Unloaded
The Loaded and Unloaded events are tied directly to the platform's native view lifecycle. Use them to manage event subscriptions symmetrically:
public partial class DetailPage : ContentPage
{
public DetailPage()
{
InitializeComponent();
Loaded += OnLoaded;
Unloaded += OnUnloaded;
}
private void OnLoaded(object sender, EventArgs e)
{
App.Current.RequestedThemeChanged += OnThemeChanged;
}
private void OnUnloaded(object sender, EventArgs e)
{
App.Current.RequestedThemeChanged -= OnThemeChanged;
}
private void OnThemeChanged(object sender, AppThemeChangedEventArgs e)
{
// Handle theme change safely
}
}
The key insight here is symmetry: subscribe in Loaded, unsubscribe in Unloaded. Every += should have a corresponding -=.
Fix 2: Use WeakEventManager for Custom Events
If you publish events from long-lived services, use WeakEventManager so subscribers can be garbage collected even without explicitly unsubscribing:
public class DataSyncService
{
private readonly WeakEventManager _weakEventManager = new();
public event EventHandler SyncCompleted
{
add => _weakEventManager.AddEventHandler(value);
remove => _weakEventManager.RemoveEventHandler(value);
}
public void CompleteSync()
{
// Subscribers are held via weak references — they can be GC'd
_weakEventManager.HandleEvent(this, EventArgs.Empty, nameof(SyncCompleted));
}
}
This is a great safety net, though I'd still recommend explicitly unsubscribing where possible. Belt and suspenders.
Fix 3: Disconnect Handlers on Page Navigation
For controls that need explicit handler disconnection, the page's Unloaded event is your best friend:
public partial class MapPage : ContentPage
{
public MapPage()
{
InitializeComponent();
Unloaded += OnUnloaded;
}
private void OnUnloaded(object sender, EventArgs e)
{
// Disconnect handlers for controls with known leak issues
MyCarouselView?.Handler?.DisconnectHandler();
MyWebView?.Handler?.DisconnectHandler();
MyMap?.Handler?.DisconnectHandler();
// Clear the binding context to release ViewModel references
BindingContext = null;
}
}
Fix 4: Dispose Pages on Navigation Pop
You can create a centralized cleanup mechanism for pages that get popped from the navigation stack:
// In App.xaml.cs or your Shell setup
public partial class App : Application
{
public App()
{
InitializeComponent();
}
protected override Window CreateWindow(IActivationState activationState)
{
var shell = new AppShell();
// Hook into navigation events for cleanup
shell.Navigated += OnNavigated;
return new Window(shell);
}
private void OnNavigated(object sender, ShellNavigatedEventArgs e)
{
if (e.Source == ShellNavigationSource.Pop ||
e.Source == ShellNavigationSource.PopToRoot)
{
// Force GC after navigation to help collect popped pages
Task.Run(async () =>
{
await Task.Delay(500); // Brief delay for platform cleanup
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
});
}
}
}
That 500ms delay isn't arbitrary — it gives the platform a moment to finish its own cleanup before we trigger collection.
Fix 5: Scoped DI for Transient Pages
When using dependency injection, register pages and ViewModels as transient and manage their lifetimes with scoped service providers:
// In MauiProgram.cs
builder.Services.AddTransient();
builder.Services.AddTransient();
// Navigation with proper scope management
public class ScopedNavigationService
{
private readonly IServiceProvider _serviceProvider;
private readonly Stack _scopes = new();
public ScopedNavigationService(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public async Task PushAsync() where TPage : Page
{
var scope = _serviceProvider.CreateScope();
_scopes.Push(scope);
var page = scope.ServiceProvider.GetRequiredService();
await Shell.Current.Navigation.PushAsync(page);
}
public async Task PopAsync()
{
await Shell.Current.Navigation.PopAsync();
if (_scopes.TryPop(out var scope))
{
scope.Dispose(); // Disposes all transient services created in this scope
}
}
}
This pattern gives you deterministic cleanup of pages and their dependencies. It's a bit more setup upfront, but it pays off in apps with complex navigation flows.
Using MemoryToolkit.Maui for Automated Leak Management
MemoryToolkit.Maui is a community library that provides three key capabilities: leak detection, leak prevention (tear-down), and leak compartmentalization. Let's walk through the setup.
Step 1: Install the Package
dotnet add package AdamE.MemoryToolkit.Maui
Step 2: Configure Leak Detection in MauiProgram.cs
Keep leak detection in debug builds only — you don't want aggressive GC calls in production:
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder.UseMauiApp();
#if DEBUG
builder.Logging.AddDebug();
// UseLeakDetection must be called AFTER logging is configured
builder.UseLeakDetection(collectionTarget =>
{
Application.Current?.MainPage?.DisplayAlert(
"Leak Detected",
$"{collectionTarget.Name} was not garbage collected!",
"OK");
});
#endif
return builder.Build();
}
Step 3: Add LeakMonitorBehavior to Pages
Add the LeakMonitorBehavior.Cascade attached property to monitor a page and all its children for leaks:
When you navigate away from this page, the toolkit forces GC collection and reports any views or controls that weren't collected. It's almost too easy.
Step 4: Add TearDownBehavior for Automatic Cleanup
Once you've identified leaks, add TearDownBehavior.Cascade to automatically clean up views when they're unloaded:
TearDownBehavior handles several cleanup actions automatically:
- Clears
BindingContextfrom all views to release ViewModel references. - Sets
Content,ItemsSource, and similar properties tonull. - Calls
ClearLogicalChildren()to break parent-child reference chains. - Invokes
DisconnectHandler()on each view's handler.
One important caveat: TearDownBehavior is destructive — it tears apart the visual tree. It should only run when you're truly done with a page. If it fires prematurely (say, during a tab switch), it'll break your UI. Use the Suppress property to exclude views that need to persist:
Best Practices for Leak-Free .NET MAUI Apps
These guidelines aren't just theory — they're patterns that have proven reliable across real production apps.
Development-Time Practices
- Add finalizers to every Page and ViewModel during development. Remove them before production, but during dev they're your first line of defense.
- Test navigation cycles early and often. Navigate forward and back 10-20 times while watching memory usage. A steadily climbing memory graph? That's a clear leak signal.
- Use MemoryToolkit.Maui in all debug builds. Enable
LeakMonitorBehavior.Cascadeon every page to get immediate alerts. - Profile on real devices, not just emulators. Memory behavior differs significantly between the two, especially on iOS.
Code-Level Practices
- Always unsubscribe from events on long-lived objects. Use the
Loaded/Unloadedpattern shown above. - Prefer
WeakEventManagerfor custom events in services and managers that outlive individual pages. - Call
DisconnectHandler()for controls with known leak issues —CarouselView,WebView,Map,MediaElement. - Clear
BindingContextinUnloadedwhen a page is being permanently dismissed. - Avoid static references to pages, ViewModels, or views. If you need shared state, use a singleton service via DI and don't let it hold references to UI objects.
- Implement
IDisposableon ViewModels that hold unmanaged resources, timers, or cancellation tokens.
Architecture-Level Practices
- Use transient registration for pages and ViewModels. Singleton or scoped registrations can keep references alive longer than intended.
- Implement a navigation service that manages page lifecycles. Hook into
Navigatedevents to trigger cleanup for popped pages. - Use
CancellationTokenfor async operations. Long-running tasks that capturethisin their closure will prevent the containing object from being collected until the task completes.
Narrowing Down a Leak Source
So you know a page is leaking but you can't figure out where it's coming from. Here's a systematic process that works every time:
- Comment out all XAML content — navigate to a blank
ContentPage. If it still leaks, the problem is in code-behind or the ViewModel. - Comment out all code-behind — if the XAML-only page leaks, a specific control in the XAML is the culprit.
- Re-add controls one by one — or better yet, use binary search (add half the controls, test, narrow down). Much faster than going one at a time.
- Test per platform — some leaks are platform-specific. A control that works perfectly on Android may leak on iOS due to different reference counting semantics.
- Check third-party controls — vendor controls are a surprisingly common source of leaks. Test with and without them to isolate the issue.
Frequently Asked Questions
Does .NET MAUI automatically call DisconnectHandler when a page is popped?
No. DisconnectHandler is intentionally not invoked by .NET MAUI. The framework's design philosophy is that it shouldn't make assumptions about when you're "done with" a view. You have to call it yourself — typically in the page's Unloaded event or through a library like MemoryToolkit.Maui that automates the process.
Are memory leaks worse on iOS or Android in .NET MAUI?
Both platforms are affected, but the underlying mechanisms differ. On Android, the .NET GC has to coordinate with the Java/ART garbage collector, and cross-runtime references (GREFs) can create retention issues. On iOS, Objective-C uses reference counting rather than tracing GC, so cyclic references between managed and native objects can't be automatically resolved. In practice, both platforms need the same defensive coding habits.
Will upgrading to the latest .NET MAUI version fix memory leaks?
Partially. The .NET MAUI team has fixed many framework-level leaks in service releases (notably 8.0.60 and subsequent updates). But leaks caused by your own application code — event subscriptions, static references, missing cleanup — won't be fixed by a framework update. You need to combine framework updates with proper leak detection in your codebase.
Is MemoryToolkit.Maui safe to use in production?
The leak prevention features (TearDownBehavior) are designed to be production-safe. However, leak detection (LeakMonitorBehavior with UseLeakDetection) should only be enabled in debug builds since it triggers aggressive GC.Collect() calls that hurt performance. Wrap detection setup in #if DEBUG preprocessor directives and you'll be fine.
How can I tell if a memory increase is a leak or normal behavior?
Run a repeated navigation test: navigate to a page and back 10 times while monitoring total memory. After each pop, force GC collection. If memory returns to roughly the same baseline after each cycle, you're good — that's just normal allocation and collection. If memory grows monotonically with each cycle and never drops back down, you've got a leak. Use finalizer logging to confirm which specific objects aren't being collected.