📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

How should you design and handle events with EventHandler and EventHandler<TEventArgs>? Explain the sender and EventArgs conventions, async event handlers, and how event subscriptions cause memory leaks, including how you would find and fix one.

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.

Senior Debugging Advanced async csharp event-handlers events memory-leaks

🎯 Key points

  • Standard signature: `void Handler(object? sender, TEventArgs e)` using `EventHandler` or `EventHandler<TEventArgs>`
  • Since .NET Framework 4.5, TEventArgs need not derive from EventArgs, but deriving and using immutable args remains the convention
  • Use `EventArgs.Empty` when there is no data, and raise from `protected virtual OnXxx`
  • The publisher's delegate holds a strong reference to each subscriber: a long-lived publisher keeps short-lived subscribers alive
  • Fix leaks by unsubscribing (IDisposable), shortening publisher lifetime, or weak event patterns
  • Diagnose with dotnet-gcdump or dotnet-dump: `dumpheap -stat`, then `gcroot` showing the path through a delegate invocation list
  • Async handlers are `async void`; exceptions go to the synchronisation context or crash the process, and the publisher cannot await them

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. Conventions

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);
}
  • Use 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.
  • Event names describe what happened: past tense (OrderShipped, Closed) or "-ing" for cancellable pre-events (Closing with a Cancel property, like CancelEventArgs).
  • Raise via protected virtual void OnXxx(TEventArgs e).

2. Async event handlers

EventHandler returns void, so async handlers are async void:

shipping.OrderShipped += async (s, e) => await _email.SendAsync(e.OrderId);   // async void

Problems:

  • The publisher can't await them — it continues before handlers finish; ordering and completion are unknown.
  • Exceptions in async void handlers are raised on the SynchronizationContext or thread pool and can crash the process (in ASP.NET Core and workers).
  • No backpressure: fast publishers create unbounded concurrent work.

Options:

  • In UI apps, async void handlers are acceptable with a try/catch inside (the UI context catches and reports).
  • For server code, define async-aware events: event Func<OrderShippedEventArgs, CancellationToken, Task>? OrderShipped; and raise by iterating GetInvocationList() and awaiting each (sequentially or Task.WhenAll).
  • Better still: use a mediator/handler abstraction (INotificationHandler<T>) or a Channel<T> to decouple, with proper error handling and retries.

3. How event subscriptions leak memory

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.

4. Finding a leak

  1. Observe: memory grows steadily under constant load (dotnet-counters: gc-heap-size, gen-2-gc-count rising; Application Insights memory charts); Gen2 size never returns to baseline.
  2. Snapshot twice: 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).
  3. Find the GC root path: in Visual Studio's memory analysis "Paths to Root", dotMemory "Key retention paths", or dotnet-dump analyze → gcroot <address>. A path like static field AppEvents.TenantChanged → EventHandler (invocation list) → ReportService is the smoking gun.
  4. Confirm with code: find the += without a matching -=.

5. Fixing it

  • Unsubscribe symmetrically: subscribe in a constructor/OnStart, unsubscribe in Dispose/OnStop (implement IDisposable; DI disposes scoped services).
  • Use APIs that return IDisposable registrations (IOptionsMonitor.OnChange, CancellationToken.Register, IObservable.Subscribe) and dispose them.
  • Avoid static events and long-lived publishers for short-lived subscribers; invert the relationship (publisher pushes to a scoped handler resolved per event).
  • Use weak events where lifetimes can't be controlled (UI frameworks: WeakEventManager, CommunityToolkit WeakReferenceMessenger).
  • Don't capture this in lambdas subscribed to long-lived events unless you unsubscribe.

6. A production example (how I'd tell it)

[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.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. Why the publisher keeps the subscriber alive

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.

2. Weak events

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:

  • Can't use lambdas that capture local state (the closure object would be collected immediately unless held elsewhere).
  • Slight overhead per raise and cleanup.
  • Behaviour depends on GC timing — handlers may still run briefly after the subscriber is "done".

.NET's ConditionalWeakTable<TKey, TValue> helps associate data with objects without extending their lifetime.

3. Async event design patterns

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.

4. Diagnostic tools

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

5. Testing for leaks

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.)

Try it yourself

  1. Subscribe many short-lived objects to a static event and take two dotnet-gcdump snapshots.
  2. Find the retention path with gcroot.
  3. Fix it with IDisposable unsubscription and verify with a weak-reference test.
  4. Implement an async-aware event with Func<T, CancellationToken, Task> and isolated error handling.

💻 Code

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);
    }
}

⚠ Common mistakes

1. Subscribing without unsubscribing

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.

2. async void handlers in server code

What 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.

3. Custom delegate types for events

What happens: public delegate void OrderHandler(Order o); for every event. Why it is wrong: inconsistent signatures and no sender. Do instead: EventHandler<TEventArgs>.

4. Mutable EventArgs

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).

5. Capturing this in lambdas on static events

What 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.

6. Diagnosing leaks by guessing

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.

7. Ignoring disposable registrations

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.

8. Using weak events everywhere

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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate describes memory growth in Kubernetes. Why: Leaks in containers end in OOMKilled pods; tests memory investigation skills.
    How to answer

    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.

  2. When: Candidate mentions async void event handlers. Why: async void handler exceptions crash the process; tests global exception handling.
    How to answer

    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.

  3. When: Candidate talks about raising events safely. Why: Checks understanding of what the event keyword generates.
    How to answer

    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.

🛠 Real-world problems: how you handle them

Scoped services leaking through a singleton's event
Situation
An ASP.NET Core pricing API on Azure Kubernetes Service. A singleton FeatureFlagService raised FlagsChanged; a scoped PricingCalculator subscribed in its constructor.
Signals
Memory grew about 200 MB per hour per pod, Gen 2 heap steadily rising in dotnet-counters, pods OOM-killed every [number] hours; latency also crept up because `+=` copied an ever-larger invocation list.
Investigation
Took two dotnet-gcdump snapshots 10 minutes apart and saw PricingCalculator and AppDbContext counts growing. In a full dump, `gcroot` on a calculator showed FeatureFlagService._flagsChanged -> invocation list -> PricingCalculator.
Resolution
Made PricingCalculator implement IDisposable and unsubscribe; later removed the subscription entirely by reading the flag through IOptionsMonitor.CurrentValue at use time.
Prevention
Added a code review rule for subscriptions across lifetimes, an integration test that runs 10,000 scoped resolutions and asserts the subscriber count stays flat, and a memory-growth alert in Application Insights.

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].

↩ Usually asked after

More in Delegates and Events

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me