Closures in C#: display classes, capture by variable, loop capture rules since C# 5, shared closure scopes, async and long-lived closures, allocation costs and leaks, and mitigation with static lambdas and state arguments.
A closure is a lambda or local function plus the variables it captures. The compiler moves those variables into fields of a hidden display class, so the method and the lambda share the same variable. That's why capture is by variable, not by value. In a for loop all lambdas share one loop variable, while foreach gets a fresh one per iteration since C# 5. Every lambda in a scope shares one display class, and capturing this keeps the whole object alive, which can leak memory through timers and caches. In hot paths I use static lambdas with explicit state.
A closure is a lambda (or local function) plus the variables it captures from its enclosing scope. In C#, the compiler implements captures by hoisting those variables into a compiler-generated class, so the lambda and the enclosing method share the same variable, not a copy. That explains the classic loop bugs, async/timer surprises, hidden allocations and memory leaks — and why static lambdas exist.
public Func<Order, bool> IsOverLimit(decimal limit)
{
var margin = 0.05m;
return o => o.Total > limit * (1 + margin);
}
compiles to roughly:
sealed class <>c__DisplayClass0_0 { public decimal limit; public decimal margin;
internal bool <IsOverLimit>b__0(Order o) => o.Total > limit * (1 + margin); }
public Func<Order, bool> IsOverLimit(decimal limit)
{
var closure = new <>c__DisplayClass0_0 { limit = limit, margin = 0.05m }; // locals now live here
return closure.<IsOverLimit>b__0; // delegate bound to the closure
}
The captured locals stop being stack variables: every read/write in the method goes through the closure object, which lives as long as any delegate referencing it.
var threshold = 100m;
var isBig = (Order o) => o.Total > threshold;
threshold = 1000m;
isBig(new Order { Total = 500m }); // false - lambda sees the CURRENT value (1000)
The lambda reads the variable when it runs, not when it was created. If you need a snapshot, copy into a new local before capturing.
for vs foreach capturevar actions = new List<Action>();
for (int i = 0; i < 3; i++) actions.Add(() => Console.Write(i)); // prints 333
foreach (var n in new[] { 0, 1, 2 }) actions.Add(() => Console.Write(n)); // prints 012
for loop has one variable i for the whole loop, incremented each iteration — all lambdas share it.foreach declares a fresh variable per iteration (a breaking change made because the old behaviour caused so many bugs).for: int copy = i; actions.Add(() => Console.Write(copy));.for (int i = 0; i < files.Length; i++)
tasks.Add(Task.Run(() => Process(files[i]))); // i may be files.Length by the time it runs -> IndexOutOfRangeException
Delayed execution (tasks, timers, callbacks, event handlers, IObservable) makes capture-by-variable bugs worse: the variable has moved on by the time the lambda runs. Timers and long-lived callbacks also keep captured objects alive:
_timer = new Timer(_ => Refresh(), null, 0, 1000); // captures 'this' - the timer keeps this object alive
this, DbContext, HttpContext — alive indefinitely.HttpContext/scoped services in background tasks: the request ends and the captured objects are disposed or reused → ObjectDisposedException or data from another request.cache.GetOrAdd(key, static (k, loader) => loader.Load(k), _loader); // no capture
string.Create(len, state, static (span, s) => ...);
static lambdas (C# 9) can't capture anything; the compiler errors if they try. Combined with state-passing overloads (GetOrAdd(key, factory, arg), ThreadPool.QueueUserWorkItem(callback, state, preferLocal), Task.Factory.StartNew(action, state)), they eliminate closure allocations and accidental captures. Non-capturing lambdas are also cached as singleton delegates by the compiler.
A request handler started a background task with Task.Run(() => _audit.WriteAsync(HttpContext.User.Identity!.Name, order)). Under load, audit entries occasionally recorded the wrong user or threw ObjectDisposedException — the closure captured this (the controller) and its HttpContext, which ASP.NET Core recycles after the request ends. We captured plain values first (var user = User.Identity!.Name; var orderId = order.Id;) and moved the work to a background queue with a static lambda, fixing both the data corruption and the allocation churn.
A debugging-oriented question on capture semantics and memory.
Green flags: display class explanation; capture by variable; for versus foreach; shared scope retention; this capture leaks; static lambdas and state overloads.
Red flags: "lambdas copy values"; unaware of the for-loop bug; no idea closures allocate.
The compiler creates one display class per scope that declares captured variables. Nested scopes may get chained display classes (the inner one references the outer). Variables declared inside a loop body get a new display class instance per iteration; variables declared outside are shared. That's exactly why foreach's per-iteration variable fixes capture bugs.
this → the lambda becomes an instance method on the containing class (if only this is captured) or the display class gets a <>4__this field.ref/out/in parameters and ref struct locals (like Span<T>) can't be captured (they might point to the stack).A non-static local function called directly captures via a struct passed by ref — no heap allocation. Converting it to a delegate (or using it in async/iterators) forces heap capture. So local functions are a good tool to avoid closure allocations in synchronous code.
<>c__DisplayClass types.<>c__DisplayClassN_M types — large counts indicate hot-path captures; retention paths through them reveal leaks.[MemoryDiagnoser] shows allocation per operation.Async methods hoist locals that live across await into the state machine (boxed to the heap on first real suspension). Capturing large objects in long-running async methods keeps them alive until completion. ConfigureAwait, cancellation and scoping matter for lifetime.
| Pattern | Example |
|---|---|
| Static lambda + state argument | dict.GetOrAdd(k, static (k, s) => s.Create(k), state) |
| Pass values as parameters | Task.Run(static s => Work((Input)s!), input) (or Task.Factory.StartNew) |
| Cache delegates in static readonly fields | static readonly Func<Order, bool> IsPaid = o => o.Status == Paid; |
| Local functions called directly | struct capture |
IAsyncEnumerable / channels instead of callbacks |
explicit data flow |
for loop capture bug and fix it.HttpContext in Task.Run in a test app and observe failures after the request ends.using System.Collections.Concurrent;
namespace Advanced.Closures;
public static class Program
{
public static void Main()
{
// Capture by variable
var threshold = 10;
Func<int, bool> isBig = x => x > threshold;
threshold = 20;
Console.WriteLine(isBig(15)); // False
// for: one shared variable
var forActions = new List<Action>();
for (var i = 0; i < 3; i++) forActions.Add(() => Console.Write(i));
forActions.ForEach(a => a()); // 333
Console.WriteLine();
// fix: copy per iteration
var fixedActions = new List<Action>();
for (var i = 0; i < 3; i++) { var copy = i; fixedActions.Add(() => Console.Write(copy)); }
fixedActions.ForEach(a => a()); // 012
Console.WriteLine();
// foreach: fresh variable per iteration since C# 5
var eachActions = new List<Action>();
foreach (var n in new[] { 0, 1, 2 }) eachActions.Add(() => Console.Write(n));
eachActions.ForEach(a => a()); // 012
Console.WriteLine();
// Lambda can modify the captured variable
var counter = 0;
Action increment = () => counter++;
increment(); increment();
Console.WriteLine(counter); // 2
// Allocation-free with static lambda + state argument
var cache = new ConcurrentDictionary<int, string>();
var prefix = "item-";
var value = cache.GetOrAdd(7, static (key, p) => p + key, prefix);
Console.WriteLine(value); // item-7
}
}
// Leak pattern (do not do this in a scoped service):
// public sealed class PriceWatcher(IPriceClient client)
// {
// private readonly Timer _timer = new(_ => _ = client.RefreshAsync(), null, 0, 60_000); // captures this/client, never disposed
// }
// Fix: run periodic work in a BackgroundService with PeriodicTimer, and dispose timers.
for loop variables in deferred lambdasWhat happens: tasks or callbacks created in a for loop use the loop index.
Why it is wrong: all share the final value; often out-of-range errors.
Do instead: copy to a local per iteration.
What happens: expecting a lambda to keep the value at creation. Why it is wrong: lambdas read current variable values. Do instead: copy to a new local before capturing.
What happens: Task.Run(() => Use(HttpContext)) or scoped DbContext.
Why it is wrong: disposed or recycled objects; wrong data.
Do instead: capture plain values and create new scopes in background services.
What happens: capturing lambdas per request/per item. Why it is wrong: allocations and GC pressure. Do instead: static lambdas, state-passing APIs, cached delegates.
thisWhat happens: timers or static events with lambdas using instance members.
Why it is wrong: objects never collected.
Do instead: dispose timers/subscriptions; avoid capturing this in long-lived callbacks.
What happens: one lambda captures a large buffer, another lambda from the same scope is stored. Why it is wrong: the buffer stays alive with the stored lambda. Do instead: limit capture scope or pass data as parameters.
What happens: parallel lambdas increment a captured counter.
Why it is wrong: data races.
Do instead: Interlocked, thread-local accumulation, or results returned from tasks.
static for non-capturing lambdasWhat happens: lambdas that should be capture-free gain captures in later edits.
Why it is wrong: silent allocations and lifetime issues.
Do instead: mark them static.
Closures are where language convenience meets the garbage collector. Seniors can explain the display class, predict loop capture behaviour, and recognise closure-based leaks in heap dumps. In production code I watch for closures stored in long-lived objects (timers, caches, static events), and in hot paths I use static lambdas with state arguments. Knowing this turns mysterious memory growth into a quick diagnosis.
A lambda is an anonymous function, either an expression like x => x * 2 or a statement block. It has no type until it is converted, either to a delegate, which is compiled code, or to an Expression<Func<...>>, which is a data structure EF Core can turn into SQL. Since C# 10 a lambda can have a natural type, so var f = (int x) => x * 2 is a Func<int,int>. When it captures variables, the compiler hoists them into a closure class, which allocates. static lambdas forbid capture. Newer additions are attributes and return types (C# 10), default parameters (C# 12), and C# 14 parameter modifiers like out without writing types.
C# has selection statements (if, switch), iteration (for, foreach, while, do) and jumps (break, continue, return, goto, throw, yield). foreach over an array compiles to an index loop; over anything else it calls GetEnumerator, loops on MoveNext/Current, and disposes the enumerator in a finally. The bugs I watch for are modifying a list while iterating (it throws), closures capturing a for loop variable (they all see the last value), off-by-one bounds, and enumerating a lazy LINQ query twice.
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.
How to tell it (STAR)
**S:** Our pricing API's memory and outbound calls kept growing until restart. **T:** Find and fix the leak. **A:** A heap dump showed timers whose callback closures captured scoped services, so each request left a live timer behind. I moved the refresh into one background service and added a soak test. **R:** Memory and outbound calls flattened, and we cut provider API costs by [amount].
Last updated 30 Oct 2026.