Delegates, events and closures look simple from the outside, which is exactly why they produce some of the trickiest bugs a senior engineer will debug in production: a memory leak from an event nobody unsubscribed, a loop that prints the same value ten times, or a handler whose exception silently vanishes because it ran on an async void method. Interviewers use this area to see whether you understand what the compiler actually generates for a closure or a field-like event, not just how to write +=. The ten questions below cover multicast invocation and return values, event lifetime and weak event patterns, closure capture of loop variables, the real cost of a capturing lambda, thread-safe event raising, and how to choose between a plain event, IObservable<T> and System.Threading.Channels for a given producer/consumer problem. Expect scenario questions where you have to spot the bug in a short code sample.
Q1 What actually happens when you invoke a multicast delegate that returns a value, and how do you collect every handler's result?#
Short answer: A delegate instance holds an ordered invocation list, and calling it runs every target in order, but the call only returns the last target's result, and the first thrown exception stops the remaining targets from ever running. If you need every result, or you need one handler's failure to not block the others, you must walk the invocation list yourself instead of invoking the multicast delegate directly.
This surprises people because nothing about the syntax suggests it: Func<T> looks like it returns one value because it does, but a caller who combined several targets with += has no compile-time signal that only the last one's return value is observable. Delegate.EnumerateInvocationList (available since .NET 9) walks the list without allocating the array that GetInvocationList() returns, which matters if this runs on a hot path.
Func<decimal, decimal> discounts = price => price;
discounts += price => price * 0.95m;
discounts += price => throw new InvalidOperationException("Seasonal rule unavailable.");
discounts += price => price * 0.90m;
var results = new List<decimal>();
var errors = new List<Exception>();
foreach (var discount in Delegate.EnumerateInvocationList(discounts))
{
try { results.Add(discount(100m)); }
catch (Exception ex) { errors.Add(ex); }
}
// discounts(100m) alone would only return 90m and never run the failing target's neighbors.What interviewers look for: immediate recognition that only the last return value survives, and a working strategy (manual iteration) for both aggregating results and isolating failures, since both come up in real handler-chain designs.
- Common mistakes: assuming a multicast
Funcbehaves like a pipeline where each handler sees the previous one's result; each target is called independently with the original arguments. - Follow-up questions: How would you change the design if you actually wanted handlers chained, each transforming the previous output? (Use a single delegate that composes explicitly, or a small pipeline abstraction, not multicast invocation.)
Q2 How do .NET events cause memory leaks, and how does a weak event pattern fix it?#
Short answer: An event's invocation list holds a strong reference to every subscriber through the delegate's target, so a long-lived publisher, such as a singleton service or a static event, keeps every subscriber alive for as long as the publisher lives, regardless of whether anything else references the subscriber. A weak event pattern breaks that chain by storing a weak reference to the subscriber instead of relying on the delegate itself to hold it.
The classic symptom is a UI element, request-scoped handler or per-user object that should be collected after use but is not, because it never called -= on a publisher that outlives it. The fix that requires no special infrastructure is deterministic unsubscription, typically in Dispose, tied to the subscriber's own lifetime. When you cannot guarantee that, for example because the subscriber's lifetime is genuinely shorter and unpredictable relative to the publisher's, a weak event source keeps only a WeakReference to the target and an open-instance method reference, pruning dead entries as it raises the event:
public sealed class WeakEventSource<TArgs>
{
private readonly List<(WeakReference Target, MethodInfo Method)> _handlers = [];
public void Subscribe(Action<TArgs> handler) =>
_handlers.Add((new WeakReference(handler.Target), handler.Method));
public void Raise(TArgs args)
{
for (var i = _handlers.Count - 1; i >= 0; i--)
{
var (target, method) = _handlers[i];
if (target.Target is { } alive)
method.Invoke(alive, [args]);
else
_handlers.RemoveAt(i); // subscriber was collected; drop it
}
}
}What interviewers look for: the causal chain stated precisely (publisher outlives subscriber, delegate target keeps it alive), and awareness that a full production implementation, such as WPF's weak event manager or an MVVM toolkit's messenger, adds thread safety and avoids per-call reflection cost that this simplified version pays.
- Common mistakes: assuming
WeakReferencewrapping the delegate itself helps; the delegate must still be held strongly somewhere to be invokable, so the weak reference has to target the subscriber, not the multicast delegate. - Follow-up questions: Why is a weak event source usually reserved for framework-level or long-lived publishers rather than everyday application code? (It adds real complexity and a reflection cost; deterministic
Dispose-based unsubscription solves the same problem more simply when the subscriber's lifetime is under your control.)
Q3 Why does a lambda inside a for loop sometimes print the same value for every iteration, and how did this change historically?#
Short answer: A lambda captures the variable, not its value at the time the lambda was created, and a C-style for loop declares one variable that every iteration reuses, so all the captured lambdas end up reading whatever the variable holds when they finally run, typically the loop's final value. foreach has captured a fresh variable per iteration since C# 5; before that version it had the identical bug.
This is one of the most consistently asked closure questions because it tests whether you understand capture-by-reference-to-variable at a mechanical level, not just as a memorized gotcha. The compiler lifts captured variables into a hidden class instance; a for loop's counter lives in one instance shared by every lambda created inside the loop body, while foreach since C# 5 allocates a new logical variable, and therefore a new hidden instance, for each iteration.
var afterFor = new List<Action>();
for (var i = 0; i < 3; i++)
afterFor.Add(() => Console.Write(i));
afterFor.ForEach(a => a()); // 333: one shared i
var afterForEach = new List<Action>();
foreach (var n in new[] { 0, 1, 2 })
afterForEach.Add(() => Console.Write(n));
afterForEach.ForEach(a => a()); // 012: fresh n per iteration since C# 5
var fixedFor = new List<Action>();
for (var i = 0; i < 3; i++)
{
var local = i; // copy into a loop-scoped local
fixedFor.Add(() => Console.Write(local));
}
fixedFor.ForEach(a => a()); // 012What interviewers look for: a precise explanation ("captures the variable, not the value"), not just "loops and closures are tricky," plus the correct historical detail that foreach changed in C# 5 while for never did, because a for loop's semantics (one declared variable, mutated by the increment) did not change.
- Common mistakes: claiming C# "fixed closures" universally; only
foreachchanged, and aforloop still needs a manually copied local. - Follow-up questions: Does this issue apply to
Parallel.For? (Each worker gets a distinct loop index by design there, so the classic trap does not apply the same way, but capturing other shared state still can race.)
Q4 What does capturing a variable actually cost at run time, and how does marking a lambda static help?#
Short answer: A lambda that captures a local, a parameter or this forces the compiler to generate a hidden closure class, allocate an instance of it when the enclosing scope is entered, and allocate a new delegate instance pointing at the closure's method each time the lambda expression runs. A lambda that captures nothing is different: the compiler caches a single delegate instance in a static field and reuses it forever, and the static modifier (C# 9) makes that guarantee explicit by turning any accidental capture, including of this, into a compile error.
This matters most in code that runs per request or in a tight loop, where a capturing lambda passed to Where, GetOrAdd or a similar higher-order API allocates on every call even though the logic never changes. The fix many high-throughput APIs support is a static lambda combined with an explicit state argument, so the value that would otherwise be captured is passed as data instead of closed over.
public sealed class TenantCache(string connectionString)
{
private readonly ConcurrentDictionary<string, Tenant> _tenants = new();
// Captures `this` for connectionString: allocates a delegate every call.
public Tenant GetCapturing(string id) =>
_tenants.GetOrAdd(id, key => Tenant.Load(key, connectionString));
// Static lambda plus a state argument: no capture, delegate is cached once.
public Tenant Get(string id) =>
_tenants.GetOrAdd(id, static (key, cs) => Tenant.Load(key, cs), connectionString);
}What interviewers look for: the distinction between "no capture, cached delegate" and "capture, allocate every time," plus knowing that static is enforced by the compiler rather than a naming convention or comment.
- Common mistakes: believing every lambda allocates on every call; non-capturing lambdas are cached and reused regardless of whether you mark them
static, which is a documentation and safety net, not the only way to avoid the allocation. - Follow-up questions: Why would
Delegate.EnumerateInvocationListor a hot event with many subscribers make this allocation cost more visible? (Every raise touches every target, so per-call allocations multiply by the subscriber count and the raise frequency together.)
Q5 What is the standard pattern for raising an event thread-safely, and what race does it actually close?#
Short answer: Raise a field-like event with EventName?.Invoke(this, args) rather than a separate null check followed by a call, because ?.Invoke reads the delegate field into a temporary exactly once before checking it for null and invoking it. A plain if (EventName != null) EventName(this, args); reads the field twice, so a concurrent unsubscribe between the check and the call can null it out and throw NullReferenceException at the invocation.
The compiler already generates thread-safe add and remove accessors for a field-like event, so concurrent subscribe and unsubscribe calls do not corrupt the invocation list itself; the race that ?.Invoke closes is specifically the check-then-call gap on the raising side. What it does not give you is a guarantee that a handler will never run after the subscriber considered itself unsubscribed: if unsubscribe and raise happen concurrently, the handler that was about to be removed may still fire once, because the delegate snapshot was already captured. Designs that cannot tolerate that need an explicit lock around both subscription changes and raising, at the cost of contention.
public class OrderService
{
public event EventHandler<OrderPlacedEventArgs>? OrderPlaced;
protected virtual void OnOrderPlaced(OrderPlacedEventArgs e) =>
OrderPlaced?.Invoke(this, e); // one field read, safe against concurrent unsubscribe
}What interviewers look for: the precise mechanism (single read versus double read), not just "use ?.Invoke because it's safer," and awareness that it prevents a crash but does not give exactly-once-after-unsubscribe semantics.
- Common mistakes: believing
?.Invokemakes the handler's own code thread-safe; it only protects the null-check-and-call sequence on the publisher's side. - Follow-up questions: How would you guarantee no handler runs after
Disposereturns on the subscriber? (Take a lock shared between unsubscribe and raise, or redesign around a cancellation token the handler checks.)
Q6 Why does -= sometimes silently fail to remove a handler you are sure you subscribed?#
Short answer: Delegates are immutable value-like references compared by target and method, so -= removes the last invocation-list entry that is equal by that comparison, but a lambda expression written a second time is a brand-new delegate instance, not equal to the one you originally subscribed, even though the source code looks identical. If you plan to unsubscribe a lambda later, you must keep a reference to the exact delegate instance you subscribed.
+= and -= compile to Delegate.Combine and Delegate.Remove, and because delegates are immutable, both return a new delegate instance rather than mutating the existing one, which is also why events are safe to combine concurrently: each add or remove produces a fresh list rather than editing a shared one in place. Delegate.Remove specifically removes the last matching entry, which matters when the same handler was added more than once.
publisher.Changed += (_, _) => Console.WriteLine("changed");
publisher.Changed -= (_, _) => Console.WriteLine("changed"); // no-op: a different delegate instance
EventHandler handler = (_, _) => Console.WriteLine("changed");
publisher.Changed += handler;
publisher.Changed -= handler; // works: same delegate instanceWhat interviewers look for: the target-and-method equality rule stated correctly, and the practical habit of storing a named delegate reference for anything you intend to unsubscribe.
- Common mistakes: assuming
-=throws or warns when nothing matches; it is a silent no-op. - Follow-up questions: If the same method group is subscribed twice, what does one
-=do? (Removes only one occurrence, the last one added; the other still fires.)
Q7 Why is async void acceptable for an event handler when it is discouraged everywhere else, and how do you keep it safe?#
Short answer: The standard .NET event pattern is synchronous, void Handler(object? sender, TEventArgs e), so a handler that must call asynchronous code has no async Task signature to return; async void is the only shape that fits, which is exactly why UI frameworks and the event pattern make an exception for it. The danger is that an exception thrown inside an async void method is raised directly on the synchronization context instead of being captured in a Task, so nothing the publisher does can observe or handle it, and on many hosts it terminates the process.
The safe pattern is to treat the top of every async void handler as a boundary: wrap the body in try/catch, log or otherwise handle failures inside the handler itself, and never let an exception propagate out of it. Everywhere else in a codebase, async Task remains the right signature, because callers can await it, observe its exceptions, and compose it with other asynchronous work.
private async void OnOrderPlaced(object? sender, OrderPlacedEventArgs e)
{
try
{
await _notifier.SendReceiptAsync(e.OrderId, CancellationToken.None);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send receipt for order {OrderId}", e.OrderId);
}
}What interviewers look for: the specific reason async void exists for handlers (signature compatibility with the event pattern) and the specific danger (unobservable exceptions), not a blanket "never use async void" rule that ignores why the exception exists.
- Common mistakes: wrapping the
awaitcall intry/catchbut leaving synchronous code before it unguarded, which can still throw synchronously into the publisher's raise call. - Follow-up questions: How would you test that an
async voidhandler behaves correctly on failure? (Invoke it directly in a test and await anyTaskyour test harness can observe, or refactor the logic into anasync Taskmethod that the handler merely calls and awaits inside its owntry.)
Q8 How do you choose between a plain .NET event, IObservable<T>, and System.Threading.Channels for a producer/consumer design?#
Short answer: All three connect a producer of occurrences to one or more consumers, but they differ in who can push data, how consumers compose logic over the stream, and whether backpressure exists. A plain event is the simplest and cheapest choice for synchronous, in-process notifications with no need to compose or buffer. IObservable<T> adds a rich set of composition operators over time, at the cost of a library dependency for anything beyond the bare interfaces. A channel adds real asynchronous backpressure and is the right choice when a producer can outpace a consumer.
| Aspect | Event | IObservable<T> | Channel<T> |
|---|---|---|---|
| Who can raise it | Only the declaring type | Any code holding the IObserver<T> | Any code holding the ChannelWriter<T> |
| Composition | None built in | Rich operators (Select, Where, Throttle, and more) via the System.Reactive package | Manual; you write the loop |
| Backpressure | None; the raiser is not blocked | Push-based; depends on the observer | Built in for bounded channels |
| Async-friendly consumption | Awkward (async void handlers) | Yes, with Rx operators | Yes, ReadAllAsync() returns IAsyncEnumerable<T> |
| Typical use | In-process notifications, UI and domain events | Composed streams (market data, telemetry pipelines) | Producer/consumer queues across async code, worker pipelines |
IObservable<T> and IObserver<T> themselves live in the base class library, but the fluent operators, Subject<T> and most of what makes Rx practical to use come from the separate System.Reactive package. System.Threading.Channels ships inbox on modern .NET and needs no package reference; it is usually the better fit for backend code that hands work between an async producer and an async consumer, such as a background worker draining a queue, while events remain the natural fit for "something happened" notifications inside a single process.
What interviewers look for: a decision framework based on backpressure and composition needs, not a claim that one option universally replaces the others.
- Common mistakes: reaching for
IObservable<T>purely to avoid an event, without needing its composition operators, which adds a dependency for no real benefit. - Follow-up questions: When would you put a channel behind an event-like API? (A bounded channel internally, with a simple
event-shaped or callback-shaped public surface, when you want backpressure without exposing channel types to callers.)
Q9 How do you unit test code that raises or depends on an event, without coupling the test to implementation details?#
Short answer: Subscribe a handler from the test, trigger the action that should raise the event, and assert on what the handler captured, exactly as a real consumer would; avoid reaching into the publisher's invocation list through reflection, because that couples the test to the delegate's internal representation rather than its observable behavior.
A handler that records every raised argument into a list is usually enough to assert both that the event fired and with what data, including asserting it did not fire when it should not have. For asynchronous work triggered from a handler, await a TaskCompletionSource that the test's handler completes, rather than sleeping, to keep the test both fast and deterministic. Reflecting over GetInvocationList() to count subscribers or infer implementation details is a smell: it tests how the publisher is built, not what it does, and breaks the moment the implementation changes shape without changing behavior.
[Fact]
public async Task PlaceOrder_raises_OrderPlaced_with_correct_total()
{
var service = new OrderService();
var captured = new List<OrderPlacedEventArgs>();
service.OrderPlaced += (_, e) => captured.Add(e);
service.PlaceOrder(Guid.NewGuid(), 42.50m);
Assert.Single(captured);
Assert.Equal(42.50m, captured[0].Total);
}What interviewers look for: testing through the public event contract rather than reflection, and comfort with asynchronous test synchronization when a handler triggers further asynchronous work.
- Common mistakes: asserting on
GetInvocationList().Lengthto check whether "the event still has the right handler," which tests plumbing rather than behavior. - Follow-up questions: How would you test that a subscriber correctly unsubscribes and stops receiving events after
Dispose? (Dispose it, raise again, and assert the recorded list did not grow.)
Q10 You are reviewing a public API's event design. What do you check to make sure it will not become a memory-leak trap for consumers?#
Short answer: Check who is expected to outlive whom. If the type raising the event is a singleton, a static holder, or otherwise long-lived relative to its subscribers, plain strong-reference events are a latent leak, and the API should either document a required Dispose-based unsubscribe, provide IDisposable subscription tokens, or use a weak event pattern internally so callers cannot get this wrong by forgetting one line.
Beyond lifetime, check whether the event fires from a background thread while consumers assume a UI or request context, whether handlers are expected to be fast (a slow handler blocks every other subscriber and the raiser itself, since multicast invocation is synchronous), and whether the event could reasonably be replaced by a channel if producers can outpace consumers. A good API surface makes the safe usage the easy one: an IDisposable returned from Subscribe, clear documentation of which thread raises the event, and, for anything genuinely long-lived and widely subscribed, consideration of a weak event source instead of a bare event field.
What interviewers look for: a lifetime-first review checklist rather than a syntax check, and the judgment to reserve weak event patterns for genuinely long-lived publishers rather than adding their complexity everywhere.
- Common mistakes: approving an event-heavy API purely on functional correctness without asking who owns unsubscription, which is exactly the class of leak that surfaces only under load in production.
- Follow-up questions: How would you retrofit leak safety onto an existing, widely used event without a breaking change? (Add an
IDisposable-returningSubscribeoverload alongside the existing event, and migrate call sites incrementally.)
Quick-Fire Round#
| Question | Answer |
|---|---|
What does a multicast Func<T> return when it has three targets? | Only the last target's result |
Since which C# version does foreach give each iteration its own variable? | C# 5 |
What does static on a lambda guarantee? | No capture; any accidental capture becomes a compile error |
Does -= throw if the delegate instance does not match? | No, it silently does nothing |
What race does ?.Invoke close when raising an event? | The check-then-call gap between testing for null and invoking |
Where do IObservable<T> and IObserver<T> live? | The base class library; the operators come from the System.Reactive package |
Does System.Threading.Channels need a NuGet package on modern .NET? | No, it ships inbox |
What happens to an exception thrown inside async void? | It cannot be awaited or observed by the caller; it can crash the process |
| What does a weak event source store instead of the delegate itself? | A weak reference to the subscriber, plus the method to invoke |
How to Prepare#
- Be ready to trace, line by line, why a
for-loop closure prints the final value while aforeachclosure since C# 5 does not. - Practice explaining the
?.Invokerace concretely: which two reads of the field happen, and in what order, in the unsafe version. - Write a small weak event source from scratch once; the mechanics stop being mysterious the moment you have implemented one.
- Have a ready answer for "events,
IObservable<T>, or channels" that references backpressure and composition, not just familiarity. - Prepare a story about an event-driven memory leak you diagnosed or reviewed, since this is one of the most common "tell me about a bug" prompts in this area.