A small .NET console demo that shows how .NET events can silently leak memory in MVVM-style code, and two ways to fix it.
When you subscribe to an event with publisher.SomeEvent += subscriber.Handler, the publisher holds a strong reference to the subscriber.
So if the publisher outlives the subscriber, which is exactly what happens when the publisher is a singleton service, a message bus, a parent ViewModel, or any other long-lived object, the subscriber cannot be garbage collected, even after every other reference to it has been dropped.
In this demo:
Serviceis the long-lived publisher with aDataChangedevent.LeakyViewModelsubscribes toDataChangedin its constructor and never unsubscribes. This is the bug.- The demo creates the VM inside a helper method and returns only a
WeakReferenceto it, so by the time control returns to the caller, no local variable references the VM anymore. After a forced GC, the VM is still alive, becauseServiceis still holding it through the event subscription.
The demo confirms this by wrapping the VM in a WeakReference. WeakReference doesn't count as a GC root, so we can check IsAlive after a forced collection to see whether anything else is keeping the VM rooted. If it's True, something is leaking it.
The leaky scenario also raises an event after the GC, and you'll see the (supposedly gone) VM still respond.
The most direct fix: implement IDisposable on the VM and -= the handler in Dispose.
public class DisposableViewModel : IDisposable
{
private readonly Service _service;
private bool _disposed;
public DisposableViewModel(Service service)
{
_service = service;
_service.DataChanged += OnDataChanged;
}
public void Dispose()
{
if (_disposed) return;
_service.DataChanged -= OnDataChanged;
_disposed = true;
GC.SuppressFinalize(this);
}
}The demo exercises this with RunDisposable(callDispose: true). With Dispose called, the VM is collected after GC and the event raise produces no output.
A reusable helper that subscribes to the event on the VM's behalf, but holds the VM via a WeakReference. The publisher ends up holding the wrapper strongly, but the wrapper doesn't keep the VM alive.
_serviceSubscription = new WeakEventSubscription<Service, string>(
source: service,
attach: (s, h) => s.DataChanged += h,
detach: (s, h) => s.DataChanged -= h,
target: this,
invoke: static (target, sender, args) =>
((WrapperViewModel)target).OnDataChanged(sender, args));The demo exercises this with RunWrapper. The VM is collected after GC even though Dispose is never called on the wrapper VM in the test.
Program.cs runs three scenarios. For each one, Helper:
- Creates a
Service. - Creates the VM inside a helper method so its local reference falls out of scope cleanly when the method returns. (This matters — in Debug builds the JIT may keep
Main-scope locals rooted for the entire method, which would falsely report a leak.) - Returns a
WeakReferenceto the VM. TheWeakReferenceis a probe — it observes whether the VM survives a GC without itself being a GC root. - Forces a full GC.
- Prints whether the VM is still alive.
- Raises an event to demonstrate whether a zombie subscriber is still reacting.
- .NET 8 SDK (or whichever version your
.csprojtargets) — rundotnet --versionin a terminal to confirm. - VS Code.
- The C# Dev Kit extension (which pulls in the base C# extension automatically).
-
Clone and open the repo:
git clone https://github.com/AminZar69/EventHandlerLeakDemo.git cd EventHandlerLeakDemo code .
-
Restore packages. The C# Dev Kit will usually do this automatically when it detects the
.csproj. If it doesn't, open the integrated terminal (Ctrl+`) and run:dotnet restore
-
Build from the terminal:
dotnet build
Or press
Ctrl+Shift+Band pick thebuildtask. -
Run from the terminal:
dotnet run