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

📚 Interview question

What are closures in C#? Explain how captured variables are hoisted into a compiler-generated class, why capture is by variable rather than by value, the for-loop versus foreach capture difference, closures in async code and timers, the allocation and memory-leak risks, and how static lambdas help.

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.

Mid-Level Debugging Advanced closures csharp lambdas memory-leaks performance

🎯 Key points

  • A closure is a lambda or local function plus the variables it captures from the enclosing scope
  • The compiler hoists captured locals into fields of a display class; both the method and the lambda use the same field
  • Capture is by variable: later changes are visible inside the lambda, and the lambda can change the variable
  • foreach loop variables are fresh per iteration (since C# 5); a `for` loop variable is shared by all iterations
  • All lambdas in the same scope share one display class, so one long-lived lambda can keep another's captures alive
  • Capturing `this` keeps the whole object alive; long-lived closures (timers, caches, static events) cause leaks
  • Each closure costs a heap allocation plus a delegate; static lambdas and APIs with a state argument avoid it

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. Hoisting into a display class

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.

2. Capture by variable, not by value

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.

3. for vs foreach capture

var 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
  • A for loop has one variable i for the whole loop, incremented each iteration — all lambdas share it.
  • Since C# 5, foreach declares a fresh variable per iteration (a breaking change made because the old behaviour caused so many bugs).
  • Fix for for: int copy = i; actions.Add(() => Console.Write(copy));.

4. Closures in async code and timers

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

5. Allocation and memory-leak risks

  • Allocations: each entry into a scope with captured variables allocates a closure; each lambda evaluation allocates a delegate. In hot paths (per request, per item) this adds GC pressure.
  • Shared closure: all lambdas in the same scope share one display class. If one lambda captures a large object (e.g. a 50 MB buffer) and another small lambda is stored long-term, the big object stays alive too.
  • Leaks: closures stored in long-lived places (static events, caches, singleton callbacks, timers) keep captured objects — including this, DbContext, HttpContext — alive indefinitely.
  • Captured HttpContext/scoped services in background tasks: the request ends and the captured objects are disposed or reused → ObjectDisposedException or data from another request.

6. How static lambdas help

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.

7. A production example

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.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. Display class scope rules

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.

2. What gets captured

  • Locals and parameters → fields on the display class.
  • 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).

3. Local functions vs lambdas

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.

4. Debugging closures

  • In SharpLab, inspect <>c__DisplayClass types.
  • In memory profilers, closures appear as <>c__DisplayClassN_M types — large counts indicate hot-path captures; retention paths through them reveal leaks.
  • BenchmarkDotNet's [MemoryDiagnoser] shows allocation per operation.

5. Async methods are closures too

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.

6. Patterns to avoid closures

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

Try it yourself

  1. Reproduce the for loop capture bug and fix it.
  2. Capture a large array in one lambda and store another small lambda from the same scope in a static field; take a memory snapshot.
  3. Benchmark a capturing lambda vs a static lambda with state.
  4. Capture HttpContext in Task.Run in a test app and observe failures after the request ends.

💻 Code

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.

⚠ Common mistakes

1. Capturing for loop variables in deferred lambdas

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

2. Assuming captures are snapshots

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.

3. Capturing request-scoped objects in background work

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.

4. Closures in hot paths

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.

5. Long-lived closures capturing this

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

6. Shared display class retaining big objects

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.

7. Mutating captured variables from multiple threads

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.

8. Not using static for non-capturing lambdas

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

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions static lambdas to avoid captures. Why: Closures are created by lambdas; checks static lambdas.
    How to answer

    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.

  2. When: Candidate mentions the for loop capture bug. Why: Checks the foreach loop variable change in C# 5.
    How to answer

    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.

  3. When: Candidate mentions closures keeping objects alive. Why: Captured this in event handlers is a common leak.
    How to 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.

🛠 Real-world problems: how you handle them

Timer closures leaked scoped services
Situation
An ASP.NET Core API created a System.Threading.Timer inside a scoped PriceWatcher to refresh prices every minute. The callback lambda captured `this`.
Signals
Steady memory growth and rising thread-pool work; outbound calls to the price provider grew with the number of requests served since start-up.
Investigation
Took a dotnet-dump, ran `dumpheap -stat` and found thousands of PriceWatcher and TimerQueueTimer objects; `gcroot` showed TimerQueue -> TimerQueueTimer -> callback delegate -> display class -> PriceWatcher.
Resolution
Moved the refresh into a singleton BackgroundService using PeriodicTimer, and removed the timer from the scoped class.
Prevention
Added a code review rule that timers must be owned by singletons and disposed, and a soak test that checks memory and outbound call rates stay flat.

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

↩ Usually asked after

More in Advanced Language Features

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me