← Back to list

I Traced a 4-Year-Old WinUI 3 Memory Leak to 4 Missing Lines in Microsoft’s XAML Compiler

If you’ve ever used {x:Bind} inside a WinUI 3 Window, there’s a good chance your app is leaking memory. Quietly, consistently, every time…

Hyeonsik Song · 2026-03-28 06:44 · 2 claps · 4.3 min read
#microsoft #csharp #winui
Open on Medium ↗
Wiki topics: 🔧 · Data Engineering

I Traced a 4-Year-Old WinUI 3 Memory Leak to 4 Missing Lines in Microsoft’s XAML Compiler

If you’ve ever used {x:Bind} inside a WinUI 3 Window, there’s a good chance your app is leaking memory. Quietly, consistently, every time you open and close that window.

This bug has been sitting in Microsoft’s backlog since June 2022. After nearly four years, one official comment, and dozens of community reports, it’s still not fixed.

I finally got fed up, dug into the generated code, traced it back to the XAML compiler’s T4 template, and found that four lines of code would have prevented the whole thing.

The simplest repro you can imagine

Two nearly identical windows. The only difference is how the button’s click handler is wired:

<!-- This leaks -->
<Button Click="{x:Bind OnButtonClick}" />

<!-- This doesn't -->
<Button Click="OnButtonClick" />

I built a small automated test that opens five windows of each type, closes them, forces a GC, and checks ‘WeakReference’ finalization.

This is what came back:

x:Bind : created=5, finalized=0, leaked=5
Normal : created=5, finalized=5, leaked=0

Test results without XBindLeakFix: x:Bind windows leak (5/5 still alive after GC), normal windows are collected as expected.

Test results without XBindLeakFix: x:Bind windows leak (5/5 still alive after GC), normal windows are collected as expected.

The normal windows freed about 50 MB cleanly. The ‘{x:Bind}’ windows held onto their 50 MB forever, sitting in memory until the process exits.

What x:Bind actually generates

I opened the ‘.g.cs’ files that the XAML compiler produces behind the scenes, and the difference jumped out immediately.

The normal event version was a boring 44-line file where ‘GetBindingConnector’ returns null and that’s it.

The ‘{x:Bind}’ version was a 519-line file, and the key part looked like this:

// GetBindingConnector, case for Window
Window element1 = (Window)target;
var bindings = new XBindWindow_obj1_Bindings();
bindings.SetDataRoot(this); // bindings holds a ref to the Window
this.Bindings = bindings;
element1.Activated += bindings.Activated; // ← subscribes here

‘Activated +=’ is there, but the corresponding ‘Activated -=’ is nowhere to be found. The ‘StopTracking()’ method only cleans up ‘PropertyChanged ’ listeners. It never touches the ‘Activated ’event.

Why the GC can’t break this

Here’s the reference cycle this creates:

Circular Reference (Why it leaks)

Circular Reference (Why it leaks)

Back in UWP, ‘Window ’inherited from ‘DependencyObject’, so the XAML framework’s Reference Tracker could break these cycles automatically. But in WinUI 3, ‘Window ’no longer inherits from ‘DependencyObject’.

Why Page works but Window doesn’t

Why Page works but Window doesn’t

That’s why ‘x:Bind’ works fine in ‘Page’ or ‘UserControl’ — those are still ‘DependencyObject’ types, so the Reference Tracker handles them. Only ‘Window’ falls through the crack.

The real root: the XAML compiler’s T4 template

I traced the problem all the way to this file in the microsoft-ui-xaml repository:

src/src/XamlCompiler/…/CodeGenerators/CSharpPagePass2.tt

Specifically, around lines 296–301:

if (element.Type.IsDerivedFromWindow()) {
  element.Activated += bindings.Activated; // subscribe only
} else {
  element.Loading += bindings.Loading;
}

Both paths subscribe. Neither path unsubscribes. For ‘FrameworkElement’ types, the Reference Tracker covers it. For ‘Window’, nobody’s cleaning up.

And here’s the generated ‘StopTracking()’:

public void StopTracking()
{
  this.bindingsTracking.ReleaseAllListeners(); // PropertyChanged only
  this.initialized = false;
  // Activated? Nothing.
}

In four years and 35+ issue comments, it hadn’t been traced back to this template.

The proposed fix

The Fix

The Fix

Two changes to the T4 template. That’s all it takes.

1. Register a cleanup handler on ‘Window.Closed’:

element1.Activated += bindings.Activated;
// Add:
element1.Closed += (_, _) =>
{
  bindings.StopTracking();
};

The lambda captures only ‘bindings’, not the Window itself, so it doesn’t root the Window.

2. Make ‘StopTracking()’ actually do its job:

public void StopTracking()
{
  if (this.dataRoot != null)
  this.dataRoot.Activated -= this.Activated; // unsubscribe
  this.bindingsTracking.ReleaseAllListeners();
  this.dataRoot = null; // break the back-ref
  this.initialized = false;
}

That’s really all there is to it.

Verification

I built an MSBuild target that automatically patches the generated ‘.g.cs’ files after XAML compilation.

Before patch:

  • GC freed only 50.2 MB (normal windows only)
  • All 5 x:Bind windows still alive

After patch:

  • GC freed 100.2 MB (everything)
  • All 5 x:Bind windows properly finalized
  • Retained memory after GC: dropped from 101 MB to 885 KB

Same test with XBindLeakFix installed: all x:Bind windows properly finalized. Memory dropped from 101 MB to 885 KB.

Same test with XBindLeakFix installed: all x:Bind windows properly finalized. Memory dropped from 101 MB to 885 KB.

It works.

A trap I fell into

My first instinct was to subscribe to ‘Closed’ from inside the Window:

public MyWindow()
{
  this.Closed += OnClosed; // intuitive, but…
}

This actually made things worse. The delegate’s ‘Target’ is ‘this’, the Window itself, so the CCW ends up rooting the Window directly.

The solution is to use a static delegate:

Closed += static (sender, _) =>
{
  ((MyWindow)sender).Cleanup();
};

A static delegate has no ‘Target’, so the CCW doesn’t root the Window.

The workaround you can use right now

How XBindLeakFix works (build pipeline)

How XBindLeakFix works (build pipeline)

Rebuilding the XAML compiler externally isn’t possible. Microsoft has officially confirmed it depends on private internal components. So I built an MSBuild post-processing target that patches the generated code on every build, and published it as a NuGet package.

Install it with one line:

dotnet add package XBindLeakFix

Or add to your ‘.csproj’:

<PackageReference Include="XBindLeakFix" Version="1.0.0" />

On every build, the target finds ‘Activated += bindings.Activated’ in the XAML compiler’s generated ‘.g.cs’ files and injects the cleanup handler. The XAML compiler regenerates each build, and the patch runs again each build. Fully automated, zero code changes needed.

  • NuGet: nuget.org/packages/XBindLeakFix
  • Source + sample app: github.com/blogcin/XBindLeakFix

Nearly 4 years. 35+ comments. And the fix was just 4 lines.

If this bug has been affecting your app, there’s a workaround you can use today. Give XBindLeakFix a try, and if it helps, star the repo or drop a comment on the original issue. The more visibility this gets, the better the chances of an official fix.


메타데이터
post_id
888fa7f2172c
slug
i-traced-a-4-year-old-winui-3-memory-leak-to-4-missing-lines-in-microsofts-xaml-compiler-888fa7f2172c
url
https://medium.com/@hyeonsiksong/i-traced-a-4-year-old-winui-3-memory-leak-to-4-missing-lines-in-microsofts-xaml-compiler-888fa7f2172c
canonical_url
https://medium.com/@hyeonsiksong/i-traced-a-4-year-old-winui-3-memory-leak-to-4-missing-lines-in-microsofts-xaml-compiler-888fa7f2172c
author_url
https://medium.com/@hyeonsiksong
status
ok
fetched_at
2026-07-15 20:14:52