Edit

Performance Profiling

Performance profiling is the process of measuring the performance of an application to identify areas for improvement. .NET MAUI and client applications, in general, are interested in:

  • Startup time: The time it takes for the application to start and display the first screen.
  • CPU usage: If specific methods are consuming too much CPU time: either through many calls or long-running operations.
  • Memory usage: If many allocations are made beyond reason or if there are memory leaks.

The techniques and tools for improving these metrics are different, which we plan to demystify in this guide. The tools used to profile .NET MAUI applications can also vary depending on the platform. This guide covers Android, iOS, Mac Catalyst, and Windows profiling approaches.

Important

Always profile Release builds for accurate performance measurements.

Debug builds use the interpreter (UseInterpreter=true) for C# hot reload support, which significantly impacts performance and produces unrealistic results.

.NET MAUI applications normally use CoreCLR on Android, iOS, Mac Catalyst, and Windows in .NET 11 and later. NativeAOT has different diagnostics limitations; see NativeAOT deployment. For runtime selection, see Runtime and compilation.

Prerequisites

Installing Diagnostic Tools

To profile .NET MAUI applications with the .NET diagnostic tools, you need to install the following .NET global tools. The router is used for the Android and iOS TCP workflows; Mac Catalyst and Windows use their local diagnostic endpoints.

  • dotnet-trace - Collects CPU traces and performance data
  • dotnet-dsrouter - Forwards diagnostic connections from remote devices to your local machine
  • dotnet-gcdump - Collects memory dumps for analyzing managed memory usage

You can install these tools using the following commands:

$ dotnet tool install -g dotnet-trace
You can invoke the tool using the following command: dotnet-trace
Tool 'dotnet-trace' was successfully installed.
$ dotnet tool install -g dotnet-dsrouter
You can invoke the tool using the following command: dotnet-dsrouter
Tool 'dotnet-dsrouter' was successfully installed.
$ dotnet tool install -g dotnet-gcdump
You can invoke the tool using the following command: dotnet-gcdump
Tool 'dotnet-gcdump' was successfully installed.

Note

Install the latest versions of all the diagnostic tools. Check dotnet-trace, dotnet-dsrouter, and dotnet-gcdump on NuGet for the latest versions.

The --dsrouter option in dotnet-trace and dotnet-gcdump automatically launches and manages dotnet-dsrouter as a subprocess. If the integrated option cannot find the router, install dotnet-dsrouter globally or place it alongside the diagnostic tool.

Warning

Diagnostic TCP endpoints are development and test interfaces. Keep them on loopback or use device forwarding, and never expose them to an untrusted network. The endpoints are unauthenticated and unencrypted.

See the .NET Conf session, .NET Diagnostic Tooling with AI, for a live demo of using these tools.

How the Tools Work Together

To use these diagnostic tools on iOS and Android, several components work together:

  • The .NET global tools (dotnet-trace, dotnet-gcdump, dotnet-dsrouter) run on your development machine.
  • The Mono diagnostic component (libmono-component-diagnostics_tracing.so) is included in your application package.
  • dotnet-dsrouter forwards the diagnostic connection from the remote device or emulator to a local port on your machine.
  • The diagnostic tools connect to this local port to collect profiling data.

The .NET global tools run on your development machine. EventPipe and the diagnostic server are CoreCLR runtime components.

Android and iOS applications use the configured TCP diagnostic port. dotnet-dsrouter bridges that port to the local diagnostic endpoint used by dotnet-trace or dotnet-gcdump. Use the android-emu or android router mode for Android, and the ios-sim or ios router mode for iOS. Mac Catalyst uses a direct local CoreCLR Unix domain socket endpoint and does not use the iOS TCP or --dsrouter workflow. Windows uses the normal local CoreCLR diagnostic endpoint and does not require dotnet-dsrouter.

The --dsrouter option in dotnet-trace and dotnet-gcdump automatically handles the complexity of starting dotnet-dsrouter and coordinating the connection where a router is required.

Building Your Application for Profiling

To enable profiling, your application must be built with the diagnostic-port settings required by the target platform.

Understanding Diagnostic Properties

The following MSBuild properties control how your application communicates with the diagnostic tools:

  • DiagnosticAddress: The IP address where dotnet-dsrouter is listening. Use 10.0.2.2 for Android emulators (this is the host machine's loopback address from the emulator's perspective), and 127.0.0.1 for physical devices and iOS.

  • DiagnosticPort: The port number for the diagnostic connection (default is 9000).

  • DiagnosticSuspend: When true, the application waits for the profiler to connect before starting. When false, the application starts immediately and the profiler can connect later. Use true for startup profiling, false for runtime profiling and memory dumps.

  • DiagnosticListenMode: Set to connect for Android (the app connects to dotnet-dsrouter), or listen for iOS (the app listens for dotnet-dsrouter to connect to it).

  • EnableDiagnostics: Controls SDK diagnostics configuration. On Android and iOS, setting this property or any Diagnostic* property preserves diagnostic providers in optimized builds and packages the configured diagnostic ports. In Mono-based applications, it also includes the Mono diagnostics component in the application package. For CoreCLR applications, EventPipe and the diagnostic server are built into the runtime, so this property does not add a Mono diagnostics component. Mac Catalyst uses the local CoreCLR diagnostic endpoint rather than the mobile Diagnostic* port configuration.

Note

The diagnostic component is intended for development and testing builds only.

DOTNET_EnableDiagnostics is a different setting: it is a runtime environment variable. Setting DOTNET_EnableDiagnostics=0 disables the runtime diagnostic server and related diagnostics. Do not confuse this runtime variable with the EnableDiagnostics MSBuild property.

The following properties configure DOTNET_DiagnosticPorts, the runtime diagnostic-port environment variable:

  • DiagnosticConfiguration: Supplies the complete DOTNET_DiagnosticPorts value when advanced configuration is required.
  • DiagnosticAddress: Supplies the address used by the diagnostic-port configuration. Use 10.0.2.2 for an Android emulator and 127.0.0.1 for Android device forwarding or iOS.
  • DiagnosticPort: Supplies the TCP port number, such as 9000. The app and router must use the same free port.
  • DiagnosticSuspend: When true, the runtime waits for the diagnostic connection before startup continues. Use it for startup tracing. When false, the application starts immediately and a tool can attach later.
  • DiagnosticListenMode: Uses connect for Android, where the app connects to the router. The .NET 11 iOS SDK uses listen, where the app listens for the router. Keep the iOS simulator and physical iOS device workflows separate.

DiagnosticConfiguration can supply the complete runtime configuration directly:

dotnet build -t:Run -c Release -f net11.0-android -p:DiagnosticConfiguration=10.0.2.2:9000,connect,suspend

Use the individual properties in the examples below when they make the platform topology easier to read.

Build Command Examples

When you run dotnet-trace or dotnet-gcdump with the --dsrouter option, the tool displays instructions for building your application. The diagnostic properties apply to both .NET 10 and .NET 11. The examples are versioned because the target frameworks and platform workflows differ. For example:

For Android emulators:

dotnet build -t:Run -c Release -f net10.0-android -p:DiagnosticAddress=10.0.2.2 -p:DiagnosticPort=9000 -p:DiagnosticSuspend=false -p:DiagnosticListenMode=connect

For Android devices:

dotnet build -t:Run -c Release -f net10.0-android -p:DiagnosticAddress=127.0.0.1 -p:DiagnosticPort=9000 -p:DiagnosticSuspend=false -p:DiagnosticListenMode=connect

For iOS devices and simulators:

dotnet build -t:Run -c Release -f net10.0-ios -p:DiagnosticAddress=127.0.0.1 -p:DiagnosticPort=9000 -p:DiagnosticSuspend=false -p:DiagnosticListenMode=listen

Note

Use -f net10.0-android or -f net10.0-ios for projects with multiple target frameworks in $(TargetFrameworks).

Important

Applications built with these diagnostic properties should only be used for development and testing. Never release builds with diagnostic components enabled to production, as they can expose endpoints with deeper insights into your application's code.

For an Android emulator:

dotnet build -t:Run -c Release -f net11.0-android -p:DiagnosticAddress=10.0.2.2 -p:DiagnosticPort=9000 -p:DiagnosticSuspend=false -p:DiagnosticListenMode=connect

For a physical Android device:

dotnet build -t:Run -c Release -f net11.0-android -p:DiagnosticAddress=127.0.0.1 -p:DiagnosticPort=9000 -p:DiagnosticSuspend=false -p:DiagnosticListenMode=connect

For an iOS simulator:

dotnet build -t:Run -c Release -f net11.0-ios -p:DiagnosticAddress=127.0.0.1 -p:DiagnosticPort=9000 -p:DiagnosticSuspend=false -p:DiagnosticListenMode=listen

For a physical iOS device:

dotnet build -t:Run -c Release -f net11.0-ios -p:DiagnosticAddress=127.0.0.1 -p:DiagnosticPort=9000 -p:DiagnosticSuspend=false -p:DiagnosticListenMode=listen
dotnet-trace collect --dsrouter ios

Run these commands from a macOS development host. The ios router mode uses the USB-connected physical iOS device.

For Mac Catalyst:

dotnet build -t:Run -c Release -f net11.0-maccatalyst
dotnet-trace ps
dotnet-trace collect -p <pid>

Mac Catalyst uses the direct local CoreCLR EventPipe diagnostic endpoint. Do not use the iOS TCP settings or dotnet-dsrouter for Mac Catalyst. Instruments remains an alternative for native and system profiling.

For Windows:

Use the normal local CoreCLR diagnostic endpoint. Windows does not require DiagnosticConfiguration, the mobile Diagnostic* properties, or dotnet-dsrouter.

Important

Applications built with diagnostic-port settings should only be used for development and testing. Keep diagnostic endpoints on loopback or behind device forwarding. They are unauthenticated and unencrypted.

Profiling CPU Usage

The dotnet-trace tool collects CPU sampling information in formats like .nettrace and .speedscope.json. These traces show you the time spent in each method, helping you identify performance bottlenecks in your application.

The workflow for CPU profiling depends on whether you're measuring startup time or profiling runtime operations. The key difference is the -p:DiagnosticSuspend MSBuild property.

Profiling Startup Time

To capture accurate startup time measurements, suspend application startup until the profiler is ready. This ensures you capture the entire startup sequence from the very beginning.

  1. In one terminal, start dotnet-trace with the --dsrouter option:

    dotnet-trace collect --dsrouter android-emu --format speedscope
    

    Or for a physical Android device:

    dotnet-trace collect --dsrouter android --format speedscope
    

    For iOS devices and simulators, use --dsrouter ios or --dsrouter ios-sim respectively.

  2. In another terminal, build and deploy your application with -p:DiagnosticSuspend=true to pause at startup:

    For Android emulators:

    dotnet build -t:Run -c Release -f net10.0-android -p:DiagnosticAddress=10.0.2.2 -p:DiagnosticPort=9000 -p:DiagnosticSuspend=true -p:DiagnosticListenMode=connect
    

    For Android devices:

    dotnet build -t:Run -c Release -f net10.0-android -p:DiagnosticAddress=127.0.0.1 -p:DiagnosticPort=9000 -p:DiagnosticSuspend=true -p:DiagnosticListenMode=connect
    

    For iOS (devices and simulators):

    dotnet build -t:Run -c Release -f net10.0-ios -p:DiagnosticAddress=127.0.0.1 -p:DiagnosticPort=9000 -p:DiagnosticSuspend=true -p:DiagnosticListenMode=listen
    
  3. Your application will pause at the splash screen, waiting for dotnet-trace to connect. Once connected, the application will start and dotnet-trace will begin recording.

  4. Allow your application to fully start and reach the initial screen.

  5. Press <Enter> in the dotnet-trace terminal to stop recording.

The trace file will be saved to the current directory. Use the -o option to specify a different output directory.

Start the collector before starting the application. For each target, run the collector command in one terminal and the build command in another terminal.

Android emulator:

dotnet-trace collect --dsrouter android-emu --format speedscope

In another terminal, run:

dotnet build -t:Run -c Release -f net11.0-android -p:DiagnosticAddress=10.0.2.2 -p:DiagnosticPort=9000 -p:DiagnosticSuspend=true -p:DiagnosticListenMode=connect

Physical Android device:

dotnet-trace collect --dsrouter android --format speedscope

In another terminal, run:

dotnet build -t:Run -c Release -f net11.0-android -p:DiagnosticAddress=127.0.0.1 -p:DiagnosticPort=9000 -p:DiagnosticSuspend=true -p:DiagnosticListenMode=connect

iOS simulator:

dotnet-trace collect --dsrouter ios-sim --format speedscope

In another terminal, run:

dotnet build -t:Run -c Release -f net11.0-ios -p:DiagnosticAddress=127.0.0.1 -p:DiagnosticPort=9000 -p:DiagnosticSuspend=true -p:DiagnosticListenMode=listen

Physical iOS device:

dotnet-trace collect --dsrouter ios --format speedscope

In another terminal, run:

dotnet build -t:Run -c Release -f net11.0-ios -p:DiagnosticAddress=127.0.0.1 -p:DiagnosticPort=9000 -p:DiagnosticSuspend=true -p:DiagnosticListenMode=listen

Run the physical iOS workflow from a macOS development host.

Mac Catalyst:

Mac Catalyst uses the direct local CoreCLR diagnostic endpoint rather than the mobile TCP and Diagnostic* configuration. Start the application, then find its process and attach from another terminal:

dotnet build -t:Run -c Release -f net11.0-maccatalyst

In another terminal, run:

dotnet-trace ps
dotnet-trace collect -p <pid> --format speedscope

This direct-attach workflow begins after the application launches; do not use dotnet-dsrouter for Mac Catalyst.

For Android and iOS, the application pauses at the splash screen until the diagnostic tool connects. After the connection is established, the application starts and the trace is recorded. Allow the application to reach its initial screen, then press <Enter> in the dotnet-trace terminal to stop recording.

Profiling Runtime Operations

To profile specific operations during runtime (such as button taps, navigation, or scrolling), use -p:DiagnosticSuspend=false and connect the profiler after the application has launched.

  1. Build and deploy your application with -p:DiagnosticSuspend=false:

    dotnet build -t:Run -c Release -f net10.0-android -p:DiagnosticAddress=127.0.0.1 -p:DiagnosticPort=9000 -p:DiagnosticSuspend=false -p:DiagnosticListenMode=connect
    
  2. Navigate to the area of your application you want to profile.

  3. Start dotnet-trace with the --dsrouter option:

    dotnet-trace collect --dsrouter android --format speedscope
    
  4. Perform the operation you want to profile.

  5. Press <Enter> to stop the trace.

Build and deploy the application with DiagnosticSuspend=false, then start the appropriate collector after the application has launched. The app and router must use the same address, port, and listen mode.

Android emulator:

dotnet build -t:Run -c Release -f net11.0-android -p:DiagnosticAddress=10.0.2.2 -p:DiagnosticPort=9000 -p:DiagnosticSuspend=false -p:DiagnosticListenMode=connect
dotnet-trace collect --dsrouter android-emu --format speedscope

Physical Android device:

dotnet build -t:Run -c Release -f net11.0-android -p:DiagnosticAddress=127.0.0.1 -p:DiagnosticPort=9000 -p:DiagnosticSuspend=false -p:DiagnosticListenMode=connect
dotnet-trace collect --dsrouter android --format speedscope

iOS simulator:

dotnet build -t:Run -c Release -f net11.0-ios -p:DiagnosticAddress=127.0.0.1 -p:DiagnosticPort=9000 -p:DiagnosticSuspend=false -p:DiagnosticListenMode=listen
dotnet-trace collect --dsrouter ios-sim --format speedscope

Physical iOS device:

dotnet build -t:Run -c Release -f net11.0-ios -p:DiagnosticAddress=127.0.0.1 -p:DiagnosticPort=9000 -p:DiagnosticSuspend=false -p:DiagnosticListenMode=listen
dotnet-trace collect --dsrouter ios --format speedscope

Run this command from a macOS development host.

Mac Catalyst:

Use the direct local CoreCLR EventPipe diagnostic endpoint:

dotnet-trace ps
dotnet-trace collect -p <pid> --format speedscope

Do not use the iOS TCP or --dsrouter commands.

Windows:

Use dotnet-trace against the normal local CoreCLR diagnostic endpoint.

After the collector connects for any of these platforms, perform the operation you want to profile, then press <Enter> in the dotnet-trace terminal to stop the trace.

This approach produces a more focused trace file containing only the specific operation you're investigating.

Understanding Trace Output

When dotnet-trace is collecting a trace, you'll see output similar to:

Process        : $HOME/.dotnet/tools/dotnet-dsrouter
Output File    : /tmp/hellomaui-app-trace
[00:00:00:35]    Recording trace 1.7997   (MB)
Press <Enter> or <Ctrl+C> to exit...

After pressing <Enter>, the trace is finalized:

Stopping the trace. This may take up to minutes depending on the application being traced.

Trace completed.
Writing:    hellomaui-app-trace.speedscope.json

Viewing Trace Files

The --format argument controls the output format:

  • nettrace (default): Can be viewed in PerfView or Visual Studio on Windows
  • speedscope: JSON format that can be viewed on any platform at https://speedscope.app/

For cross-platform analysis, use --format speedscope:

dotnet-trace collect --dsrouter android --format speedscope

Profiling on Windows

While the cross-platform dotnet-trace tool works on Windows, the platform offers additional native profiling options that may be more convenient.

Using Visual Studio Performance Profiler

The Visual Studio Performance Profiler provides integrated profiling for .NET applications. See the Visual Studio profiling feature tour for comprehensive guidance.

Using PerfView

PerfView is a powerful, free performance analysis tool for Windows that can profile .NET MAUI applications with minimal setup.

To profile with PerfView:

Build your application for Release with ReadyToRun enabled:

dotnet publish -f net10.0-windows10.0.19041.0 -c Release -p:PublishReadyToRun=true
dotnet publish -f net11.0-windows10.0.19041.0 -c Release -p:PublishReadyToRun=true
  1. Launch PerfView and select Collect > Collect.

  2. In the Command field, filter on your app's executable (for example, hellomaui.exe).

  3. Click Start Collection, then manually launch your app.

  4. Click Stop Collection once your app has completed the operation you want to profile.

  5. Open CPU Stacks to view timing information, or use the Flame Graph tab for a graphical view.

You can also save the PerfView data in SpeedScope format (File > Save View As) to view it at https://speedscope.app/ for cross-platform analysis.

Measuring Windows Startup Time with PerfView

To measure precise startup times on Windows, you can use PerfView to capture Event Tracing for Windows (ETW) events:

  1. In PerfView, open Collect > Collect and expand Advanced Options.

  2. Configure the following:

    • Enable Kernel Base
    • Add Microsoft-Windows-XAML:0x44:Informational to Additional Providers
  3. Click Start Collection, then launch and close your app 3-5 times.

  4. Click Stop Collection.

  5. Open the Events report and calculate startup time by finding:

    • The Windows Kernel/Process/Start event for your app (note the Time MSec value)
    • The first Microsoft-Windows-XAML/Frame/Stop event for the same process ID
    • Subtract the start time from the stop time to get startup duration

Run the app multiple times and average the results for more accurate measurements.

Using dotnet-trace on Windows

For unpackaged Windows applications, you can use dotnet-trace directly:

dotnet publish -f net10.0-windows10.0.19041.0 -c Release -p:PublishReadyToRun=true -p:WindowsPackageType=None
dotnet-trace collect --format speedscope -- bin\Release\net10.0-windows10.0.19041.0\win10-x64\publish\YourApp.exe
dotnet publish -f net11.0-windows10.0.19041.0 -c Release -p:PublishReadyToRun=true -p:WindowsPackageType=None
dotnet-trace collect --format speedscope -- bin\Release\net11.0-windows10.0.19041.0\win10-x64\publish\YourApp.exe

This child-process form is suitable for an unpackaged Windows application. For a packaged MSIX application, activate the app first and attach to its process instead of using the child-process command. Windows does not require dotnet-dsrouter for either workflow.

Profiling on iOS and Mac Catalyst with Instruments

For iOS and Mac Catalyst applications, Apple's Instruments tool provides native profiling with detailed insights into app launch time and performance.

Using Instruments for App Launch Profiling

  1. Build your app for Release with symbols preserved:

    dotnet build -c Release -f net10.0-ios -p:NoSymbolStrip=true
    
    dotnet build -c Release -f net11.0-ios -p:NoSymbolStrip=true
    

    The NoSymbolStrip=true property keeps native symbols in the executable, making stack traces in Instruments much more helpful.

  2. Install the app on your device:

    dotnet build -t:Run -c Release -f net10.0-ios -p:NoSymbolStrip=true
    
    dotnet build -t:Run -c Release -f net11.0-ios -p:NoSymbolStrip=true
    
  3. Launch Instruments (from Xcode or by running open -a Instruments in Terminal).

  4. Select your iOS device at the top.

  5. Select your app from the list of installed applications.

  6. Choose the App Launch instrument template.

  7. Click Choose, then click the Record button to start profiling.

  8. The app will launch automatically. Stop the recording once the app has fully started.

  9. In the results, select the App Lifecycle row to see the lifecycle timeline. The last row in the bottom table shows the time when the app completed launching (for example, Currently running in the foreground...).

For more information about using Instruments, see Apple's documentation on Reducing Your App's Launch Time.

For Mac Catalyst, Instruments is an alternative for native and system profiling. Managed EventPipe profiling uses the direct local CoreCLR diagnostic endpoint for the Mac Catalyst target. Do not use the iOS TCP settings or dotnet-dsrouter commands for Mac Catalyst.

Profiling Memory Usage

Memory profiling helps you identify memory leaks and understand memory allocation patterns in your application. Use dotnet-gcdump to create snapshots of managed memory.

The CoreCLR EventPipe and diagnostic-port instructions in this section do not apply to NativeAOT applications. See NativeAOT deployment for its diagnostics limitations.

Collecting Memory Dumps

To collect a memory dump, use the same --dsrouter workflow as dotnet-trace:

dotnet-gcdump collect --dsrouter android

Use --dsrouter android-emu, --dsrouter ios, or --dsrouter ios-sim for other targets.

Unlike CPU tracing, memory dumps do not require suspending application startup. Build your application with -p:DiagnosticSuspend=false:

dotnet build -t:Run -c Release -f net10.0-android -p:DiagnosticAddress=127.0.0.1 -p:DiagnosticPort=9000 -p:DiagnosticSuspend=false -p:DiagnosticListenMode=connect

For Android and iOS, use the --dsrouter workflow. Mac Catalyst uses the direct local CoreCLR endpoint.

Android emulator:

dotnet build -t:Run -c Release -f net11.0-android -p:DiagnosticAddress=10.0.2.2 -p:DiagnosticPort=9000 -p:DiagnosticSuspend=false -p:DiagnosticListenMode=connect
dotnet-gcdump collect --dsrouter android-emu

Physical Android device:

dotnet build -t:Run -c Release -f net11.0-android -p:DiagnosticAddress=127.0.0.1 -p:DiagnosticPort=9000 -p:DiagnosticSuspend=false -p:DiagnosticListenMode=connect
dotnet-gcdump collect --dsrouter android

iOS simulator:

dotnet build -t:Run -c Release -f net11.0-ios -p:DiagnosticAddress=127.0.0.1 -p:DiagnosticPort=9000 -p:DiagnosticSuspend=false -p:DiagnosticListenMode=listen
dotnet-gcdump collect --dsrouter ios-sim

Physical iOS device:

dotnet build -t:Run -c Release -f net11.0-ios -p:DiagnosticAddress=127.0.0.1 -p:DiagnosticPort=9000 -p:DiagnosticSuspend=false -p:DiagnosticListenMode=listen
dotnet-gcdump collect --dsrouter ios

Run the physical iOS workflow from a macOS development host.

Mac Catalyst:

Use the direct local CoreCLR endpoint instead of --dsrouter:

dotnet-trace ps
dotnet-gcdump collect -p <pid>

Once dotnet-gcdump connects, it creates a *.gcdump file in the current directory. You can open this file in Visual Studio on Windows or PerfView.

Analyzing Memory Dumps

When you open a *.gcdump file in Visual Studio, you can:

  • View every managed object in memory
  • See the total count and size of each type
  • Inspect the reference tree to understand what's keeping objects alive
  • Compare multiple snapshots to identify growing allocations

Visual Studio's Memory Usage diagnostic tool (Debug > Windows > Diagnostic Tools) also allows you to take snapshots while debugging, though you should disable XAML hot reload for accurate results.

Tip

Consider taking memory snapshots of Release builds, as code paths can be significantly different when XAML compilation, AOT compilation, and trimming are enabled.

Diagnosing Memory Leaks

Memory leaks in .NET MAUI applications manifest as steadily increasing memory usage, especially during repeated navigation or interactions. On mobile platforms, this can eventually cause the OS to terminate your application due to excessive memory consumption.

Symptoms of Memory Leaks

A typical symptom of a memory leak might be:

  1. Navigate from the main page to a details page
  2. Navigate back
  3. Navigate to the details page again
  4. Memory grows consistently with each cycle

Determining if a Leak Exists

To determine if a page is actually leaking, use finalizers with logging and forced garbage collection during debugging.

  1. Add a finalizer with logging to the page class:

    ~MyDetailsPage() => System.Diagnostics.Debug.WriteLine("~MyDetailsPage() finalized");
    
  2. Force garbage collection in strategic places (for debugging only):

    public MyDetailsPage()
    {
        GC.Collect(); // For debugging purposes only
        GC.WaitForPendingFinalizers();
        InitializeComponent();
    }
    
  3. Test a Release build and watch the console output using adb logcat (Android) or device logs (iOS).

If the finalizer runs when navigating away from the page, the page is being collected correctly. If the finalizer never runs, the page is leaking--something is holding a reference to it indefinitely.

Warning

Remove GC.Collect() calls after debugging. They're only for diagnosing issues and should never be in production code.

Narrowing Down the Cause

Once you've identified a leak, narrow down the cause:

  1. Comment out all XAML content. Does the leak still occur?
  2. Comment out all C# code in code-behind. Does the leak still occur?
  3. Test on multiple platforms. Does it only happen on one platform?

Generally, an empty ContentPage should not leak. By systematically removing code, you can identify which control or code pattern is causing the problem.

Common Leak Patterns

C# Events

C# events can create circular references that prevent garbage collection. Consider a scenario where a child object subscribes to a parent's event, but the parent also holds a reference to the child. Both objects can end up living forever.

If the event source outlives the subscriber (like a Style in Application.Resources), this can cause entire pages to leak.

Solution: Use WeakEventManager for events in .NET MAUI controls, or unsubscribe from events when the object is no longer needed.

iOS and Mac Catalyst Circular References

On iOS and Mac Catalyst, circular references between C# objects and native objects can cause leaks because C# objects that subclass NSObject exist in both the garbage-collected .NET world and the reference-counted Objective-C world.

Example of a problematic pattern:

class MyView : UIView
{
    public MyView()
    {
        var picker = new UIDatePicker();
        AddSubview(picker); // MyView -> UIDatePicker
        picker.ValueChanged += OnValueChanged; // UIDatePicker -> MyView via event handler
    }

    void OnValueChanged(object? sender, EventArgs e) { }
}

Solutions:

  1. Make event handlers static:

    static void OnValueChanged(object? sender, EventArgs e) { }
    
  2. Use a proxy object that doesn't inherit from NSObject:

    class MyView : UIView
    {
        readonly Proxy _proxy = new();
    
        public MyView()
        {
            var picker = new UIDatePicker();
            AddSubview(picker);
            picker.ValueChanged += _proxy.OnValueChanged;
        }
    
        class Proxy
        {
            public void OnValueChanged(object? sender, EventArgs e) { }
        }
    }
    

Note

These circular reference issues are specific to iOS and Mac Catalyst. They do not normally occur on Android or Windows.

Best Practices for Avoiding Leaks

  • Test Release builds: Memory behavior can differ significantly from Debug builds due to optimizations, trimming, and AOT compilation.

  • Use finalizers when investigating: Add finalizers with logging to key objects to quickly identify if they're being collected.

  • Unsubscribe from events: Always unsubscribe from events when objects are disposed or no longer needed.

  • Be cautious with events on long-lived objects: Avoid having long-lived objects (like those in Application.Resources) hold references to short-lived objects (like pages or views).

  • Profile regularly: Make memory profiling part of your regular testing process, especially after adding new features or making significant changes.

For more detailed information about memory leak patterns and techniques, see the .NET MAUI Memory Leaks wiki.

Alternative Profiling Approaches

Android ActivityManager Startup Logs

Android automatically logs startup time information through the ActivityManager. You can view these logs using adb logcat:

adb logcat | grep "ActivityManager"

When your app starts, you'll see messages like:

ActivityManager: Displayed com.android.myexample/.StartupTiming: +3s534ms

This shows the time it took for your activity to be displayed. This is a quick way to measure startup time without any additional tooling or code changes.

For more information about Android app launch time and optimization techniques, see the Android documentation on app startup time.

Logging-Based Startup Measurement

For a lightweight approach to measuring startup time across all platforms, you can log messages at specific points in your application and measure the time between them:

  1. Add a log message when your main page loads:

    Loaded += (sender, e) => Dispatcher.Dispatch(() => 
        Console.WriteLine("loaded"));
    
  2. Use a tool like the measure-startup sample to launch your app and measure the time until the log message appears.

  3. On Android, you can filter adb logcat output to watch for specific messages:

    adb logcat | grep "loaded"
    

This approach works across all platforms and is useful for continuous integration scenarios or quick checks.

Additional Resources