Performance & Diagnostics

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

A slow desktop app is rarely slow everywhere. It is slow in one place: a start-up that does too much before the first window appears, a list that realizes ten thousand rows, a binding that reflects over a huge object, a layout pass that runs on every keystroke, or a view model that is never garbage collected. The skill is in finding that place quickly and proving that the fix helped. This page covers the measuring tools first, then the specific costs that Microsoft documents for WPF (start-up, virtualization, freezables, binding, layout, rendering), the memory leaks that event handlers cause, and the equivalent guidance for WinUI 3.

The page was written against these versions, current in October 2026:

  • .NET 10 LTS, latest patch 10.0.13, with C# 14. .NET 11 is at release candidate 2 (general availability is expected 10 to 13 November 2026). Learn does not describe a WPF performance change in .NET 10 or 11; the WPF-specific guidance below is the long-standing guidance, with its ages noted.

  • Visual Studio 2026 18.11.1, whose Performance Profiler is the main tool used here.

  • Windows App SDK 2.5.1 for the WinUI 3 section, and CommunityToolkit.Mvvm 8.4.2 for the messenger example.

  • Windows 10 and Windows 11. Hardware-accelerated rendering and the diagnostic tools behave the same on both.

What Microsoft Learn Provides, and What It Does Not

For WPF, Microsoft Learn keeps an "Optimize app performance" section under the advanced topics. It has one page each for hardware rendering, layout and design, 2D graphics and imaging, object behavior, application resources, text, data binding, controls, and other recommendations. These pages are accurate about what is expensive, but they are old: most carry a 2017 ms.date, and the timing tables were measured on a 2.8 GHz machine with 512 MB of memory and an ATI Radeon 9700 graphics card, which is hardware from the early 2000s. Read the tables as ratios (for example, 3210 ms against 46 ms), not as numbers to expect today.

For tools, Learn documents the Visual Studio profiling tools, dotnet-counters, dotnet-trace and the System.Diagnostics.Metrics API in current, maintained pages. For WinUI 3 it has current pages on loading XAML efficiently and on XAML analysis. What Learn does not provide:

  • No current page for the WPF Performance Suite (Perforator and Visual Profiler). It survives only as an archived page under previous-versions, written for the Windows SDK v7.1. See the section on the legacy suite below.

  • No data virtualization for WPF. The controls page says that no WPF control offers built-in support for data virtualization; only UI virtualization exists.

  • No official profiling guide for Windows Forms. The general Visual Studio and .NET tools apply, but the WinForms documentation has no performance section equivalent to the WPF one.

  • No start-up checklist for WPF on .NET 10. The start-up advice below combines the ReadyToRun page, the profiler pages and the Bookshelf Desktop example; the example is a pattern, not an official sample.

Measure First

Guessing is the most common performance mistake. The sequence that works is the same for every stack.

flowchart TD A["Symptom: slow start, janky scroll, high memory"] --> B["Reproduce it in a Release build"] B --> C{"What kind of symptom?"} C -->|"Start-up or one slow action"| D["CPU Usage and Application Timeline"] C -->|"UI freezes or low frame rate"| E["Application Timeline: UI thread and visual throughput"] C -->|"Memory grows over time"| F["Memory Usage snapshots and .NET Object Allocation"] C -->|"Is it the runtime or my code?"| G["dotnet-counters: GC, threads, exceptions"] D --> H["Fix the hottest path only"] E --> H F --> H G --> H H --> I["Measure again and keep the numbers"]

The Visual Studio documentation says the tools in the Performance Profiler are meant for Release builds, because a Debug build has different code generation and different timings. Open the profiler with Debug > Performance Profiler or Alt+F2. During a debugging session the Diagnostic Tools window (Ctrl+Alt+F2) offers a lighter set: CPU usage, memory usage, .NET counters and PerfTips. Learn also notes that Windows 8 or later is required to run the profiling tools with the debugger.

The tools at a glance

Tool Use it for Notes

CPU Usage (Visual Studio)

Finding the functions that use the most CPU: Top Functions, Hot Path and a call tree with Total CPU and Self CPU.

The best first tool. It can also open traces collected by dotnet-trace, which produces sampling results, not an instrumented trace.

Application Timeline (Visual Studio)

XAML apps: time spent in parsing, layout, render, I/O, app code and XAML runtime, with UI thread utilization and Visual throughput (FPS) graphs.

Learn lists WPF (.NET 4.0 and above) and UWP among its platforms. See the note about WinUI 3 below.

Memory Usage (Visual Studio)

Memory leaks: take snapshots before and after the suspect action and diff the object counts and heap size.

Available in the debugger-integrated Diagnostic Tools window and as a post-mortem tool.

.NET Object Allocation (Visual Studio)

Allocation patterns and common garbage collection problems in .NET code.

Post-mortem only; it can run on local or remote machines.

.NET Counters (Visual Studio)

Live runtime counters: CPU usage, GC heap size, exceptions, thread pool.

The Visual Studio front end for the same counters as dotnet-counters.

Instrumentation and File I/O (Visual Studio)

Exact call counts and wall-clock time (Instrumentation); slow loading caused by disk access (File I/O).

Instrumentation has more overhead than CPU Usage.

dotnet-counters

Ad-hoc health monitoring of a running process: GC, thread pool, exception rate, and your own meters.

Works with apps running .NET 5 or later.

dotnet-trace

Collecting a .nettrace trace of a process, including from start-up.

Opens in Visual Studio or PerfView; can convert to Speedscope or Chromium formats.

WPF Performance Suite

Legacy rendering and visual-tree profiling (Perforator and Visual Profiler).

Archived documentation only; see below.

Sources for the table: the Visual Studio profiling overview, the Application Timeline page, and the two CLI tool pages listed in the references.

A note on WinUI 3 and Windows Forms

The Application Timeline page lists Universal Windows apps, Windows Presentation Foundation (.NET 4.0 and above) and two older Windows versions. It does not name WinUI 3. The profiling overview shows Application Timeline for the .NET and .NET Framework language groups of "XAML apps, such as Windows desktop WPF apps and UWP apps". For WinUI 3, use CPU Usage, Memory Usage and the counters, plus the XAML analysis tool described near the end of this page. Windows Forms has no XAML, so Application Timeline does not apply; CPU Usage and the memory tools do.

The legacy WPF Performance Suite

The WPF Performance Suite is a set of profiling tools that shipped with the Windows SDK. It contains two tools: Perforator, which analyzes rendering behavior, and Visual Profiler, which profiles WPF services such as layout and event handling for the elements in the visual tree. Perforator shows frame rate, the dirty-rectangle addition rate, the number of software and hardware intermediate render targets per frame, and video memory use, and it can tint software-rendered areas purple. Visual Profiler overlays shades of red on the visual tree to show which elements use the most CPU and breaks that time down by layout, rendering and animation.

Its status as of October 2026, from Microsoft Learn:

  • The only Learn documentation is the archived "WPF Performance Suite" page under previous-versions. The page is marked archived and excluded from search indexing, and it dates from 2013.

  • The install steps refer to the Windows SDK v7.1 and to installing the Windows Performance Toolkit from the SDK. The suite needs administrator rights to run.

  • The current WPF performance pages on Learn do not mention the suite or its tools by name, and the current Visual Studio documentation points WPF developers to Application Timeline.

  • Learn gives no statement that the suite works with WPF on .NET 5 or later, and this page could not verify whether current Windows SDK releases still ship it.

So treat it as a legacy .NET Framework-era tool: useful background for understanding intermediate render targets and software fallback, but not part of the recommended workflow on .NET 10. Application Timeline and CPU Usage replace its visual-tree view in current Visual Studio.

Start-up Time

Start-up has three parts: the runtime and your assemblies loading and being compiled, your own code running before the first frame, and WPF building and laying out the first window. The profiler’s Application Timeline shows the third part (parsing, layout, render) and CPU Usage shows the second.

ReadyToRun

Normally the JIT compiler turns IL into machine code the first time each method runs, which costs time at start-up. ReadyToRun (R2R) is a form of ahead-of-time compilation: the published assemblies contain precompiled native code next to the IL. The ReadyToRun page states the trade-offs:

Aspect What Learn says

What improves

Start-up time and latency, including the first use of code, because fewer methods need JIT compilation.

Cost

Assemblies grow to roughly two to three times their size, which can slow loading from disk and increases the working set.

Small apps

Apps with little code may see no significant gain, because the .NET libraries are already precompiled with ReadyToRun.

Tiered compilation

R2R code is less optimized than JIT code, so tiered compilation replaces frequently used R2R methods with JIT-generated ones.

Requirement

Publish for a specific runtime identifier (RID) such as win-x64; R2R is not available for portable builds.

Composite mode

PublishReadyToRunComposite compiles a set of assemblies together; Learn recommends it only for apps that disable tiered compilation or Linux self-contained apps seeking the best start-up.

Methods still JIT-compiled

Generic types from other assemblies, native interop, hardware intrinsics the compiler cannot prove safe, unusual IL patterns, and dynamic methods from reflection or LINQ.

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>net10.0-windows</TargetFramework>
    <UseWPF>true</UseWPF>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <RuntimeIdentifier>win-x64</RuntimeIdentifier>
    <SelfContained>false</SelfContained>
    <!-- Precompile the app (and, when self-contained, the framework) at publish time. -->
    <PublishReadyToRun>true</PublishReadyToRun>
    <!-- Symbols (.ni.pdb) so a profiler can resolve R2R code; keep them out of the installer. -->
    <PublishReadyToRunEmitSymbols>true</PublishReadyToRunEmitSymbols>
  </PropertyGroup>

  <ItemGroup>
    <!-- Leave a very large, rarely used assembly out of R2R processing if it bloats the output. -->
    <PublishReadyToRunExclude Include="Bookshelf.Samples.LegacyImport.dll" />
  </ItemGroup>

</Project>
dotnet publish .\Bookshelf.Desktop\Bookshelf.Desktop.csproj -c Release -r win-x64 -p:PublishReadyToRun=true

ReadyToRun is one of the few publish optimizations available to WPF and Windows Forms, because trimming and Native AOT are not supported for them (the SDK reports NETSDK1168 if a WPF project sets PublishTrimmed). The Windows App Platforms page of this section has the full support matrix.

Keep the first frame cheap

Whatever the runtime does, the biggest start-up win is usually in your own code: do not do slow work before the window is on screen. The pattern for Bookshelf Desktop is to construct the window and the view models quickly, show the window, and load the books asynchronously from a command, so the user sees an interactive shell while the data arrives. Constructors and OnStartup must not hit the database, read large files or call the network.

using System.Diagnostics;
using System.Windows;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System.Windows.Threading;

namespace Bookshelf.Desktop;

public partial class App : Application
{
    private IHost? _host;

    protected override async void OnStartup(StartupEventArgs e)
    {
        base.OnStartup(e);

        // Build the container only: no database access, no file reads, no network calls here.
        _host = Host.CreateDefaultBuilder(e.Args)
            .ConfigureServices((context, services) => services.AddBookshelfDesktop(context.Configuration))
            .Build();

        await _host.StartAsync();

        var window = _host.Services.GetRequiredService<MainWindow>();
        window.Show();

        // Log how long it took until the UI was idle and ready for input. The load command starts
        // after this point, so the books appear while the user can already click.
        var startedAt = Process.GetCurrentProcess().StartTime;
        await Dispatcher.InvokeAsync(
            () => Debug.WriteLine($"Time to interactive: {(DateTime.Now - startedAt).TotalMilliseconds:F0} ms"),
            DispatcherPriority.ApplicationIdle);
    }

    protected override async void OnExit(ExitEventArgs e)
    {
        if (_host is not null)
        {
            await _host.StopAsync();
            _host.Dispose();
        }

        base.OnExit(e);
    }
}

The ApplicationIdle priority comes from the dispatcher priority table covered in the Threading, Dispatcher & async page, which also shows how the LoadCommand loads the books off the UI thread with progress and cancellation. The Dependency Injection & the Generic Host page covers AddBookshelfDesktop and the host.

Other start-up costs worth checking in the profiler:

  • Large merged resource dictionaries. Every dictionary merged in App.xaml is parsed at start-up. Keep rarely used styles and templates out of it (see the resources page of this section).

  • Heavy static constructors and field initializers. They run on first use of the type, often during the first layout pass.

  • Assembly loading. A reference that is only used on a rare path still loads its assembly when the method using it is JIT-compiled; keep rare features behind their own methods or types.

  • The WPF Font Cache service. Learn notes that the shared WPF Font Cache service is started by the first WPF application and that, on Windows Vista, switching it from Manual to Automatic (Delayed Start) reduced the initial start-up time. That advice is for a Windows version that is long out of support; the effect on Windows 10 and 11 is not documented.

UI Virtualization

A visual element is expensive: it costs memory for the container, the template tree and its bindings, plus layout time. UI virtualization creates containers only for the items near the viewport and discards or reuses them while the user scrolls. Learn’s object-behavior page shows the scale of the effect: adding and rendering 5000 TextBlock elements took 3210 ms in a StackPanel and 46 ms in a VirtualizingStackPanel.

The Lists, Grids & Trees page of this section describes every virtualization property in detail, lists the causes that silently turn it off, and is the place to configure it. The summary from the Learn controls page:

Topic Rule

Defaults

UI virtualization is on by default for ListView and ListBox when items are data bound. TreeView needs VirtualizingStackPanel.IsVirtualizing set to true; ComboBox and ContextMenu can be enabled.

Things that turn it off

Adding item containers directly to the control, mixed container types (a Menu with Separator items), ScrollViewer.CanContentScroll set to false, IsVirtualizing set to false.

Recycling

VirtualizingPanel.VirtualizationMode="Recycling" reuses containers for different items. It improves scrolling but means per-container state, such as an Expander.IsExpanded bound to the container, moves to another item.

Deferred scrolling

ScrollViewer.IsDeferredScrollingEnabled="True" updates the content only when the thumb is released.

Data virtualization

Not provided by any WPF control. UI virtualization keeps the whole collection in memory; page the data yourself if it is too large.

The most common reason a Bookshelf list is slow is not a missing property but an unbounded container. Putting a ListView inside a ScrollViewer, or in a Grid row with Height="Auto", gives it infinite height, so it realizes every row. Give the list a bounded size:

<Grid>
    <Grid.RowDefinitions>
        <RowDefinition Height="Auto" />
        <!-- A star row bounds the list; Auto would measure it with unlimited height. -->
        <RowDefinition Height="*" />
    </Grid.RowDefinitions>

    <TextBox Grid.Row="0" Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged}" />

    <ListView Grid.Row="1"
              ItemsSource="{Binding Books}"
              SelectedItem="{Binding SelectedBook}"
              VirtualizingPanel.IsVirtualizing="True"
              VirtualizingPanel.VirtualizationMode="Recycling"
              ScrollViewer.IsDeferredScrollingEnabled="False" />
</Grid>

Freezables

A Freezable is an object with two states, unfrozen and frozen. Brushes, pens, transforms, geometries and animations are freezables. An unfrozen one must be monitored for changes, because it owns low-level graphics resources that have to be regenerated if it changes, and every element that uses it subscribes to its Changed event. Freezing turns that monitoring off, which reduces CPU and memory use; a frozen freezable can also be shared across threads, which an unfrozen one cannot.

Learn’s object-behavior page measured one SolidColorBrush applied to the Fill of ten rectangles: 212 bytes when frozen against 972 bytes when not. The numbers are old, but the direction is the point.

Fact Detail

Freezables stay unfrozen by default

You must call Freeze() (or use PresentationOptions:Freeze in markup) yourself.

CanFreeze

Check it before freezing; freezing an object that cannot be frozen throws InvalidOperationException.

What blocks freezing

Animated or data-bound properties, properties set by a dynamic resource, or sub-objects that cannot be frozen.

Modifying a frozen object

Throws InvalidOperationException. Use Clone() or CloneCurrentValue() to get an unfrozen deep copy.

Freezing a container

Freezing a PathGeometry freezes its figures and segments too.

Animating

A frozen freezable cannot be animated; the animation system clones it for you, which costs time, so leave an object unfrozen if you plan to animate it.

In markup, map the presentation options namespace and mark it ignorable, as Learn recommends, so that XAML readers that do not know the attribute skip it:

<ResourceDictionary
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:PresentationOptions="http://schemas.microsoft.com/winfx/2006/xaml/presentation/options"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="PresentationOptions">

    <!-- Shared by every list row: frozen once, no per-row change tracking. -->
    <SolidColorBrush x:Key="FavoriteBrush"
                     PresentationOptions:Freeze="True"
                     Color="#FFC9A227" />

    <SolidColorBrush x:Key="RowDividerBrush"
                     PresentationOptions:Freeze="True"
                     Color="#33808080" />
</ResourceDictionary>

Brushes that are created in code, for example for a chart in the details pane, should be frozen after configuration:

using System.Windows.Media;

namespace Bookshelf.Desktop.Rendering;

public static class BookshelfBrushes
{
    public static Brush Spine { get; } = CreateSpineBrush();

    private static Brush CreateSpineBrush()
    {
        var brush = new LinearGradientBrush(Colors.SaddleBrown, Colors.Peru, 90);

        // Freeze only when the object allows it: bound or animated properties would make this false.
        if (brush.CanFreeze)
        {
            brush.Freeze();
        }

        return brush;
    }
}

A resource that a theme switch has to replace (the light and dark Fluent palettes in the Themes & Fluent Design page) is usually referenced with DynamicResource, and a dynamic resource cannot be frozen. That is fine: freeze the brushes that never change, and leave the theme brushes unfrozen.

Data Binding Costs

Data binding is convenient and has a measurable price. Learn’s data-binding performance page ranks the ways the binding engine can resolve a source property:

Source How the engine resolves it Relative cost

A plain CLR object and CLR property

Reflection to get a TypeDescriptor, then a PropertyDescriptor.

Slowest; 115 ms to bind 1000 TextBlock.Text properties in Learn’s measurement.

A CLR object that implements INotifyPropertyChanged

Reflection directly on the source type.

Still reflection, but a smaller working set than the first method; the same 115 ms binding time, 305 ms against 314 ms total render time.

A DependencyObject and DependencyProperty

No reflection; the property system and binding engine resolve it together.

Fastest; 90 ms binding and 263 ms render time in the same test.

View models should still implement INotifyPropertyChanged (the MVVM Toolkit page generates it), because the alternative is a binding that never updates. The practical lessons from the page and from the binding mechanics are:

Guideline Why

Bind to an ObservableCollection<T>, not a List<T>

A List<T> raises no change event, so the only way to refresh the list is to assign a new collection to ItemsSource, which throws away all containers and regenerates them. Learn measured 1656 ms to add one item to a non-virtualized ListBox with a List<T>, against 20 ms with an ObservableCollection<T>.

Prefer IList<T> over IEnumerable as the source

Binding an IEnumerable makes WPF create a wrapper IList<T>, an extra object and extra work.

Do not bind to one huge object

Binding 1000 TextBlock elements to a single object with 1000 properties took 950 ms, against 115 ms for 1000 objects with one property each.

Do not convert CLR data to XML just to bind it

Binding to XML is slower than binding to CLR objects.

Use Mode=OneTime for values that never change

A one-time binding does not need to listen for source changes, so there is no subscription to maintain. (Not stated on the Learn performance page; this follows from the binding modes documentation.)

Set UpdateSourceTrigger deliberately

PropertyChanged on a search box runs the filter on every keystroke; see the debounce example below.

The ObservableCollection<T> rule has a sharp edge: each Add raises a separate change notification, so loading five thousand books one by one makes the list react five thousand times. Two ways to avoid that for Bookshelf Desktop are to build the collection off-screen and assign it once, or to defer the refresh of the collection view while a batch of changes is applied:

using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Windows.Data;
using Bookshelf.Core;

namespace Bookshelf.Desktop.ViewModels;

public sealed class BookListUpdater(ObservableCollection<BookViewModel> books)
{
    // Applies a batch of changes with a single refresh of the view, instead of one per item.
    public void ReplaceAll(IReadOnlyList<Book> loaded)
    {
        ICollectionView view = CollectionViewSource.GetDefaultView(books);

        using (view.DeferRefresh())
        {
            books.Clear();

            foreach (var book in loaded)
            {
                books.Add(new BookViewModel(book));
            }
        }
    }
}

DeferRefresh reduces the work of sorting, grouping and filtering, but each Add still raises its own collection change notification. For tens of thousands of items, build a new ObservableCollection<BookViewModel> and assign it once to the property that ItemsSource binds to, accepting that the containers are regenerated a single time. The Converters, Collection Views & Data Templates page covers the collection view and DeferRefresh in more detail.

A converter that does real work on every binding update is another hidden cost; keep converters cheap and allocation-free, and do not look up resources by key inside a converter on a hot path.

Layout and Rendering Costs

Layout

Learn describes layout as a recursive, mathematically intensive process: a measure pass and an arrange pass for every child of a panel, then drawing. The cost grows with the number of children, and the layout pass is invoked again when:

  • a child is added to the collection,

  • a LayoutTransform is applied,

  • UpdateLayout is called on a child, or

  • a dependency property whose metadata affects measure or arrange changes.

The Layout & Panels page of this section explains the two passes. The performance consequences from Learn:

Guideline Detail

Use the cheapest panel that does the job

Grid and StackPanel do much more than Canvas. If you do not need that, a Canvas or a custom panel costs less.

Update a transform instead of replacing it

Changing the value of an existing RenderTransform avoids a layout calculation; assigning a new transform object does not. This matters most in animations.

Prefer RenderTransform over LayoutTransform

A LayoutTransform triggers a new layout pass (Learn lists it among the causes); a render transform is applied after layout.

Build the tree top-down

Adding a node to the logical tree raises property invalidations on its parent and children. Learn measured 11 ms for building a 150-level tree top-down against 366 ms bottom-up.

Avoid ScrollBarVisibility.Auto

Use Disabled, Hidden or Visible where you can; Auto is intended for cases where space is limited.

Keep templates lean

A Button template already expands to several elements; an unoptimized ControlTemplate adds more elements to the visual tree.

The same bounded-size rule applies to lists: give them a star row, not an Auto row (see the XAML in the virtualization section). Avoid calling UpdateLayout() yourself except when code must read the final size of an element immediately after changing it, since it forces a synchronous layout pass.

Rendering

WPF has a hardware rendering pipeline (DirectX) and a software pipeline (CPU). Learn calls the app render bound: the more pixels, the greater the cost, and the software pipeline is entirely CPU bound with fill rate (the number of pixels drawn) as the main problem. Rendering capability is classified into three tiers:

Tier Meaning

0

No graphics hardware acceleration; DirectX version level below 7.0.

1

Partial acceleration; DirectX version level 7.0 or higher and below 9.0.

2

Most graphics features accelerated; DirectX version level 9.0 or higher.

Rendering guidance from the "Other recommendations" page:

  • Set Brush.Opacity rather than the element Opacity when you use a brush for Fill or Stroke; changing an element’s opacity can make WPF create a temporary surface.

  • Subscribing to CompositionTarget.Rendering makes WPF animate continuously. Detach the handler whenever you can.

  • Turn off hit testing (IsHitTestVisible="False") on large 3D surfaces that do not need it.

  • Software-rendered areas are expensive. The legacy Perforator tool attributed intermediate render targets to DrawingBrush, VisualBrush, opacity on a visual and tile modes on a TileBrush; those are good places to look when a screen is slow on a machine without acceleration, for example over a remote desktop session.

The Graphics, Animation & Multimedia page of this section covers brushes, effects and animations in more depth.

Runtime Counters and Traces

The profiler is the right tool when you can run the app under Visual Studio. For a running process, a user’s machine, or a long soak test, the command-line tools are lighter.

dotnet-counters

dotnet-counters observes counter values published through the EventCounter or Meter APIs. Counters can be read from applications running .NET 5 or later. Since .NET 10.0.100 the tool can run without being installed, using dnx; the other options are dotnet tool install --global dotnet-counters or a direct download. The tool must run as the same user as the target process (or as root), and a 32-bit app needs the 32-bit tool.

Command Purpose

dotnet-counters ps

Lists the .NET processes that can be monitored, with their command-line arguments.

dotnet-counters monitor

Shows periodically refreshing values. Options: --process-id / -p, --name / -n, --refresh-interval, --counters, and -- <command> to launch the app and monitor it from start-up.

dotnet-counters collect

Writes values to a file. The same options plus --format (csv or json) and --output / -o.

# Which .NET processes are running?
dotnet-counters ps

# Watch the runtime counters of the running app, refreshing every 3 seconds.
dotnet-counters monitor --name Bookshelf.Desktop --refresh-interval 3 --counters System.Runtime

# Watch only garbage collections and total allocated bytes.
dotnet-counters monitor --name Bookshelf.Desktop --counters "System.Runtime[dotnet.gc.collections,dotnet.gc.heap.total_allocated]"

# Record the counters from start-up into a CSV file.
dotnet-counters collect --format csv --output bookshelf-counters.csv -- .\Bookshelf.Desktop.exe

The runtime counters seen by an app on .NET 9 or later, as shown in Learn’s sample output, include GC collections by generation, total allocated bytes, last-collection heap size and fragmentation, GC pause time, JIT compiled methods and time, thread pool thread count and queue length, lock contentions and the process working set. For apps on .NET 8 or older the tool falls back to the older System.Runtime EventCounters, such as GC Heap Size (MB), Allocation Rate, CPU Usage (%) and Exception Count.

Source: dotnet-counters

A custom meter for Bookshelf Desktop

Counters become much more useful when the app publishes its own. The System.Diagnostics.Metrics API is the recommended mechanism: create a Meter as a named group of instruments, and the instruments record measurements that tools aggregate. A Counter<T> counts things that only increase; a Histogram<T> is the usual choice for timings, because the tail of the distribution matters more than the average. In an app built on the Generic Host, obtain the meter from IMeterFactory, which hosts register automatically since .NET 8.

The following decorator measures the repository calls of Bookshelf Desktop without touching the view models:

using System.Diagnostics;
using System.Diagnostics.Metrics;
using Bookshelf.Core;

namespace Bookshelf.Desktop.Diagnostics;

public sealed class MeteredBookRepository : IBookRepository
{
    private readonly IBookRepository _inner;
    private readonly Counter<int> _booksLoaded;
    private readonly Histogram<double> _loadDuration;

    public MeteredBookRepository(IBookRepository inner, IMeterFactory meterFactory)
    {
        _inner = inner;

        var meter = meterFactory.Create("Bookshelf.Desktop");
        _booksLoaded = meter.CreateCounter<int>(
            "bookshelf.books_loaded",
            unit: "{book}",
            description: "Books returned by GetAllAsync");
        _loadDuration = meter.CreateHistogram<double>(
            "bookshelf.load_duration",
            unit: "s",
            description: "Time taken by GetAllAsync");
    }

    public async Task<IReadOnlyList<Book>> GetAllAsync(CancellationToken cancellationToken)
    {
        var started = Stopwatch.GetTimestamp();
        var books = await _inner.GetAllAsync(cancellationToken);

        _loadDuration.Record(Stopwatch.GetElapsedTime(started).TotalSeconds);
        _booksLoaded.Add(books.Count);
        return books;
    }

    public Task<Book?> GetByIdAsync(int id, CancellationToken cancellationToken) =>
        _inner.GetByIdAsync(id, cancellationToken);

    public Task<Book> AddAsync(Book book, CancellationToken cancellationToken) =>
        _inner.AddAsync(book, cancellationToken);

    public Task UpdateAsync(Book book, CancellationToken cancellationToken) =>
        _inner.UpdateAsync(book, cancellationToken);

    public Task DeleteAsync(int id, CancellationToken cancellationToken) =>
        _inner.DeleteAsync(id, cancellationToken);
}

Registering the decorator in the host (the Dependency Injection & the Generic Host page shows the surrounding registrations) wraps the EF Core repository:

using Bookshelf.Core;
using Bookshelf.Data;
using Bookshelf.Desktop.Diagnostics;
using Microsoft.Extensions.DependencyInjection;

namespace Bookshelf.Desktop;

public static class DiagnosticsServiceCollectionExtensions
{
    public static IServiceCollection AddBookshelfMetrics(this IServiceCollection services)
    {
        // IMeterFactory is registered by the Generic Host; the decorator wraps the real repository.
        services.AddSingleton<SqliteBookRepository>();
        services.AddSingleton<IBookRepository>(provider => new MeteredBookRepository(
            provider.GetRequiredService<SqliteBookRepository>(),
            provider.GetRequiredService<System.Diagnostics.Metrics.IMeterFactory>()));
        return services;
    }
}

Then watch the meter while you click Load in the running app:

dotnet-counters monitor --name Bookshelf.Desktop --counters Bookshelf.Desktop
Press p to pause, r to resume, q to quit.
    Status: Running

Name                                              Current Value
[Bookshelf.Desktop]
    bookshelf.books_loaded ({book})                         5,000
    bookshelf.load_duration (s)
        Percentile
        50                                                  0.412
        95                                                  0.418
        99                                                  0.418

The output shape follows Learn’s sample for histograms, where dotnet-counters renders a histogram as 50th, 95th and 99th percentiles; the numbers above are illustrative, not a measurement.

Recording a measurement is cheap (Learn says typically under 10 ns when nothing is collecting), but keep tag values bounded: a tag such as a book ID would create one time series per book. Learn suggests staying below about a thousand tag combinations per instrument.

dotnet-trace

dotnet-trace collects a trace of a running process without a native profiler, using the runtime’s EventPipe. Unlike the counters it records events and, by default, samples thread stacks, so the result can be opened in Visual Studio (on Windows) or PerfView to see where time went. Install it with dotnet tool install --global dotnet-trace.

Option or profile Meaning

dotnet-trace collect --process-id <PID>

Trace a running process. dotnet-trace ps lists candidates.

dotnet-trace collect -- <command>

Launch the app as a child and trace it from start-up, which is how you investigate a slow start.

Default profiles

With no --profile, --providers or --clrevents, the tool enables dotnet-common (lightweight runtime events: GC, assembly loader, JIT, exceptions, threading) and dotnet-sampled-thread-time (stack samples about every millisecond).

gc-verbose, gc-collect, database

Other built-in profiles: GC collections with sampled allocations, GC collections only (very low overhead), and ADO.NET and Entity Framework commands.

--format (NetTrace, Speedscope or Chromium)

Output format; the default is NetTrace. Converting is lossy, so keep the .nettrace file.

--duration dd:hh:mm:ss, --output

Stop after a time; choose the file name.

# Trace the first seconds of start-up of the published app, then stop with Enter.
dotnet-trace collect -- .\Bookshelf.Desktop.exe

# Trace a running instance for 30 seconds, including database commands.
dotnet-trace collect --name Bookshelf.Desktop --profile dotnet-common,dotnet-sampled-thread-time,database --duration 00:00:00:30

# Convert for a viewer that is not Visual Studio or PerfView (the original .nettrace file is kept).
dotnet-trace convert .\Bookshelf.Desktop_20261023_101500.nettrace --format Speedscope

Source: dotnet-trace

Two cautions from the tool pages. Launching an app through dotnet-trace -- <command> redirects its input and output unless you pass --show-child-io, and stopping a trace of a large app can take minutes while the runtime sends the type information. Also, the cpu-sampling profile that older articles mention was removed from collect; the current equivalent is dotnet-sampled-thread-time (with dotnet-common).

Memory Leaks

In a managed desktop app, a "leak" almost always means an object that the garbage collector cannot free because something reachable still refers to it. The common root is an event subscription: the delegate that a listener passes to an event is a reference to the listener, so a long-lived event source keeps every subscriber alive. Learn says this directly for WPF: not removing handlers can keep objects alive, which is especially costly when the object is the root of a logical or visual tree.

Source of the leak How it happens Fix

A handler on a longer-lived object

A short-lived view or view model subscribes to a service, a static event or a singleton, and never unsubscribes.

Unsubscribe when the listener is done (Unloaded, Dispose), or use a weak event.

A static event

The event lives as long as the process, so its subscribers do too.

Avoid static events; use a messenger with weak references, or an instance event on an injected service.

Changed handlers on an unfrozen Freezable

Assigning a brush to Rectangle.Fill adds a handler from the brush back to the rectangle. Setting the rectangle to null does not free it while the brush is alive.

Clear the property (Fill = null) before releasing the element, or freeze the brush so it keeps no handlers.

Bindings to non-INotifyPropertyChanged sources

A binding to a plain CLR property can keep the source alive through the property descriptor mechanism. Learn does not document this case (see "What Microsoft Learn Does Not Provide").

Make the source implement INotifyPropertyChanged or use Mode=OneTime.

Timers, tasks and CompositionTarget.Rendering

A running DispatcherTimer or a rendering handler references its owner.

Stop the timer and detach the handler on Unloaded.

Static caches and singletons

A dictionary or list that only grows.

Bound the cache, use weak references, or clear it at a known point.

An event leak and its fixes

Suppose a statistics panel shows the count of favorite books and listens to a catalog that raises an event when any book changes. The leak:

using Bookshelf.Core;

namespace Bookshelf.Desktop.Services;

public interface IBookChangeFeed
{
    event EventHandler<BookChangedEventArgs>? Changed;
}

public sealed class BookChangedEventArgs(Book book) : EventArgs
{
    public Book Book { get; } = book;
}

// A singleton: it lives as long as the application.
public sealed class BookChangeFeed : IBookChangeFeed
{
    public event EventHandler<BookChangedEventArgs>? Changed;

    public void Publish(Book book) => Changed?.Invoke(this, new BookChangedEventArgs(book));
}
using CommunityToolkit.Mvvm.ComponentModel;
using Bookshelf.Desktop.Services;

namespace Bookshelf.Desktop.ViewModels;

// Created each time the user opens the statistics tab, and meant to die when the tab closes.
public sealed partial class BookStatsViewModel : ObservableObject
{
    private readonly IBookChangeFeed _feed;

    public BookStatsViewModel(IBookChangeFeed feed)
    {
        _feed = feed;

        // LEAK: the singleton feed now holds a delegate to this instance.
        // Every tab the user opens leaves one more BookStatsViewModel alive.
        _feed.Changed += OnBookChanged;
    }

    [ObservableProperty]
    public partial int ChangedBooks { get; set; }

    private void OnBookChanged(object? sender, BookChangedEventArgs e) => ChangedBooks++;
}

There are two standard fixes. The first is to unsubscribe, which is deterministic and needs a defined end of life (IDisposable, called when the tab is closed):

using CommunityToolkit.Mvvm.ComponentModel;
using Bookshelf.Desktop.Services;

namespace Bookshelf.Desktop.ViewModels;

public sealed partial class BookStatsViewModel : ObservableObject, IDisposable
{
    private readonly IBookChangeFeed _feed;

    public BookStatsViewModel(IBookChangeFeed feed)
    {
        _feed = feed;
        _feed.Changed += OnBookChanged;
    }

    [ObservableProperty]
    public partial int ChangedBooks { get; set; }

    public void Dispose() => _feed.Changed -= OnBookChanged;

    private void OnBookChanged(object? sender, BookChangedEventArgs e) => ChangedBooks++;
}

The second is the weak event pattern, for the case where the listener does not know when to unsubscribe. WPF’s generic WeakEventManager<TEventSource,TEventArgs> registers a handler without keeping the listener alive:

using CommunityToolkit.Mvvm.ComponentModel;
using Bookshelf.Desktop.Services;
using System.Windows;

namespace Bookshelf.Desktop.ViewModels;

public sealed partial class BookStatsViewModel : ObservableObject
{
    public BookStatsViewModel(BookChangeFeed feed)
    {
        // The manager holds a weak reference to this instance, so the feed no longer keeps it alive.
        // The event is found by name through reflection, which is slower than a dedicated manager.
        WeakEventManager<BookChangeFeed, BookChangedEventArgs>.AddHandler(feed, nameof(BookChangeFeed.Changed), OnBookChanged);
    }

    [ObservableProperty]
    public partial int ChangedBooks { get; set; }

    private void OnBookChanged(object? sender, BookChangedEventArgs e) => ChangedBooks++;
}

The generic manager needs the concrete event source type, takes the event name as a string, and uses reflection to find the event; Learn says it is less efficient than an existing or custom WeakEventManager, and that the pattern is primarily relevant to control authors. For view models that talk to each other, the Messaging Between View Models page shows the MVVM Toolkit messenger, which avoids direct event subscriptions altogether, and explains its reference behavior.

Finding a leak with the Visual Studio memory tools

Learn recommends taking two snapshots with the Memory Usage tool: one right before a suspected problem and one right after, then comparing them. To find the BookStatsViewModel leak above:

  1. Start the app under the debugger (or profile a Release build from Alt+F2 with Memory Usage selected).

  2. Open the statistics tab, close it, and take a snapshot.

  3. Repeat the open-and-close cycle five times and take a second snapshot.

  4. Open the second snapshot’s object-count diff against the first. If BookStatsViewModel shows an increase of five, instances are being retained. Inspecting the retaining path (here, the BookChangeFeed event delegate) is a feature of the snapshot viewer that this page did not verify against the Learn pages.

This is the practical meaning of Learn’s statement that the Memory Usage tool is helpful for finding leaks "which are not typically common in .NET apps": they are not common, but event handlers are where they usually come from.

WinUI 3 and Windows App SDK

WinUI 3 apps get the same profilers and CLI tools, plus XAML performance guidance that is current and maintained. Most of it is about doing less work during load.

Guideline Detail (from the Learn pages)

Minimize element count

Element count matters most in data templates, because each element is created for each data item.

Use x:Load instead of Visibility for content shown later

A Collapsed element is skipped at render time but still costs its object instance in memory. With x:Load="False" the framework does not create the instance until needed, at a placeholder cost of about 600 bytes. It is the recommended deferred-loading pattern in the Windows App SDK; profile first, because for tiny elements the placeholder costs more than it saves.

Use {x:Bind} instead of {Binding}

{Binding} causes a series of allocations and can involve reflection and boxing; {x:Bind} compiles bindings at build time. It is not suited to late-bound scenarios.

Use x:Key, not x:Name, for resources

x:Name on a resource makes the platform instantiate it immediately instead of lazily.

Keep ResourceDictionary out of a UserControl

The platform makes a copy of the dictionary for every instance of the control.

Put page-specific resources in the page

App.xaml is parsed at start-up, so resources used by a single non-initial page should be in that page.

Virtualize lists

ListView and GridView virtualize, but only if they get a bounded viewport; in a ScrollViewer or an auto-sized Grid row they realize everything. Use ItemsStackPanel or ItemsWrapGrid; StackPanel, WrapGrid and VariableSizedWrapGrid disable virtualization.

Decode images at display size

Set DecodePixelWidth and DecodePixelHeight (physical pixels by default; DecodePixelType can be Logical). Use SetSourceAsync and attach the BitmapImage to the tree before setting the source.

Avoid overdraw

Use a panel Background instead of a Rectangle behind it, a Border instead of a Grid used as a border, and CacheMode="BitmapCache" for static composite shapes that do not animate.

Keep the UI thread free

Use asynchronous APIs; synchronous calls into off-thread functions block it.

Deferring the details pane of the WinUI 3 Bookshelf screen until a book is selected, and decoding the cover at the size it is displayed:

<Page
    x:Class="Bookshelf.WinUI.BooksPage"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">

    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*" />
            <ColumnDefinition Width="360" />
        </Grid.ColumnDefinitions>

        <ListView x:Name="BooksList"
                  ItemsSource="{x:Bind ViewModel.Books, Mode=OneWay}"
                  SelectedItem="{x:Bind ViewModel.SelectedBook, Mode=TwoWay}" />

        <!-- Not created until a book is selected; x:Load takes an x:Bind expression. -->
        <StackPanel Grid.Column="1"
                    x:Name="DetailsPane"
                    x:Load="{x:Bind ViewModel.HasSelection, Mode=OneWay}">
            <Image Width="160" Height="240">
                <Image.Source>
                    <!-- Decode the cover at the size it is shown, not at its native resolution. -->
                    <BitmapImage UriSource="{x:Bind ViewModel.SelectedBook.CoverUri, Mode=OneWay}"
                                 DecodePixelWidth="160"
                                 DecodePixelHeight="240" />
                </Image.Source>
            </Image>
            <TextBlock Text="{x:Bind ViewModel.SelectedBook.Title, Mode=OneWay}" />
        </StackPanel>
    </Grid>
</Page>

The same Learn page describes the XAML analysis tool in Visual Studio, which checks an app against a ruleset and flags: decoded image size larger than render size, collapsed elements at load time, a ListView that is not virtualized, a blocked UI thread, {Binding} where {x:Bind} would do, x:Name on resources, and non-virtualizing panels as an items panel.

WPF, WinUI 3 and Windows Forms Compared

Topic WPF WinUI 3 Windows Forms

Profiling tools

Visual Studio CPU Usage, Memory Usage, Application Timeline; dotnet-counters, dotnet-trace.

CPU Usage, Memory Usage, counters and traces; XAML analysis for UI rules.

CPU Usage, Memory Usage, counters and traces. No XAML tools.

Start-up

ReadyToRun (no trimming or Native AOT); defer work after the first frame.

ReadyToRun; PublishAot is supported since Windows App SDK 1.6 (test carefully, some libraries aren’t AOT-compatible, and trimming needs work); compiled XAML output; x:Load.

ReadyToRun; defer work after the first frame; no XAML to parse.

List virtualization

VirtualizingStackPanel; Recycling mode; no data virtualization.

ListView / GridView with ItemsStackPanel / ItemsWrapGrid.

ListView with VirtualMode.

Binding cost

Reflection-based {Binding}; ObservableCollection<T>; OneTime for static values.

{x:Bind} compiled at build time preferred over {Binding}.

BindingSource and INotifyPropertyChanged (see the validation and binding pages).

Resource handling

Freeze brushes; DynamicResource cannot be frozen.

x:Key not x:Name; page-level resources.

Reuse Brush, Pen, Font objects and dispose GDI objects.

Leak risk

Event handlers, static events, Freezable.Changed handlers.

Event handlers and x:Bind event subscriptions on long-lived objects.

Event handlers and undisposed GDI+ objects and controls.

The Windows Forms row on VirtualMode and GDI object disposal has no dedicated Learn performance page. The WinUI 3 leak row follows from the same event-subscription rule; Learn’s WinUI pages do not discuss leaks.

What Changed Across .NET Versions

  • .NET Framework 4.8 to .NET (Core). The WPF performance guidance is unchanged; the Learn pages were written for .NET Framework and are still the current WPF pages. What changed is the tooling: dotnet-counters, dotnet-trace, the Meter API and ReadyToRun are available on .NET 5 or later, and the legacy WPF Performance Suite is a .NET Framework-era tool.

  • .NET 6. Composite ReadyToRun was added.

  • .NET 8. Generic Host registers IMeterFactory; dotnet-counters shows the older System.Runtime EventCounters for apps on .NET 8 or lower and the newer System.Runtime meter for later versions.

  • .NET 10. dnx dotnet-counters runs the tool without installing it (.NET 10.0.100 and later); dotnet-trace gained collect-linux (Linux only, not relevant to Windows desktop).

  • .NET 11 (RC2). Learn documents a --buffering-mode Block option for dotnet-trace that needs a .NET 11 runtime.

Pitfalls

  • Measuring a Debug build. Profile Release; Debug timings mislead.

  • Optimizing without a baseline. Record the number before the change (a histogram percentile, a frame rate, a snapshot diff), or you cannot tell whether the fix worked.

  • Reading old Learn numbers as targets. The WPF timing tables come from decade-old hardware; use them for the direction of the effect.

  • Unbounded containers defeating virtualization. A list in a ScrollViewer or an Auto row realizes every item.

  • Assigning a new collection to fix a refresh. It regenerates every container; use ObservableCollection<T>.

  • Per-item Add in a loop. Each call raises a notification; batch with DeferRefresh or assign a prepared collection once.

  • Freezing something that must change later. A frozen brush throws on modification; clone it instead.

  • Static events and singleton events. They keep every subscriber alive for the life of the process.

  • Forgetting to detach CompositionTarget.Rendering. WPF animates continuously while a handler is attached.

  • High-cardinality metric tags. A tag per book or per user multiplies time series; keep tag values bounded.

  • Deep-tracing in production by default. dotnet-trace with the default profiles samples stacks about every millisecond; use dotnet-common or gc-collect for a low-overhead trace of a user machine.

  • Treating the WPF Performance Suite as current. It is archived documentation for the Windows SDK v7.1; use Application Timeline instead.

  • Overusing x:Load in WinUI. Each deferred element costs about 600 bytes of placeholder; profile before deferring small elements.

What Microsoft Learn Does Not Provide

  • No maintained page for the WPF Performance Suite; only the archived previous-versions page.

  • No official WPF guidance for data virtualization.

  • No current WPF performance walkthrough for .NET 10: the optimization pages date from 2017 (layout and design was revised in February 2026) and use .NET Framework terminology.

  • The Learn performance pages do not describe the memory leak for binding to sources that do not implement INotifyPropertyChanged, so the risk is named here without a Learn source for the mechanism.

  • No Windows Forms performance section.

  • No official comparison of WPF and WinUI 3 rendering performance.