C# Lambda Closure Leak — Timer Crash After 48 Hours
A lambda capturing 'this' caused a 4GB memory leak in 48 hours with frequent GCs.
20+ years shipping production .NET services in enterprise systems. Written from production experience, not tutorials.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
- A lambda is a compiler-transformed anonymous method: captured variables create hidden heap-allocated closure objects.
- Func
returns a value; Action returns void; Predicate is a semantic alias for Func . - Use Expression
> when the lambda must be inspected as data (e.g., EF Core SQL translation) — .Compile() is expensive. - Non-capturing lambdas allocate once and cache the delegate; capturing lambdas allocate per invocation — measure with Sharplab.
- The
staticlambda modifier (C# 9) enforces zero capture at compile time — use it on hot paths. - Biggest mistake: storing a lambda that captures
thisin a long-lived event — the entire object graph stays alive.
Lambda closures in C# are the silent memory killers that crash production services after 48 hours of uptime. When you write () => timer.Elapsed += (s, e) => , the C# compiler generates a compiler-generated class that captures any local variables the lambda references.DoWork()
That captured reference keeps the entire object graph alive — including your timer, your service, and everything it references — preventing garbage collection. The crash happens because each timer tick creates a new closure instance, and the timer holds a strong reference to the delegate, which holds a reference to the closure, which holds a reference to your service.
After ~48 hours of accumulation, you hit an OutOfMemoryException or the timer stops firing because the finalizer thread is overwhelmed.
Func, Action, and Predicate are just generic delegate types — Func<T, TResult> for methods that return a value, Action<T> for void methods, and Predicate<T> for boolean checks. The real trap is that lambdas are syntactic sugar for compiler-generated classes with instance methods.
When a lambda captures a local variable, the compiler promotes that variable to a field on the generated class. The delegate instance holds a reference to that class instance. If the delegate is long-lived (like a timer callback), the captured variables and their entire reference chain stay alive indefinitely.
This is fundamentally different from a static method reference, which has no captured state.
The async lambda trap is even worse. async (s, e) => await generates a state machine struct that gets boxed when assigned to a delegate. Each invocation allocates a new boxed copy of the state machine on the heap. Combined with timer callbacks, this creates a compounding allocation pattern that eventually overwhelms the garbage collector.SomeMethod()
The fix is always to unsubscribe old handlers before subscribing new ones, use static lambdas (C# 9+) where possible, or restructure to avoid capturing the service instance entirely. Tools like dotMemory or PerfView can show you the closure instances accumulating in gen-2 heap.
Imagine you run a bakery and you need someone to ice a cake. You could hire a full-time pastry chef (a named method), or you could just hand a passing helper a sticky note that says 'spread the white stuff on top' (a lambda). Func is that sticky note when the helper hands you something back — like 'taste this and tell me if it's sweet'. Action is the sticky note when you just want the job done with no feedback needed. That's the whole mental model.
Every time you write a LINQ query, wire up an event handler, or pass behaviour into a dependency-injection container, you're leaning on delegates, lambdas, Func, and Action. These aren't just syntactic sugar — they're the foundation of functional-style C# and the engine behind async pipelines, middleware chains, and strategy patterns. Missing the internals here means writing code that leaks memory, captures variables by accident, and benchmarks 10× slower than it should.
Before lambdas, passing behaviour meant either creating a named method somewhere else in the class or writing a verbose anonymous delegate. Both approaches forced you to break your train of thought, scroll away, and name something that only ever lived for one call site. Lambdas collapsed that gap, letting you express intent exactly where the intent is needed. Func and Action gave those anonymous blocks a type-safe home — a way for the compiler to reason about inputs and outputs without you writing a custom delegate type for every scenario.
By the end of this article you'll understand how the compiler lowers a lambda to IL, why closures allocate on the heap even when you don't expect them to, when Func causes boxing and how to dodge it, and how to use Expression<Func<T>> when you need the lambda as data rather than as executable code. You'll walk away with a mental model that survives any interview question and any production incident.
How Lambda, Func, and Action Actually Work in C#
Lambda expressions are anonymous methods that capture variables from their enclosing scope. Func and Action are generic delegate types that define the signature of a callable block: Func returns a value, Action does not. Together they let you pass behavior as data — a function pointer with closure semantics.
When a lambda captures a variable, the compiler hoists that variable into a compiler-generated class instance. The lambda holds a reference to that instance, not the stack variable. This means the captured variable's lifetime extends to match the delegate's lifetime — a key property that causes both flexibility and memory leaks.
Use lambdas with Func/Action when you need deferred execution, callbacks, or LINQ-style transformations. In production, they are essential for event handlers, async continuations, and dependency injection. But every capture extends object lifetimes — misuse creates closures that outlive their intended scope, leading to unexpected retention and crashes.
How the C# Compiler Actually Turns a Lambda Into Code
A lambda is not a new kind of runtime object — it's a compiler transformation. When you write x => x * 2, the compiler looks at the context and decides what to emit. If the lambda captures no variables from the enclosing scope, the compiler emits a static private method on the same class and caches a single delegate instance pointing to it. You pay zero allocation cost after the first call.
The moment your lambda captures a local variable or a parameter from the enclosing method — say int multiplier = 3; Func<int,int> triple = x => x * multiplier; — the compiler synthesises a hidden class (often called a 'closure class' or a 'display class'). It lifts the captured variable into a field on that class, rewrites your local variable as a reference to that field, and the delegate points to an instance method on the heap-allocated closure object. One capture, one allocation.
Understanding this distinction is not academic. In a hot loop that creates lambdas on every iteration, capturing a variable accidentally can turn a zero-allocation path into thousands of small objects per second — exactly the kind of thing that causes GC pressure in game loops, real-time trading systems, and high-throughput ASP.NET endpoints. SharpLab.io lets you paste any lambda and see exactly what the compiler emits before you commit to production.
using System; using System.Runtime.CompilerServices; public class LambdaCompilerBehaviour { // ── CASE 1: No capture ──────────────────────────────────────────────── // The compiler emits a static method and caches ONE delegate instance. // Re-using this Func thousands of times costs zero extra allocations. public static Func<int, int> GetDoublerNonCapturing() { // 'number' is the lambda parameter — not captured from outer scope Func<int, int> doubler = number => number * 2; return doubler; } // ── CASE 2: Captures a local variable ──────────────────────────────── // Compiler creates a hidden 'DisplayClass' on the heap. // Every call to GetDoublerCapturing allocates a new closure object. public static Func<int, int> GetDoublerCapturing(int factor) { // 'factor' comes from the method parameter — this IS a capture Func<int, int> multiplier = number => number * factor; return multiplier; } public static void Main() { // Non-capturing: delegate is reused from the static cache var doubler = GetDoublerNonCapturing(); Console.WriteLine($"Non-capturing result: {doubler(5)}"); // 10 // Capturing: each call allocates a fresh closure + delegate var tripler = GetDoublerCapturing(3); var quadrupler = GetDoublerCapturing(4); Console.WriteLine($"Tripler result : {tripler(5)}"); // 15 Console.WriteLine($"Quadrupler result: {quadrupler(5)}"); // 20 // Prove they are independent objects — different factors captured Console.WriteLine($"Same delegate? {ReferenceEquals(tripler, quadrupler)}"); // False // ── CASE 3: Loop capture gotcha ─────────────────────────────────── // Classic interview trap: all lambdas share the SAME closure variable var actions = new Action[3]; for (int i = 0; i < 3; i++) { int snapshot = i; // fix: copy to a loop-local variable actions[i] = () => Console.WriteLine($"Snapshot value: {snapshot}"); } foreach (var action in actions) action(); // prints 0, 1, 2 — not 3, 3, 3 } }
Func vs Action vs Predicate — Choosing the Right Delegate Type
Func<T, TResult> is the generic delegate for any method that takes zero to sixteen inputs and returns a value. The last type parameter is always the return type. Action<T> is the same shape but the return type is void — you're saying 'do this work, I don't need a value back'. Predicate<T> is just Func<T, bool> with a more expressive name — it's kept around because it predates generic Func in the BCL and is still used by List<T>.FindAll and Array.Find.
The real decision isn't Func vs Action — it's whether you need the lambda to be a delegate (executable right now) or an expression tree (inspectable data). LINQ-to-Objects uses IEnumerable<T>.Where(Func<T, bool>) because it runs in memory. LINQ-to-SQL and Entity Framework use IQueryable<T>.Where(Expression<Func<T, bool>>) because they need to translate your lambda into SQL. Same syntax at the call site; completely different runtime behaviour.
For performance-critical code, consider using static lambdas (the static modifier on a lambda, introduced in C# 9). Marking a lambda static causes a compile-time error if you accidentally capture anything, enforcing the zero-allocation path. It's a guardrail, not a performance boost in itself — the compiler already optimises non-capturing lambdas to static methods, but the static keyword makes the intent explicit and the constraint enforced.
using System; using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; public class FuncActionComparison { // ── Func: takes input(s), returns a value ───────────────────────────── // Func<TInput, TOutput> — last type param is ALWAYS the return type static Func<string, int> ParseLength = text => text.Length; // ── Action: takes input(s), returns nothing ─────────────────────────── // Great for side-effects: logging, writing to DB, sending events static Action<string> LogMessage = message => Console.WriteLine($"[LOG {DateTime.UtcNow:HH:mm:ss}] {message}"); // ── Predicate: specialised Func<T, bool> ───────────────────────────── // Identical to Func<string, bool> but more semantically expressive static Predicate<string> IsLongWord = word => word.Length > 6; // ── Static lambda (C# 9+): enforces no capture at compile time ──────── static Func<double, double> CircleArea = static radius => Math.PI * radius * radius; // ── Expression tree: lambda as DATA, not code ───────────────────────── // EF Core translates this to SQL; a plain Func<> would execute in-memory static Expression<Func<string, bool>> LongWordExpression = word => word.Length > 6; public static void Main() { // Func in action string sentence = "Lambda expressions are powerful"; int wordCount = sentence.Split(' ').Sum(ParseLength); Console.WriteLine($"Total chars (no spaces): {wordCount}"); // 27 // Action for side-effects LogMessage("Application started"); // Predicate with List<T>.FindAll — predates generic Func var words = new List<string> { "C#", "delegates", "lambda", "closures", "IL" }; List<string> longWords = words.FindAll(IsLongWord); Console.WriteLine($"Long words: {string.Join(", ", longWords)}"); // Static lambda — can't accidentally capture double area = CircleArea(5.0); Console.WriteLine($"Circle area (r=5): {area:F4}"); // 78.5398 // Expression tree — inspect it, don't just execute it Console.WriteLine($"Expression body: {LongWordExpression.Body}"); // (word.Length > 6) // Compile the expression to a delegate when you DO want to execute it Func<string, bool> compiledPredicate = LongWordExpression.Compile(); Console.WriteLine($"'delegates' is long: {compiledPredicate("delegates")}"); // True // ── Chaining with Func: build a simple pipeline ─────────────────── Func<string, string> trim = s => s.Trim(); Func<string, string> toLower = s => s.ToLower(); Func<string, string> sanitise = s => toLower(trim(s)); // manual composition Console.WriteLine(sanitise(" Hello World ")); // hello world } }
Compile() on an Expression<Func<>> generates IL at runtime and is roughly 1000× slower than invoking an already-compiled delegate. Cache the result of Compile() in a static field or a ConcurrentDictionary — never call it inside a loop or on every request.Compile(), adding ~2ms latency — enough to push p99 response times from 80ms to 250ms.Compile() results or pay 1000× the cost.Closures, Variable Capture, and the Memory Leak You Don't See Coming
A closure keeps its captured variables alive as long as the delegate itself is alive. That sounds obvious, but the consequences are non-obvious in production. If you capture this — either explicitly or by accessing an instance field inside a lambda — the entire object graph rooted at this is pinned in memory for as long as any subscriber holds a reference to that delegate.
The most common real-world leak pattern: a long-lived service (say, a singleton) subscribes a lambda to an event on a short-lived object, and the lambda captures this. The short-lived object can't be collected because the event holds a delegate that holds the closure that holds a reference back to the singleton — and transitively, back to the short-lived object itself if the closure also touched any of its fields. Event subscriptions and callbacks passed to timer APIs are the two most common vectors.
The fix is either to unsubscribe explicitly when the short-lived object is disposed, use weak event patterns, or — best of all — restructure so the lambda captures only value-type snapshots of the data it needs rather than a reference to the object. Analysing capture graphs manually is tedious; dotMemory and the .NET Object Allocation Tracker in Visual Studio both show you exactly which delegate is keeping which object graph alive.
using System; using System.Collections.Generic; // ── Simulates a long-lived event source (e.g. a message bus singleton) ─── public class MessageBus { // Storing delegates keeps all their captured variables alive private readonly List<Action<string>> _subscribers = new(); public void Subscribe(Action<string> handler) => _subscribers.Add(handler); public void Publish(string message) { foreach (var subscriber in _subscribers) subscriber(message); } // Missing an Unsubscribe here is the leak — omitted intentionally to show the problem } // ── Short-lived processor that captures 'this' inside a lambda ──────────── public class OrderProcessor : IDisposable { private readonly string _processorId; private readonly MessageBus _bus; private bool _disposed; // Large payload simulating a real object with meaningful state private readonly byte[] _largeCache = new byte[1024 * 1024]; // 1 MB public OrderProcessor(string processorId, MessageBus bus) { _processorId = processorId; _bus = bus; // PROBLEM: 'this' is implicitly captured because we access _processorId // MessageBus._subscribers now holds a reference chain: // delegate -> closure -> this -> _largeCache (1 MB stays alive!) _bus.Subscribe(order => HandleOrder(order)); } private void HandleOrder(string order) { if (_disposed) return; // guard, but memory is still not freed Console.WriteLine($"[{_processorId}] Processing: {order}"); } // ── CORRECT PATTERN: capture only the value you need ───────────────── public static OrderProcessor CreateWithSnapshot(string processorId, MessageBus bus) { var processor = new OrderProcessor(processorId, bus); // Capture a value-type snapshot, not 'this' // The delegate no longer roots the entire OrderProcessor graph string idSnapshot = processorId; bus.Subscribe(order => Console.WriteLine($"[SNAPSHOT:{idSnapshot}] {order}")); return processor; } public void Dispose() { _disposed = true; // In a real system: _bus.Unsubscribe(handler) — store handler ref to enable this Console.WriteLine($"[{_processorId}] Disposed — but lambda still in MessageBus!"); } } public class ClosureMemoryBehaviour { public static void Main() { var bus = new MessageBus(); // long-lived singleton // Short-lived processor — we call Dispose and null the ref, // but the lambda inside MessageBus still roots the 1 MB cache var processor = new OrderProcessor("OP-001", bus); bus.Publish("ORDER-42"); processor.Dispose(); processor = null!; // local ref gone GC.Collect(); // GC runs GC.WaitForPendingFinalizers(); // bus._subscribers still holds the delegate -> _largeCache is NOT collected Console.WriteLine("Processor nulled and GC ran — memory leak in place."); // Publishing again still works because closure is still alive bus.Publish("ORDER-43"); // prints [OP-001] Processing: ORDER-43 } }
this inside a timer callback lambda.this in lambdas passed to long-lived timers or event sources.this roots the entire object graph — potential memory leak.Performance Deep-Dive — When Lambdas Cost Nothing vs When They Cost a Lot
Let's be precise about allocations. A non-capturing, non-static lambda called repeatedly allocates its delegate once on first use and never again — the compiler caches it in a static field. A capturing lambda allocates a closure object every time the enclosing method runs. A multicast delegate (one with multiple subscribers via +=) allocates a new immutable delegate array on every subscription change.
The second cost is virtual dispatch. Invoking a delegate is roughly equivalent to a virtual method call — it's not free like a direct static call, but on modern JIT it's a single indirect branch prediction miss in the worst case. For 99% of code this is irrelevant. For tight numeric loops called millions of times per second, prefer generic constraints with interfaces (where T : IProcessor) over Func<T> — the JIT can devirtualise and even inline interface calls on value types, which it cannot do with delegates.
The third cost is generic instantiation. Func<int, int> and Func<string, string> are separate closed generic types — each gets its own JIT-compiled code on first use. This is usually fine, but if you're dynamically building pipelines with many unique Func<> combinations, you may see JIT compilation time spikes on startup. Pre-warming critical paths in hosted services' StartAsync methods sidesteps this.
using System; using System.Diagnostics; public class LambdaPerformanceComparison { private const int Iterations = 10_000_000; // ── Option 1: Direct static method call ────────────────────────────── // Fastest possible — the JIT inlines this with zero indirection private static double ComputeCircleArea(double radius) => Math.PI * radius * radius; // ── Option 2: Non-capturing static lambda ──────────────────────────── // Compiler emits a static method + one cached delegate; zero ongoing allocation private static readonly Func<double, double> CircleAreaDelegate = static radius => Math.PI * radius * radius; // ── Option 3: Capturing lambda (re-created on every call) ───────────── // Returns a NEW closure object each time — heap pressure in a tight loop private static Func<double, double> BuildCapturingDelegate(double factor) { // 'factor' is captured — forces closure allocation return radius => Math.PI * radius * radius * factor; } public static void Main() { double total = 0; var sw = Stopwatch.StartNew(); // ── Benchmark 1: Direct static method ──────────────────────────── sw.Restart(); for (int i = 1; i <= Iterations; i++) total += ComputeCircleArea(i); long directMs = sw.ElapsedMilliseconds; Console.WriteLine($"Direct static method : {directMs} ms (total={total:E2})"); // ── Benchmark 2: Cached non-capturing delegate ──────────────────── total = 0; sw.Restart(); for (int i = 1; i <= Iterations; i++) total += CircleAreaDelegate(i); long cachedDelegateMs = sw.ElapsedMilliseconds; Console.WriteLine($"Cached delegate : {cachedDelegateMs} ms (total={total:E2})"); // ── Benchmark 3: Capturing lambda re-created per outer call ─────── // Simulates a pattern like: services.AddTransient(sp => BuildPipeline(config)) total = 0; sw.Restart(); for (int i = 1; i <= Iterations; i++) { // BUG PATTERN: building a new delegate on every loop tick var capturingFunc = BuildCapturingDelegate(1.0); // new closure each time total += capturingFunc(i); } long capturingMs = sw.ElapsedMilliseconds; Console.WriteLine($"Capturing (per-iter) : {capturingMs} ms (total={total:E2})"); Console.WriteLine(); Console.WriteLine("Key insight: cached non-capturing delegates approach direct-call speed."); Console.WriteLine("Re-creating capturing delegates in a loop is the real performance killer."); } }
static before any lambda that lives in a hot path: Func<int,int> square = static n => n * n;. If you later accidentally reference an instance field inside it, the compiler gives you CS8820 — a compile-time error, not a runtime performance surprise. It costs nothing extra at runtime; it's pure signal to both the compiler and your teammates.Async Lambdas and the State Machine Trap
When you mark a lambda with async, the compiler generates a state machine class — similar to a closure, but more complex. This state machine captures all local variables at the time of the first await, and keeps them alive until the Task completes. If you store an async lambda in a long-lived collection (like an event handler list or a ConcurrentBag), all captured resources—including HttpClient, DbContext, or CancellationTokenSource—stay allocated until the delegate is removed.
A common pattern that fails silently: registering an async lambda as a handler for a timer or a background service. The lambda captures this and the timer fires repeatedly. Each invocation may start a new async operation before the previous one completes, leading to resource exhaustion and memory growth. The fix is to use Func<Task> instead of Action for async callbacks, and ensure you await the returned task to avoid fire-and-forget behaviour.
Async lambdas also cannot be used with Expression<Func<>> because state machines cannot be represented as expression trees. The compiler catches this at compile time with CS1989. If you need query translation with async operations, you must separate concerns: use Expression<Func<>> for query definition and Func<Task<T>> for execution.
using System; using System.Threading.Tasks; using System.Threading; public class AsyncLambdaBehaviour { // ── PROBLEM: Async lambda stored as Action ───────────────────────────── // Exceptions are unobserved; fire-and-forget can crash the process public static Action AsyncActionLeak = async () => { await Task.Delay(10); // If this throws, the exception is swallowed throw new InvalidOperationException("Silent crash"); }; // ── CORRECT: Use Func<Task> for async callbacks ─────────────────────── public static Func<Task> AsyncFuncCorrect = async () => { await Task.Delay(10); // Caller can await this and observe exceptions }; public static async Task Main() { // Fire the bad pattern — exception goes unobserved try { AsyncActionLeak(); // No await possible - fire and forget } catch (Exception ex) { // This catch block NEVER executes Console.WriteLine($"Caught: {ex.Message}"); } // Wait a bit for the exception to (maybe) crash await Task.Delay(100); Console.WriteLine("Survived the silent exception? Check Application Insights."); // Correct invocation await AsyncFuncCorrect(); // Exception is properly observed Console.WriteLine("Async Func completed safely."); } }
Func<Task> and await the returned task in the event publisher, or implement a proper queue mechanism.Timer.Elapsed += async (s, e) => await SendBulkEmail();.Why Your Lambdas Are Eating Stack Frames — Recursive Allocs You Missed
Every lambda you write creates a delegate object. When you chain lambdas inside loops, inside other lambdas, you're not just paying allocation cost once — you're paying it per iteration, per call site, per closure capture. The GC sees these as short-lived objects and treats them like hot garbage, but only after they've burned CPU cycles in Gen0 collections.
The real killer: lambda hoisting. The compiler promotes captured variables to heap-allocated display classes. When a lambda captures a value type, that value gets boxed and stored in the display class. If the lambda escapes the scope — say you store it in a static cache — that value stays alive forever, pinning your entire closure chain. I've seen production services where a single captured int grew into a 50MB leak over 12 hours.
Fix it: avoid capturing when you can pass parameters. Use static lambdas in C# 9+ where no closure is needed — the compiler can then allocate the delegate once and reuse it. Measure with dotnet-counters before you optimise, but never assume lambdas are free. They're syntactic sugar that hides a grimy heap allocation.
// io.thecodeforge — csharp tutorial Func<int>[] BuildLeakyCache() { var cache = new Func<int>[100]; for (int i = 0; i < cache.Length; i++) { // Captures 'i' by reference — display class allocated per iteration cache[i] = () => i; } return cache; } // Static lambda — no capture, single delegate reused Func<int, int> StaticSquare = static (x) => x * x; var leaky = BuildLeakyCache(); Console.WriteLine(leaky[0]()); // Outputs 100 -- closure captured the loop variable
for loop captures the variable, not its value. All delegates see the final value (100). This is a classic off-by-one bug disguised as a memory issue.static lambdas when possible. If you must capture, copy the value into a local inside the loop to snapshot it.Expression Trees vs Delegates — The Incorrect Shortcut That Breeds Runtime Exceptions
Here's a mistake I see every junior make: using Func<int, bool> where they should use Expression<Func<int, bool>>. They look the same. They compile fine. Then your ORM throws a cryptic NotSupportedException at runtime because it can't decompile an opaque delegate back into SQL.
Func is a compiled method pointer. It executes native IL. Expression<T> is a representation of the syntax tree — it's data, not code. Frameworks like EF Core, LINQ to SQL, and MongoDB drivers parse the expression tree to translate your predicate into query language. Hand them a delegate, and they have no choice but to pull every row into memory and filter client-side. Your "optimised" lambda just turned a 10ms query into a 10-second OOM kill.
The rule: use Expression<Func<T, bool>> when the lambda will be interpreted (query translation, validation rule building). Use Func<T, bool> when it will be compiled and executed (in-memory filtering, event handlers). If you see Func in a repository interface and that repo talks to a database, someone cut a corner. Fix it before the pager wakes you at 3 AM.
// io.thecodeforge — csharp tutorial using System.Linq.Expressions; // Wrong — forces client-side evaluation Func<Customer, bool> badPredicate = c => c.TotalOrders > 1000; // Right — EF Core translates this to WHERE clause Expression<Func<Customer, bool>> goodPredicate = c => c.TotalOrders > 1000; // Simulated usage var customers = new List<Customer>(); // badPredicate: all rows loaded, filtered in memory var badResult = customers.Where(badPredicate); // goodPredicate: translated to SQL, filtered in database var goodResult = customers.AsQueryable().Where(goodPredicate); Console.WriteLine(badResult.GetType()); // IEnumerable Console.WriteLine(goodResult.GetType()); // IQueryable
IEnumerable<T>.Where(Func), assume eager load. When you see IQueryable<T>.Where(Expression), assume deferred execution with server-side filter. The type signature tells the whole story.Expression<Func<...>> for query translation targets (databases, services). Use Func<...> for in-memory operations. Mix them up and you kill performance silently.Events Provide Optional Notifications — Why They Exist Beyond Delegates
In C#, events are not just syntactic sugar over delegates; they enforce a publisher-subscriber contract that prevents external code from arbitrarily invoking or reassigning the delegate chain. Why does this matter? Because without events, any consumer of a delegate field could overwrite all subscribers by assigning a new delegate (= instead of +=), or trigger the delegate's invocation from outside the class, breaking encapsulation. Events exist to provide optional, controlled notifications: subscribers opt in, and only the declaring class can raise the event. This prevents accidental clearing or invocation misuse in production systems. Under the hood, an event property creates a private backing delegate field with add/remove accessors, offering thread-safe subscription (via lock-free CompareExchange in modern .NET). The pattern also allows for event accessor customization, enabling validation, logging, or conditional subscription. The key takeaway: events are your safety net for optional notifications when you want to prevent external callers from hijacking your invocation logic or subscriber list.
// io.thecodeforge — csharp tutorial // Events vs delegate fields — why events exist public class Button { // Event prevents external invocation/reset public event Action? Clicked; // Without event: public Action? Clicked; // Danger! public void SimulateClick() { // Only Button can raise Clicked Clicked?.Invoke(); } } // Consumer code: var btn = new Button(); btn.Clicked += () => Console.WriteLine("Clicked!"); // btn.Clicked?.Invoke(); // Compile error — not allowed // btn.Clicked = null; // Compile error — not allowed
Standard Event Pattern with EventArgs — Why You Should Follow It
The standard event pattern in C# uses EventHandler<TEventArgs> where TEventArgs derives from EventArgs. Why is this pattern prescribed, not optional? Because it provides a consistent contract across your codebase and .NET libraries, enabling tooling (e.g., Visual Studio's 'event' snippet), serialization support, and forward compatibility. When you deviate — for example, using Action<MyArgs> instead — you lose the ability to add more parameters later without breaking subscribers, and you miss the 'sender' parameter that identifies the event source. The pattern also promotes observability: custom EventArgs carry state but should be immutable to avoid race conditions. In async scenarios, never use async void for event handlers; use Task-based delegates with special handling to prevent unobserved exceptions. The recommended pattern is: define a derived EventArgs class (or use the generic one), expose the event as EventHandler<TArgs>, and raise it via a protected virtual method (OnXxx) for inheritance. This ensures subclasses can override invocation logic without needing direct event access.
// io.thecodeforge — csharp tutorial // Standard event pattern with EventArgs public class FileDownloader { public event EventHandler<DownloadArgs>? DownloadComplete; protected virtual void OnDownloadComplete(DownloadArgs args) { DownloadComplete?.Invoke(this, args); } public void Download() { // ... work ... OnDownloadComplete(new DownloadArgs("success", 1024)); } } public class DownloadArgs : EventArgs { public string Status { get; } public long Bytes { get; } public DownloadArgs(string status, long bytes) => (Status, Bytes) = (status, bytes); }
Closure Leak in a Background Service Crashes Production After 48 Hours
this._timer.Elapsed += (s, e) => ProcessOrders();. Inside ProcessOrders, the instance field _orderQueue was accessed, causing the compiler to capture this. The timer delegate was never removed, so the closure (and the entire service object graph) stayed alive for the lifetime of the application. Each new order processed allocated additional objects, but the real problem was that the closure rooted the entire service, preventing GC from reclaiming any memory._timer.Elapsed += static (s, e) => ProcessorInstance.ProcessOrders(InstanceState);. The static lambda cannot capture this, forcing the team to pass dependencies explicitly. Also added explicit disposal of the timer in the service's StopAsync method: _timer.Dispose();.- Never store a lambda that captures instance state in a long-lived event source (timers, static events, message bus).
- Use the
staticlambda modifier to enforce no-capture at compile time. - Always clean up event subscriptions and timers in
IorDisposable.Dispose()I.HostedService.StopAsync() - Monitor the number of delegate instances in dumps — a rising count is a red flag.
dotnet-dump collect and analyse with dotnet-dump analyze. Look for delegate instances referencing closure classes. Use the gcroot command to find retention paths.dotnet-counters data: monitor gen-2-gc-count and gc-heap-size. If Gen 2 collections are frequent, the delegate allocation rate is too high. Profile with BenchmarkDotNet to isolate capturing lambdas.Func<T,bool> instead of Expression<Func<T,bool>> to an IQueryable provider. Log the generated SQL if available, or use IQueryable.Expression to inspect the tree. Switch to Expression if remote evaluation is required.async void lambdas or Action parameters receiving async lambdas. Change to Func<Task> and ensure the caller awaits. If using events, consider an AsyncEventHandler pattern that returns Task.Method and Target properties.dotnet-dump analyze <dump_file>> gcroot <delegate_field_address>Observe `gen-2-gc-count` and `gc-heap-size`Profile with BenchmarkDotNet to identify capturing lambdas in hot pathsUse `AppDomain.CurrentDomain.UnhandledException` handler to log all unhandled exceptions (temporary)Change event signatures to use `Func<Task>` or `AsyncEventHandler`Check if the lambda argument type is `Func<>` instead of `Expression<Func<>>`Use `IQueryable.Expression.ToString()` to see the expression treeExpression<Func<>> and rebuild the query.| Aspect | Func<T, TResult> | Action<T> | Expression<Func<T, TResult>> |
|---|---|---|---|
| Return value | Yes — last type param is the return type | No — always void | N/A — not directly callable |
| Executable | Yes — call like a method | Yes — call like a method | Only after .Compile() (expensive) |
| Primary use case | Transformations, selectors, factory methods | Side-effects, callbacks, event handlers | ORM query translation, rule engines, serialisation |
| LINQ flavour | IEnumerable<T> (in-memory) | ForEach, custom pipelines | IQueryable<T> (SQL, CosmosDB, etc.) |
| Closure allocation | Yes if capturing | Yes if capturing | Yes — always allocates an expression tree |
| Can be static lambda | Yes (C# 9+) | Yes (C# 9+) | No — expressions cannot be static lambdas |
| Max type parameters | 16 inputs + 1 output | 16 inputs, no output | Same as underlying Func — 16 inputs |
| Supports async/await | Yes — Func<Task<T>> | Yes — Func<Task> / Async void caution | No — async lambdas cannot be expression trees |
| File | Command / Code | Purpose |
|---|---|---|
| LambdaCompilerBehaviour.cs | using System; | How the C# Compiler Actually Turns a Lambda Into Code |
| FuncActionComparison.cs | using System; | Func vs Action vs Predicate |
| ClosureMemoryBehaviour.cs | using System; | Closures, Variable Capture, and the Memory Leak You Don't Se |
| LambdaPerformanceComparison.cs | using System; | Performance Deep-Dive |
| AsyncLambdaBehaviour.cs | using System; | Async Lambdas and the State Machine Trap |
| CaptureLeakExample.cs | Func | Why Your Lambdas Are Eating Stack Frames |
| ExpressionVsDelegate.cs | using System.Linq.Expressions; | Expression Trees vs Delegates |
| EventExample.cs | public class Button | Events Provide Optional Notifications |
| StandardEvent.cs | public class FileDownloader | Standard Event Pattern with EventArgs |
Key takeaways
() => i in a delegate inside a for-loop — is the single most common lambda bug in C# code reviews; the fix is always to copy the loop variable to a local snapshot before capture.Expression.Compile() generates IL at runtime and costs ~1000× a delegate invocation; cache compiled delegates aggressively and never call .Compile() in a hot path or on every HTTP request.Common mistakes to avoid
3 patternsLoop variable capture
() => Console.Write(i) in a list and later invoking all actions prints '5, 5, 5' for a loop of 5 iterations.int snapshot = i; actions[i] = () => Console.Write(snapshot);. Each lambda now captures its own independent variable.Storing async void lambdas in Action
Func<Task> instead of Action for async callbacks, and always await the returned Task: Func<Task> callback = async () => await DoWorkAsync(); await callback();.Calling Expression.Compile() on every request
.Compile() inside a controller action or hot service method, adding ~0.5–2 ms per call from JIT compilation, causing latency spikes.ConcurrentDictionary<string, Delegate> keyed on the expression's ToString(), or precompile all expressions at application startup in IHostedService.StartAsync().Interview Questions on This Topic
What is the difference between a delegate, a lambda, and an expression tree in C#? Can you explain when you'd choose Expression
Walk me through exactly what the C# compiler emits for a capturing lambda versus a non-capturing lambda. What are the memory and performance implications, and how would you detect a closure leak in a production application?
x => x 2), the compiler generates a private static method in the enclosing class and caches a single static delegate field pointing to it. No allocation on subsequent invocations. For a capturing lambda (e.g., int factor = 3; x => x factor), the compiler creates a hidden class (display class or closure) with fields for each captured variable, and an instance method for the lambda body. Each invocation of the enclosing method allocates a new instance of this closure class and a new delegate. The implication: capturing lambdas cause heap allocations on every call, increasing GC pressure. To detect a closure leak in production, use dotMemory or the .NET Object Allocation Tracker in Visual Studio to inspect delegate roots — look for delegates that reference large object graphs that are no longer needed. Also monitor Gen 2 GC collections: frequent collections often indicate delegate retention. You can also use dotnet-counters to monitor GC heap size and dotnet-dump to analyse memory snapshots.If I mark a lambda with the `static` modifier in C# 9, what guarantee does that give me? If two non-static, non-capturing lambdas with identical bodies are assigned to two separate Func
static modifier guarantees at compile time that the lambda does not capture any variables from the enclosing scope (including this, locals, and parameters). If the lambda body accidentally references an instance field or local variable, the compiler emits error CS8820. This is a correctness guard. For two non-static, non-capturing lambdas with identical bodies (e.g., Func<int,int> a = n => n 2; Func<int,int> b = n => n 2;), the delegates are NOT guaranteed to be reference-equal. The compiler might cache them as two separate static methods (each with its own delegate field) or might merge them into one if it detects they are identical — but the C# specification does not require merging, and in practice the JIT may or may not unify them. The safest assumption: treat each lambda assignment as a separate delegate instance unless you explicitly cache it yourself.Frequently Asked Questions
Func<T, TResult> represents a method that takes one or more inputs and returns a value — the last type parameter is always the return type. Action<T> represents a method that takes one or more inputs but returns nothing (void). Use Func when you need a result back, like a selector or factory; use Action when you only care about a side-effect, like logging or publishing an event.
Yes. When a lambda captures a reference-type variable — including implicit captures of this via instance field access — it keeps that object alive for as long as the delegate exists. If that delegate is stored in a long-lived collection like an event subscriber list or a cache, the entire object graph rooted at the captured reference cannot be garbage collected. Always unsubscribe event handlers when the subscriber is disposed, and prefer capturing value-type snapshots over capturing object references.
Expression trees represent code as data — a tree of ExpressionNode objects that can be traversed, translated, and serialised (e.g. to SQL). Async methods compile into state-machine classes with complex control flow that cannot be represented as a simple expression tree. The C# compiler enforces this at compile time: writing Expression<Func<Task<int>>> e = async () => await Task.FromResult(1); produces CS1989. If you need async behaviour with a delegate, use Func<Task<TResult>> and await it normally.
The static modifier on a lambda (e.g., static (x) => x * 2) is a compile-time guard that prevents the lambda from capturing any variables from the enclosing scope. If you accidentally use an instance field or local variable inside the lambda body, the compiler emits error CS8820. Use it on hot paths or in long-lived delegates to enforce zero-allocation behaviour and make your intent clear to the next developer.
20+ years shipping production .NET services in enterprise systems. Written from production experience, not tutorials.
That's C# Advanced. Mark it forged?
8 min read · try the examples if you haven't