Event handler conventions (sender, EventArgs, EventArgs.Empty, OnXxx), async void handlers, and diagnosing and preventing event-subscription memory leaks with dotnet-dump and weak events.
The convention is EventHandler<TEventArgs> with object? sender and an immutable args object, EventArgs.Empty when there is no data, and a protected virtual OnXxx method that raises it. The big production risk is leaks: the publisher's delegate holds a strong reference to every subscriber, so a singleton or static publisher keeps short-lived subscribers alive forever. I find them with a heap dump and gcroot, which shows the path through the event's delegate, and fix them by unsubscribing in Dispose or using a weak event. Async handlers are async void, so I keep them thin and catch everything inside.
I'd cover the .NET event conventions (EventHandler, EventHandler<TEventArgs>, sender + EventArgs), the problems with async event handlers, and then the most common production issue: event subscriptions causing memory leaks — how to find them with memory tools and how to fix them.
public sealed class OrderShippedEventArgs(OrderId orderId, DateTimeOffset shippedAt) : EventArgs
{
public OrderId OrderId { get; } = orderId;
public DateTimeOffset ShippedAt { get; } = shippedAt;
}
public sealed class Shipping
{
public event EventHandler<OrderShippedEventArgs>? OrderShipped;
private void OnOrderShipped(OrderShippedEventArgs e) => OrderShipped?.Invoke(this, e);
}
EventHandler (no data, pass EventArgs.Empty) or EventHandler<TEventArgs> rather than custom delegate types.sender: the object raising the event (this), or null for static events. Typed as object? by convention so handlers can be shared.TEventArgs: immutable data about the event; since .NET 4.5 it doesn't have to derive from EventArgs, but deriving is conventional. Name it XxxEventArgs.OrderShipped, Closed) or "-ing" for cancellable pre-events (Closing with a Cancel property, like CancelEventArgs).protected virtual void OnXxx(TEventArgs e).EventHandler returns void, so async handlers are async void:
shipping.OrderShipped += async (s, e) => await _email.SendAsync(e.OrderId); // async void
Problems:
async void handlers are raised on the SynchronizationContext or thread pool and can crash the process (in ASP.NET Core and workers).Options:
async void handlers are acceptable with a try/catch inside (the UI context catches and reports).event Func<OrderShippedEventArgs, CancellationToken, Task>? OrderShipped; and raise by iterating GetInvocationList() and awaiting each (sequentially or Task.WhenAll).INotificationHandler<T>) or a Channel<T> to decouple, with proper error handling and retries.publisher.Event += subscriber.Handle; stores a delegate whose target is subscriber. The publisher's invocation list now holds a strong reference to the subscriber. If the publisher lives longer than the subscriber (static events, singletons, long-lived services, application-level objects) and the subscriber never unsubscribes, the subscriber — and everything it references (view models, DbContext, large buffers) — can't be garbage collected.
Common sources: static events, singleton services exposing events subscribed by scoped/transient objects, SystemEvents, timers, IOptionsMonitor.OnChange registrations not disposed, UI controls subscribing to application-wide events, lambdas capturing this.
dotnet-counters: gc-heap-size, gen-2-gc-count rising; Application Insights memory charts); Gen2 size never returns to baseline.dotnet-gcdump collect -p <pid> (lightweight) or dotnet-dump collect, before and after some load; compare in Visual Studio or PerfView — look for types whose count keeps increasing (e.g. OrderViewModel, ReportService).dotnet-dump analyze → gcroot <address>. A path like static field AppEvents.TenantChanged → EventHandler (invocation list) → ReportService is the smoking gun.+= without a matching -=.OnStart, unsubscribe in Dispose/OnStop (implement IDisposable; DI disposes scoped services).IDisposable registrations (IOptionsMonitor.OnChange, CancellationToken.Register, IObservable.Subscribe) and dispose them.WeakEventManager, CommunityToolkit WeakReferenceMessenger).this in lambdas subscribed to long-lived events unless you unsubscribe.[In my case] a [Blazor Server / desktop] app's memory grew [~200 MB per hour]. Two dotnet-gcdump snapshots showed [thousands] of DashboardViewModel instances accumulating. The retention path went through a singleton NotificationHub.MessageReceived event. Each dashboard subscribed in its constructor and never unsubscribed when the page/circuit closed. I implemented IDisposable on the view model to unsubscribe, and changed the hub to expose IDisposable Subscribe(Action<Message>). Memory became flat and Gen2 collections dropped.
A practical, senior-level debugging question: conventions plus the ability to diagnose a real leak.
Green flags: strong-reference explanation with direction (publisher keeps subscriber alive); concrete tools (dotnet-gcdump, dotnet-dump, gcroot); IOptionsMonitor.OnChange example; async void risks; deterministic fixes before weak events.
Red flags: "the GC handles it"; thinks the subscriber keeps the publisher alive (wrong direction); no diagnostic approach.
GC root (static field / singleton)
└── Publisher
└── _orderShipped : EventHandler (MulticastDelegate)
└── _invocationList[i] → delegate { _target = Subscriber, _methodPtr = Subscriber.Handle }
└── Subscriber's fields (DbContext, buffers, ...)
The GC considers everything reachable from roots as live. The subscriber can only be collected when the publisher is collected or the delegate is removed.
The reverse isn't a leak: a subscriber holding a reference to the publisher doesn't keep anything extra alive beyond normal ownership.
A weak event manager stores handlers via WeakReference to the target plus the method, and invokes them only if the target is still alive (cleaning up dead entries). Trade-offs:
.NET's ConditionalWeakTable<TKey, TValue> helps associate data with objects without extending their lifetime.
public event Func<OrderShippedEventArgs, CancellationToken, Task>? OrderShippedAsync;
private async Task RaiseOrderShippedAsync(OrderShippedEventArgs e, CancellationToken ct)
{
if (OrderShippedAsync is not { } handlers) return;
foreach (Func<OrderShippedEventArgs, CancellationToken, Task> h in handlers.GetInvocationList())
{
try { await h(e, ct); }
catch (Exception ex) { _logger.LogError(ex, "Handler failed"); }
}
}
Sequential awaiting preserves order; Task.WhenAll runs them concurrently. Libraries like AsyncEvent patterns in Discord.Net or Microsoft.VisualStudio.Threading's AsyncEventHandler formalise this.
| Tool | Use |
|---|---|
dotnet-counters |
Spot growth in GC heap, Gen2 size, allocation rate |
dotnet-gcdump |
Lightweight heap snapshots (type counts, sizes, roots) — safe in production |
dotnet-dump + analyze |
Full dumps: dumpheap -stat, dumpheap -type X, gcroot <addr> |
| Visual Studio Diagnostic Tools / memory analysis | Snapshot diffing, paths to root |
| JetBrains dotMemory | Retention paths, automatic event-handler leak inspection |
| PerfView | Heap snapshots and diffs on Windows |
Automated leak tests create an object, subscribe it, drop references, force GC, and assert it was collected:
var weak = CreateAndSubscribe(publisher); // returns WeakReference to subscriber
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
Assert.False(weak.IsAlive);
(Run in Release-like conditions; debug builds may extend lifetimes.)
dotnet-gcdump snapshots.gcroot.IDisposable unsubscription and verify with a weak-reference test.Func<T, CancellationToken, Task> and isolated error handling.namespace Delegates.EventHandlers;
public sealed class FlagsChangedEventArgs(IReadOnlyDictionary<string, bool> flags) : EventArgs
{
public IReadOnlyDictionary<string, bool> Flags { get; } = flags;
}
public sealed class FeatureFlagService // registered as a singleton
{
private Dictionary<string, bool> _flags = new() { ["NewPricing"] = false };
public event EventHandler<FlagsChangedEventArgs>? FlagsChanged;
public bool IsEnabled(string name) => _flags.GetValueOrDefault(name);
public void Update(string name, bool enabled)
{
_flags = new Dictionary<string, bool>(_flags) { [name] = enabled };
OnFlagsChanged(new FlagsChangedEventArgs(_flags));
}
private void OnFlagsChanged(FlagsChangedEventArgs e) => FlagsChanged?.Invoke(this, e);
}
// Leaky version: scoped subscriber, never unsubscribes
public sealed class LeakyPricingCalculator
{
private readonly byte[] _cache = new byte[1024 * 1024];
public LeakyPricingCalculator(FeatureFlagService flags) => flags.FlagsChanged += OnFlagsChanged;
private void OnFlagsChanged(object? sender, FlagsChangedEventArgs e) => Array.Clear(_cache);
}
// Fixed version: unsubscribes when the DI scope disposes it
public sealed class PricingCalculator : IDisposable
{
private readonly FeatureFlagService _flags;
private bool _useNewPricing;
public PricingCalculator(FeatureFlagService flags)
{
_flags = flags;
_useNewPricing = flags.IsEnabled("NewPricing");
_flags.FlagsChanged += OnFlagsChanged;
}
private void OnFlagsChanged(object? sender, FlagsChangedEventArgs e)
=> _useNewPricing = e.Flags.GetValueOrDefault("NewPricing");
public decimal Price(decimal baseAmount) => _useNewPricing ? baseAmount * 0.95m : baseAmount;
public void Dispose() => _flags.FlagsChanged -= OnFlagsChanged;
}
public static class Program
{
public static void Main()
{
var flags = new FeatureFlagService();
for (var i = 0; i < 100; i++) _ = new LeakyPricingCalculator(flags); // 100 MB kept alive
for (var i = 0; i < 100; i++) { using var calc = new PricingCalculator(flags); _ = calc.Price(100m); }
GC.Collect();
GC.WaitForPendingFinalizers();
Console.WriteLine($"Heap after collect: {GC.GetTotalMemory(forceFullCollection: true) / 1024 / 1024} MB");
flags.FlagsChanged += async (_, e) =>
{
try { await Task.Delay(10); Console.WriteLine("async handler done"); }
catch (Exception ex) { Console.WriteLine(ex); } // async void: catch inside
};
flags.Update("NewPricing", true);
Thread.Sleep(50);
}
}
What happens: short-lived objects subscribe to long-lived publishers in constructors.
Why it is wrong: subscribers are kept alive forever — memory leaks.
Do instead: unsubscribe in Dispose, or use disposable subscription APIs.
async void handlers in server codeWhat happens: += async (s, e) => await ... in services.
Why it is wrong: exceptions can crash the process and the publisher can't await completion.
Do instead: async-aware events, mediator handlers or channels.
What happens: public delegate void OrderHandler(Order o); for every event.
Why it is wrong: inconsistent signatures and no sender.
Do instead: EventHandler<TEventArgs>.
What happens: handlers modify shared event data.
Why it is wrong: later handlers see changed data; order dependence.
Do instead: immutable EventArgs (except deliberate patterns like Cancel).
this in lambdas on static eventsWhat happens: AppEvents.Changed += (s, e) => Refresh(); in a view model.
Why it is wrong: can't easily unsubscribe and leaks the view model.
Do instead: store the handler and unsubscribe, or use weak events.
What happens: restarting services periodically to "fix" memory growth. Why it is wrong: hides the root cause and costs availability. Do instead: take heap snapshots and follow GC root paths.
What happens: IOptionsMonitor.OnChange(...) return values discarded in scoped services.
Why it is wrong: callbacks accumulate and keep services alive.
Do instead: keep and dispose the returned IDisposable.
What happens: weak events for all subscriptions. Why it is wrong: handlers with captured state get collected unexpectedly; behaviour depends on GC timing. Do instead: prefer explicit lifetimes; use weak events where lifetimes can't be controlled.
Event leaks are one of the most common managed memory leaks I have seen in long-running services, and they hide well because nothing throws. My checklist when reviewing an event subscription: who lives longer, publisher or subscriber? Who unsubscribes, and when? Is the handler async? In DI-based apps the worst case is a singleton publisher and scoped subscribers, which also drags DbContexts along. I also push teams towards pull-based designs (read IOptionsMonitor.CurrentValue) where the push is not essential.
RAM is physical memory. Each process gets a virtual address space that the OS maps onto RAM page by page. The working set is what's resident in RAM right now; private (committed) memory is what the process owns. The GC heap is only one part: native allocations (native libraries, NativeMemory, unmanaged buffers), thread stacks, JIT code, and runtime structures add to it. In Kubernetes, the cgroup limit counts all of it, and going over gets the container OOMKilled (exit code 137), not an OutOfMemoryException. So if the GC heap is small but the pod dies, I compare GC heap size with working set in dotnet-counters. A big gap points to native memory or too many threads; a growing GC heap points to a managed leak I'd chase with dotnet-gcdump. Then I fix the cause and set limits and GC settings deliberately.
In ASP.NET Core, register AddProblemDetails(), one or more IExceptionHandler implementations (.NET 8+) with AddExceptionHandler<T>(), and app.UseExceptionHandler() early in the pipeline. Handlers log once and map known exceptions to the right status code and an RFC 9457 ProblemDetails body, and never leak stack traces in production. For the process as a whole, AppDomain.CurrentDomain.UnhandledException is a last-chance notification: you can log and flush, but the process still terminates. TaskScheduler.UnobservedTaskException fires when a faulted task that nobody awaited is garbage-collected; it does not crash the process by default. In a BackgroundService, an unhandled exception in ExecuteAsync stops the host by default (.NET 6+). async void exceptions go to the synchronisation context and usually crash the process, so avoid async void except for event handlers.
An event is a delegate member with access restricted to subscribe and unsubscribe. Outside the class you can only use += and -=; only the owner can raise it or clear it, whereas a public delegate field can be invoked or overwritten by anyone. For a field-like event the compiler generates a private delegate field and thread-safe add and remove accessors. I raise it with Changed?.Invoke(this, e), which reads the field once. I avoid static events because they keep every subscriber alive until it unsubscribes.
How to tell it (STAR)
**S:** Our pricing pods were being OOM-killed every [number] hours. **T:** I owned finding the leak. **A:** I compared gcdumps, saw scoped calculators accumulating, and used gcroot to show they were held by a singleton's event invocation list. I fixed the lifetime issue, then removed the push subscription in favour of reading current flags. **R:** Memory went flat at [amount], restarts stopped, and p95 latency improved by [amount].
Last updated 30 Oct 2026.