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.
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 |
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 |
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. |
|
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. |
|
Collecting a |
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 |
Composite mode |
|
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
Source: ReadyToRun deployment overview
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);
}
}
Source: Optimize app performance (WPF)
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.xamlis 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 |
Things that turn it off |
Adding item containers directly to the control, mixed container types (a |
Recycling |
|
Deferred scrolling |
|
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 |
|
Check it before freezing; freezing an object that cannot be frozen throws |
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 |
Freezing a container |
Freezing a |
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;
}
}
Source: Freezable objects overview
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 |
Slowest; 115 ms to bind 1000 |
A CLR object that implements |
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 |
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 |
A |
Prefer |
Binding an |
Do not bind to one huge object |
Binding 1000 |
Do not convert CLR data to XML just to bind it |
Binding to XML is slower than binding to CLR objects. |
Use |
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 |
|
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
LayoutTransformis applied, -
UpdateLayoutis 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 |
|
Update a transform instead of replacing it |
Changing the value of an existing |
Prefer |
A |
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 |
Use |
Keep templates lean |
A |
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.Opacityrather than the elementOpacitywhen you use a brush forFillorStroke; changing an element’s opacity can make WPF create a temporary surface. -
Subscribing to
CompositionTarget.Renderingmakes 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 aTileBrush; 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 |
|---|---|
|
Lists the .NET processes that can be monitored, with their command-line arguments. |
|
Shows periodically refreshing values. Options: |
|
Writes values to a file. The same options plus |
# 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.
Source: Creating metrics
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 |
|---|---|
|
Trace a running process. |
|
Launch the app as a child and trace it from start-up, which is how you investigate a slow start. |
Default profiles |
With no |
|
Other built-in profiles: GC collections with sampled allocations, GC collections only (very low overhead), and ADO.NET and Entity Framework commands. |
|
Output format; the default is |
|
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 ( |
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. |
|
Assigning a brush to |
Clear the property ( |
Bindings to non- |
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 |
Timers, tasks and |
A running |
Stop the timer and detach the handler on |
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++;
}
Source: Weak event patterns
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:
-
Start the app under the debugger (or profile a Release build from
Alt+F2with Memory Usage selected). -
Open the statistics tab, close it, and take a snapshot.
-
Repeat the open-and-close cycle five times and take a second snapshot.
-
Open the second snapshot’s object-count diff against the first. If
BookStatsViewModelshows an increase of five, instances are being retained. Inspecting the retaining path (here, theBookChangeFeedevent 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 |
A |
Use |
|
Use |
|
Keep |
The platform makes a copy of the dictionary for every instance of the control. |
Put page-specific resources in the page |
|
Virtualize lists |
|
Decode images at display size |
Set |
Avoid overdraw |
Use a panel |
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>
Source: XAML analysis and best practices
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; |
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; |
ReadyToRun; defer work after the first frame; no XAML to parse. |
List virtualization |
|
|
|
Binding cost |
Reflection-based |
|
|
Resource handling |
Freeze brushes; |
|
Reuse |
Leak risk |
Event handlers, static events, |
Event handlers and |
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, theMeterAPI 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-countersshows the olderSystem.RuntimeEventCounters for apps on .NET 8 or lower and the newerSystem.Runtimemeter for later versions. -
.NET 10.
dnx dotnet-countersruns the tool without installing it (.NET 10.0.100 and later);dotnet-tracegainedcollect-linux(Linux only, not relevant to Windows desktop). -
.NET 11 (RC2). Learn documents a
--buffering-mode Blockoption fordotnet-tracethat 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
ScrollVieweror anAutorow realizes every item. -
Assigning a new collection to fix a refresh. It regenerates every container; use
ObservableCollection<T>. -
Per-item
Addin a loop. Each call raises a notification; batch withDeferRefreshor 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-tracewith the default profiles samples stacks about every millisecond; usedotnet-commonorgc-collectfor 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:Loadin 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.