Repository navigation
Description
Description
Updated a Winui3 application from .NET8 -> .NET10 and saw massive increase in memory usage.
- .NET8 ~250 Mb
- .NET10 ate up to 13 Gb (Over a week or so) before we stopped the app running.
The app routes a high volume of INotifyPropertyChanged events through the DispatcherQueue which might be part of the cause.
Tested on the latest Winui3 release and the issue was still present.
Posted the issue here because it seems like a runtime issue, not a library issue.
Configuration
Windows 11 26200.8457
Target x64
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
...
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.1839" />
<PackageReference Include="Microsoft.WindowsAppSDK" Version="2.1.3" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools.WinApp" Version="0.3.1" />
Reproduction Repo Here
Regression
The app sees a buildup of ComWrappers when targeting .net10
.NET8 Memory Profile : Mostly System Types.

.NET10 Memory Profile : an Explosion of Wrappers

Data
The production app eats a ton of memory and it seems to bounce all over the place.
The reproduction app suffers from the same leaks, but sees a plateau of memory usage (unlike the production app)

Analysis
The production application routes a large number of updates through the dispatcher queue using a modified version of the ObservableObject Class
The leak looks like it's coming from the binding, not sure if its a C#/WinRT issue or an Issue with the new GC in .NET10
namespace Observability.Common
{
/// <summary>
/// Extension of the observable objects class,
/// Allows other threads to trigger binding updates
/// </summary>
public partial class MoreObservableObject : ObservableObject
{
private static DispatcherQueue Queue { get; set; }
public static void BindGUIThread(DispatcherQueue queue)
{
Queue = queue;
}
public static void Update(Action action)
{
if (Queue == null)
{
action();
return;
}
Queue.TryEnqueue(() => action());
}
protected override void OnPropertyChanged(PropertyChangedEventArgs e)
{
Update(() => base.OnPropertyChanged(e));
}
}
public class MoreObservableCollection<T> : ObservableCollection<T>
{
public new void Add(T item)
{
MoreObservableObject.Update(() => base.Add(item));
}
public new void Clear()
{
MoreObservableObject.Update(() => base.Clear());
}
public new void RemoveAt(int index)
{
MoreObservableObject.Update(() => {
if (base.Count > index) base.RemoveAt(index);
});
}
}
}Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsNo status
Activity
DobriyTauren commentedon Aug 15, 2026
We hit this too and tracked it down to the root cause. It is not specific to WinUI or to XAML bindings — it reproduces with plain
ComWrappersand no UI framework at all.Root cause
ComWrapperskeeps a diagnostics-only registry of every CCW ever handed out for an object:GetOrCreateComInterfaceForObjectcallsRegisterManagedObjectWrapperForDiagnosticsafter theGetOrAddthat returns the cached wrapper:and that method appends unconditionally:
Two consequences:
For any long-lived object that is repeatedly marshalled to native code, the
List<T>therefore doubles forever. The entries are duplicates — the same holder added over and over — so they carry no information even for the stated diagnostic purpose.(
src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs— the code is the same onrelease/10.0and onmainas of today.)When it appeared
Neither
s_allManagedObjectWrapperTablenorRegisterManagedObjectWrapperForDiagnosticsexists onrelease/9.0: there the file is a ~6 KB partial over the native implementation, against ~84 KB onrelease/10.0after the managed implementation was shared between CoreCLR and NativeAOT (#113907). So on CoreCLR this registry is new in .NET 10, which lines up with the .NET 8/9 -> .NET 10 reports here and in microsoft-ui-xaml#10981.Minimal repro, no WinUI
Console app,
net10.0, runtime 10.0.10, x64, Windows 11. Nothing is retained by the caller: the pointer is released on every iteration.As shipped (
Debugger.IsSupportedunset, defaults totrue):Same binary, only
"System.Diagnostics.Debugger.IsSupported": falseadded toruntimeconfig.json:256 MB of live LOH from 20 million balanced calls, surviving a full blocking gen2 collection — versus completely flat with the diagnostics registration disabled.
Evidence from a real app
A WinUI 3 desktop app on the same runtime, during a voice call with a live-updating UI.
LOH doubling series from our own logging (one call, ~5 h)
Earlier sessions reached 1024 MB. Working set followed: 245 MB -> 1189 MB.
A process dump taken 42 minutes into a call, walked with ClrMD (dependent handles: primary = the CWT key, secondary = the
List<ManagedObjectWrapperHolder>):10.5 million list entries against 1,745 live holder objects — the duplication is the whole leak.
gen2stayed at 5–6 MB throughout, handle and thread counts were flat; only the LOH array grew.Measured rate: ~4,180 calls/second against that single object, constant for the duration of the call, and it kept growing across a hangup because the key object outlives the call.
With
Debugger.IsSupported=falsein the Release build, a subsequent 5 h 32 min call plus a further 1 h 42 min call showedLOHpinned at 4.8 MB and not a singleManagedObjectWrapperHolder[]allocation.Why this hits WinUI apps in particular
The growth rate is (rate of managed→native crossings of one long-lived object). In XAML that is amplified by the binding style:
{Binding}attaches a separate nativeINotifyPropertyChangedsubscriber per binding expression, so a singlePropertyChangedraise fans out to all of them, marshalling the source object once per handler;x:Bindcompiles to one generatedBindingsclass that subscribes once and dispatches by property name in managed code.The contrast is visible in the dump: our view model, bound by 140 classic binding expressions spread over several views, accumulated 10.5 million entries, while views using
x:Bindshow 41 entries per generated bindings object.We then measured the effect of converting one of those views. 77 of the 140 expressions moved to
x:Bind; the remaining 63 come from three instances of another user control that inherits the sameDataContextand still uses classic bindings. Rate before: ~4,180/s. Rate after, taken across two consecutive doublings of the array: ~2,400–2,600/s. Roughly proportional to the number of remaining subscribers, which is what the per-expression model predicts.Worth stating plainly though: binding style is not a mitigation. Removing more than half of the subscribers only changed how long it takes to reach a gigabyte — from roughly five hours to roughly nine.
This is also why the repro in this issue and in microsoft-ui-xaml#10981 (same reporter, filed against the XAML repo in January, still unresolved and not cross-linked) both involve high-frequency
INotifyPropertyChangedtraffic throughDispatcherQueue.Workaround
Set the feature switch, which makes
RegisterManagedObjectWrapperForDiagnosticsreturn early:Verify that
"System.Diagnostics.Debugger.IsSupported": falseactually lands in the generatedruntimeconfig.json.On the duplicated entries
Worth separating two things: the registry existing at all, and what it stores. The 10.5 million entries above are the same holder repeated — 1,745 distinct holder objects existed in the entire process. So the list grows with the number of calls rather than with the number of wrappers, and the repeated entries carry no additional information for the stated purpose in the comment above the method ("keep all
ManagedObjectWrapperHolders alive as long as the managed object is alive").Classification
Would it make sense to treat this as a regression rather than
tenet-performance?GetOrCreateComInterfaceForObjectis meant to be called repeatedly — itAddRefs and returns the cached pointer — and code that was flat on .NET 8/9 now grows without bound on .NET 10 with no opt-in.fix: prevent .NET 10 COM wrapper memory leak in production (#1215)