Every allocation a .NET service makes is a small bill the garbage collector eventually has to pay, and at high request volumes those bills compound into gen0 pauses, gen2 promotions and CPU spent zeroing memory instead of serving traffic. Engineers with ten or more years of experience are expected to find allocation hot spots without guesswork, know which pooling tool fits which problem, and recognize the allocations hiding in code that looks perfectly ordinary. Interviewers use this topic to separate people who can recite "spans are fast" from people who have actually read an allocation profile and fixed what it showed. This page covers the questions asked in senior .NET loops: finding allocations under production conditions, ArrayPool<T> versus ObjectPool<T> versus RecyclableMemoryStream, string.Create and interpolated string handlers, hidden boxing, closures, LINQ costs, struct enumerators and the cases where pooling backfires.

Q1 How do you find where a .NET service is allocating the most memory, without attaching a heavyweight profiler in production?#

Short answer: Confirm there is a real problem with dotnet-counters' allocation-rate and GC-count metrics, then capture a low-overhead dotnet-trace session using the gc-verbose profile, which samples object allocations by type and call stack, and read the result in PerfView or Visual Studio.

The workflow has two stages because they answer different questions. dotnet-counters monitor --counters System.Runtime -p <pid> streams live values for Allocation Rate (bytes per second), and Gen 0/1/2 GC Count, with essentially no setup and negligible overhead; a healthy request handler should show an allocation rate that tracks request volume, not one that climbs while traffic is flat. Once that confirms a real allocation problem, dotnet-trace collect -p <pid> --profile gc-verbose captures GC collection events plus sampled object-allocation events, which is precisely the "which types, from which call stacks" answer that counters cannot give you, at a sampling overhead low enough to run against a live production instance rather than only in a lab.

Bash
dotnet tool install --global dotnet-counters
dotnet tool install --global dotnet-trace

dotnet-counters monitor --counters System.Runtime -p 4807

dotnet-trace collect -p 4807 --profile gc-verbose --duration 00:00:00:30

Open the resulting .nettrace file in Visual Studio's diagnostics window or in PerfView, both of which can group allocations by type and roll up the allocating call stacks into a view close to a flame graph. This is the step most candidates skip: they jump straight to guessing at code instead of letting the trace tell them where to look, which is exactly the discipline interviewers are testing for.

What interviewers look for: a two-stage workflow (confirm, then localize) using the actual CLI tool names and flags, not a vague "I'd use a profiler." Bonus points for knowing this works against a running process with no redeploy and no debugger attach.

Common mistakes: reaching for a full memory dump before confirming the problem is allocation rate rather than a leak; running a profiler with full instrumentation in production instead of a sampling-based trace.

Q2 When do you reach for ArrayPool<T>, when for ObjectPool<T>, and when do you just allocate?#

Short answer: ArrayPool<T> is for raw, short-lived buffers you rent and return around a unit of work; Microsoft.Extensions.ObjectPool's ObjectPool<T> is for whole objects that are genuinely expensive to construct; and for small, cheap objects you should simply allocate, because renting has overhead of its own that a gen0 allocation often beats.

ArrayPool<T>.Shared hands out arrays from per-thread and per-core buckets with almost no construction cost involved, which suits buffers for I/O, encoding, or temporary scratch space where the content does not matter once you are done with it. ObjectPool<T> targets a different shape of problem: objects with real construction cost or an internal buffer worth reusing, such as a StringBuilder with a large backing array, or a domain object that owns an expensive-to-build parser or connection wrapper. Types that implement IResettable can clean themselves before returning to the pool, which keeps reset logic next to the type it belongs to instead of scattered across every call site.

The "just allocate" branch is the one candidates most often forget to mention. A gen0 allocation for a small, short-lived object is close to a pointer bump, and it dies for free in the next gen0 collection. Getting an object from a pool costs a lookup into a synchronized or per-core structure, and a pooled object survives until the pool itself is torn down, which usually means the process lifetime, pushing it into gen2 where it stays. Pool when construction cost or buffer size is large enough to amortize that overhead, and prove it with a benchmark before shipping the change, because "it's pooled" is not automatically "it's faster."

What interviewers look for: the decision framework, not just tool names: is this a raw buffer or a full object, is construction genuinely expensive, and has the win been measured.

Follow-up questions:

  • How would you size an ObjectPool<T>'s maximum retained count, and what happens under a burst larger than that?
  • What goes wrong if a pooled type's TryReset method forgets to clear one of its fields?

Q3 What problem does Microsoft.IO.RecyclableMemoryStream solve that renting from ArrayPool<byte> alone doesn't?#

Short answer: MemoryStream has no pooling hook of its own, so code that builds one per request to serialize or buffer data keeps allocating and reallocating a growing internal array; RecyclableMemoryStream is a drop-in, pooled replacement that manages its own tiers of reusable segments behind the ordinary MemoryStream API, so growth, seeking and multi-chunk writes no longer defeat pooling.

A single ArrayPool<byte>.Rent call works when you know the buffer's size up front and need it for a single fixed-size operation. It stops working the moment you need MemoryStream's actual behavior: unknown final length, incremental writes from a serializer, seeking, and a byte[] or ReadOnlySequence<byte> view of the result at the end. Reimplementing that on top of raw rented arrays means tracking multiple segments, growth policy and disposal yourself, which is exactly the code RecyclableMemoryStream already contains. Internally it pools memory in blocks rather than growing and copying one contiguous array the way the stock MemoryStream does, which keeps large buffers out of the large object heap and reduces the gen2 pressure that a stream growing past 85,000 bytes would otherwise create; its manager also exposes pool-health metrics and can flag streams that are never disposed, which catches the pooling leaks that hand-rolled buffer pooling rarely surfaces on its own.

C#
using Microsoft.IO;

// Create once, share across the app — the manager owns the pooled segments.
private static readonly RecyclableMemoryStreamManager s_streamManager = new();

public static byte[] SerializeToBytes<T>(T value)
{
    using MemoryStream stream = s_streamManager.GetStream(nameof(SerializeToBytes));
    JsonSerializer.Serialize(stream, value);
    return stream.ToArray();   // still one allocation: the caller asked for an owned byte[]
}

What interviewers look for: recognizing that the problem is MemoryStream's growth model, not just "streams allocate," and that the fix is a drop-in type rather than a rewrite of every call site.

Common mistakes: assuming a single ArrayPool<byte>.Rent call is a substitute for a growable, seekable stream; forgetting that the final ToArray() or equivalent is still one legitimate allocation because the caller asked for owned memory.

Q4 How do string.Create and interpolated string handlers reduce allocations compared to naive string building?#

Short answer: string.Create allocates the final string exactly once and lets you write its characters directly through a Span<char>; interpolated strings compile to DefaultInterpolatedStringHandler, which formats each value in place using TryFormat instead of boxing it and calling ToString() on an intermediate object.

string.Create<TState>(int length, TState state, SpanAction<char, TState> action) sidesteps the classic problem with building strings from parts: every + concatenation and every call into string.Format for a value type can allocate an intermediate string or box an argument before the final result exists. string.Create instead reserves the exact final length up front and hands your delegate a span to fill, so the string is allocated once and never copied again.

C#
public static string FormatInvoiceNumber(int year, int sequence)
{
    // One allocation: "INV-2026-000482" written directly into the final string.
    return string.Create(14, (year, sequence), static (span, state) =>
    {
        "INV-".AsSpan().CopyTo(span);
        state.year.TryFormat(span[4..8], out _);
        span[8] = '-';
        state.sequence.TryFormat(span[9..], out _, "D5");
    });
}

Interpolated strings solve a related but distinct problem: formatting the arguments, not just assembling the result. Since C# 10, $"..." compiles to a handler that appends literal text and formatted values into a pooled internal buffer, calling each argument's TryFormat directly instead of boxing it into an object first, which is what string.Format's params object[] overload forces for every value type argument.

C#
// $"Order {id} totals {total:C}" compiles roughly to:
var handler = new DefaultInterpolatedStringHandler(literalLength: 15, formattedCount: 2);
handler.AppendLiteral("Order ");
handler.AppendFormatted(id);           // calls int.TryFormat, no boxing
handler.AppendLiteral(" totals ");
handler.AppendFormatted(total, "C");
string message = handler.ToStringAndClear();

Library authors can go further with a custom [InterpolatedStringHandler] type that skips formatting entirely when it is not needed, which is exactly how conditional logging APIs avoid paying for message construction when the configured log level would discard the message anyway.

What interviewers look for: understanding that these are two different mechanisms addressing two different allocation sources: one for the final string, one for the arguments feeding it. Candidates who can name DefaultInterpolatedStringHandler unprompted are showing real familiarity with the C# 10 compiler output, not just the surface syntax.

Q5 Where does hidden boxing show up in code that looks perfectly ordinary?#

Short answer: Boxing hides wherever a value type is implicitly converted to object or to a non-generic interface: params object[] overloads, non-generic collections, and interface-typed variables or parameters that hold a struct, even when nothing in the code looks like a cast.

C#
public readonly record struct Money(decimal Amount, string Currency);

// Boxes 'total': the params object[] overload forces a heap allocation per call.
Console.WriteLine("Total: {0}", total);

// No boxing: the interpolated string handler formats the value type directly.
Console.WriteLine($"Total: {total}");

// Boxes: a non-generic collection can only store object references.
System.Collections.ArrayList legacyBag = new();
legacyBag.Add(total);

// No boxing: constrained callvirt lets the JIT call the interface member
// directly on the value type when T is constrained to it.
static bool IsPositive<T>(T value) where T : IComparable<T> => value.CompareTo(default!) > 0;

The last line is the one senior candidates should recognize by name: when a generic method calls an interface member on a type parameter constrained to that interface, the JIT emits a constrained.callvirt instruction that dispatches directly on the value type without boxing it, as long as the value type actually implements the interface. The same call written against the plain, non-generic interface type, such as an IComparable-typed local holding a struct, boxes every time, because the compiler has no type parameter left to constrain. Other common sources: Nullable<T> boxing to either the underlying T or null rather than to a boxed Nullable<T>, which surprises people the first time they see it in a debugger; enum values passed through logging or formatting APIs typed to accept object; and older comparer or sorter APIs that accept IComparer instead of IComparer<T>.

What interviewers look for: the ability to spot boxing in code that has no visible cast, and specifically the constrained.callvirt explanation for why generic constraints avoid it. This is a frequent bar-raiser question precisely because it requires knowing how the JIT compiles generics, not just what boxing is.

Common mistakes: assuming boxing only happens with an explicit (object) cast; not recognizing params object[] overloads as a boxing source.

Q6 How do closures cause allocations, and how do you keep them out of a hot path?#

Short answer: A lambda that captures a local variable, a parameter or this forces the compiler to allocate a closure object to hold that state, plus a new delegate instance each time the lambda expression is evaluated inside a loop or a frequently called method; passing the same state explicitly as an argument to a static lambda removes both allocations.

C#
// Captures 'requestedScope': a new closure and delegate allocate on every check, even on hits.
bool allowed = _permissionCache.GetOrAdd(userId, key => Evaluate(key, requestedScope));

// Passes state explicitly; 'static' turns an accidental capture into a compile error.
bool allowed = _permissionCache.GetOrAdd(
    userId, static (key, scope) => Evaluate(key, scope), requestedScope);

The second overload of GetOrAdd exists specifically for this pattern: it accepts an extra piece of state and a delegate typed to receive it as a parameter, so nothing needs to be captured from the enclosing scope. Marking the lambda static is not just documentation, it is enforced: the compiler rejects the lambda if it tries to capture anything, which turns an accidental future regression into a compile error instead of a silent allocation that only shows up in a profiler months later. The same pattern applies to Task.Run, LINQ operators, and any callback-based API: if the callback needs data from the caller, check first whether the API offers a state-passing overload before reaching for a capturing lambda.

What interviewers look for: recognizing that the fix is not "avoid lambdas," it is "avoid capturing," and knowing the static modifier's role as a compiler-enforced guardrail rather than a hint.

Common mistakes: believing all lambdas allocate equally regardless of whether they capture anything; missing that a lambda expression re-evaluated inside a loop allocates a new delegate on every iteration even when its captured state does not change.

Q7 What does LINQ allocate internally, and when does that actually matter?#

Short answer: Each LINQ operator wraps the source in an iterator object, invokes your lambda through a delegate on every element, and allocates a closure if that lambda captures anything; for code executed a handful of times per request this is noise, but inside a per-element inner loop over a large collection it adds up.

C#
// Allocates one IGrouping<TKey, TSource>-backed list per key just to read one value from each.
var topPricePerCategory = products
    .GroupBy(p => p.Category)
    .Select(g => (g.Key, Max: g.Max(p => p.Price)))
    .ToDictionary(x => x.Key, x => x.Max);

// .NET 9+: IEnumerable<KeyValuePair<string, decimal>>, aggregated per key
// without ever materializing an IGrouping list first.
var topPricePerCategoryFast = products.AggregateBy(
    p => p.Category, seed: 0m, (max, p) => Math.Max(max, p.Price));

GroupBy is the clearest example: it must fully materialize a list of elements per key before you can call .Max() on each group, even though the only thing the query actually needs from each group is a single aggregated value. The .NET 9 AggregateBy and CountBy operators exist precisely to remove that intermediate structure for the common case of "one value per key." More generally, enumerating a deferred query twice, calling .Count() and then iterating separately, and keeping LINQ inside a loop that runs per element of a large batch are the patterns that turn "LINQ is fine" into "LINQ shows up at the top of an allocation trace." Orchestration-level LINQ, run once or a few times per request, is rarely worth touching.

What interviewers look for: a nuanced position, not "LINQ is slow." Strong answers name the specific mechanism (iterator objects, delegate calls, IGrouping materialization) and draw a clear line between where it matters and where it does not.

Follow-up questions:

  • When would Select(...).ToList() followed by a foreach be preferable to iterating the deferred query directly?
  • What does TryGetNonEnumeratedCount buy you over calling .Count() on a query you might not otherwise enumerate?

Q8 What is a struct enumerator, and how does the "foreach doesn't allocate" guarantee silently break?#

Short answer: List<T>, Dictionary<TKey,TValue>, HashSet<T> and Span<T> all expose a value-type (struct) enumerator so a foreach over the concrete collection type calls MoveNext and Current directly with no allocation; the guarantee breaks the instant the compile-time type of the expression becomes an interface, because the struct enumerator then has to be boxed to satisfy IEnumerator<T>.

C#
List<int> numbers = [1, 2, 3, 4, 5];

// No allocation: the compiler binds directly to List<int>.Enumerator, a struct.
foreach (int n in numbers)
{
    Process(n);
}

// Allocates: the compile-time type is IEnumerable<int>, so the struct
// enumerator is boxed to satisfy IEnumerator<int>.
IEnumerable<int> asInterface = numbers;
foreach (int n in asInterface)
{
    Process(n);
}

static void DoWork(IEnumerable<int> source)
{
    // Every call boxes an enumerator, even though every real caller passes a List<int>.
    foreach (int n in source) { Process(n); }
}

The foreach compiler pattern binds structurally at compile time: it looks for GetEnumerator, MoveNext and Current on the declared type of the expression before ever considering IEnumerable<T>. That is why iterating a List<int> variable directly is free, while a method parameter typed as IEnumerable<int> pays for a boxed enumerator on every call, even when every caller happens to pass a List<int>. This is a common source of "invisible" allocations in code that looks identical to the allocation-free version at a glance, and it is one of the reasons hot-path helper methods are often written to take a concrete type, or a Span<T>, instead of the most general interface.

What interviewers look for: knowing that the compiler resolves foreach structurally, not through the interface, and being able to name the exact moment (an interface-typed variable or parameter) where the boxing is introduced.

Common mistakes: assuming foreach is always allocation-free regardless of the variable's declared type; writing a hot-path method signature as IEnumerable<T> "for flexibility" without realizing the cost when the concrete type would have been just as usable.

Q9 When does pooling make performance worse instead of better?#

Short answer: When the pooled object is small and cheap to construct, when the pool's own synchronization becomes a contention point under high parallelism, or when the pooled objects are retained for the process lifetime and simply relocate garbage from gen0, where it would have died for free, into gen2, where it does not.

Renting is not free: ArrayPool<T> looks up a per-thread or per-core bucket and falls back to a shared, synchronized structure when that is empty, and ObjectPool<T> typically wraps a concurrent structure of its own. For an object that a gen0 collection would clean up almost for free, that lookup can cost more than the allocation it was meant to avoid. Pooling also changes where an object lives, not just whether it was allocated: an object that survives in a pool for the application's lifetime is promoted to gen2 the first time a collection catches it there, and gen2 collections are the expensive ones, so a large pool of rarely reused objects can increase the very cost pooling was supposed to reduce. Under very high concurrency, a shared pool can itself become a bottleneck if its internal structure is not partitioned per core the way ArrayPool<T>.Shared is, in which case threads spend more time contending for a slot in the pool than they would have spent just allocating. Finally, pooling adds a correctness surface that plain allocation does not have: a reset routine that misses one field can leak one caller's data into the next renter, which in a multi-tenant system is a data-isolation bug, not just a performance one.

What interviewers look for: the honest, non-dogmatic view that pooling is a tool with real costs, backed by at least one concrete mechanism (bucket contention, gen2 promotion, or incomplete reset) rather than a general "sometimes it's not worth it."

Common mistakes: treating "we pooled it" as inherently an optimization without measuring; forgetting that a pool needs its own capacity and reset strategy reviewed just as carefully as the code that uses it.

Q10 Walk through reviewing a hot-path method that looks innocent but allocates heavily.#

Short answer: Read it for the same short list every time: a LINQ chain executed per request, an interpolated string passed to a logging call, and any collection or interface conversion that is broader than it needs to be; then rewrite each one with the narrowest allocation-free equivalent that preserves behavior.

C#
// Before: three avoidable allocations on every call to a method invoked per request.
public IActionResult GetActiveOrders(int customerId)
{
    var activeOrders = _orders
        .Where(o => o.CustomerId == customerId && o.Status == OrderStatus.Active)
        .ToList();                                                          // iterator + delegate

    _logger.LogInformation($"Found {activeOrders.Count} active orders for {customerId}"); // eager format

    return activeOrders.Count == 0
        ? NotFound(new { message = "No active orders", customerId })       // fine: cold path
        : Ok(activeOrders);
}

// After: same behavior, no allocation added beyond what the caller actually needs.
public IActionResult GetActiveOrders(int customerId)
{
    List<Order> activeOrders = [];
    foreach (Order order in CollectionsMarshal.AsSpan(_orders))            // no iterator, no delegate
    {
        if (order.CustomerId == customerId && order.Status == OrderStatus.Active)
        {
            activeOrders.Add(order);
        }
    }

    _logger.LogInformation(                                                // formatting is deferred
        "Found {Count} active orders for {CustomerId}", activeOrders.Count, customerId);

    return activeOrders.Count == 0
        ? NotFound(new { message = "No active orders", customerId })
        : Ok(activeOrders);
}

The logging line is the one most reviewers miss: LogInformation($"...") evaluates the interpolated string eagerly, every time, regardless of whether the configured minimum log level would even emit it, and it throws away the structured fields a log backend could otherwise index on. The template-and-arguments overload defers formatting to the logging provider and keeps Count and CustomerId as queryable fields instead of baked-in text. Note what the review deliberately leaves alone: the NotFound/Ok result objects still allocate, and that is fine, because that branch runs once per request at most and is not the code path anyone profiled. A good review fixes what the trace showed, not everything that could theoretically allocate.

What interviewers look for: a review that prioritizes the allocations a profile actually flagged, explains why each fix works, and explicitly declines to "optimize" code that was never the bottleneck.

Follow-up questions:

  • How would you add a regression test or benchmark so this method cannot silently regress back to the allocating version?
  • What would you do differently if _orders were backed by a database query instead of an in-memory list?

Quick-Fire Round#

QuestionAnswer
Does ArrayPool<T>.Rent clear the returned array?No, unless you explicitly clear it yourself.
What interface do IResettable pooled objects implement to clean up?Microsoft.Extensions.ObjectPool.IResettable.
What does string.Create guarantee about allocations?Exactly one allocation, for the final string.
What do interpolated strings compile to since C# 10?DefaultInterpolatedStringHandler, formatting each value with TryFormat.
Does a struct passed through a non-generic IComparer box?Yes; a generic, constrained call does not.
Why does foreach over List<T> avoid boxing the enumerator?The compiler binds GetEnumerator/MoveNext structurally on the concrete type.
What .NET 9 LINQ operator avoids materializing IGrouping lists?AggregateBy (and CountBy for simple counts).
Where do pooled objects live if never explicitly evicted?They are promoted to gen2 and stay for the pool's lifetime.

How to Prepare#

  • Practice the two-stage workflow: dotnet-counters to confirm an allocation problem, dotnet-trace --profile gc-verbose to localize it by type and call stack.
  • Know the decision line between ArrayPool<T>, ObjectPool<T>, RecyclableMemoryStream and plain allocation, and be ready to justify each with a concrete cost.
  • Be able to point to boxing in code with no visible cast, and explain constrained.callvirt as the reason generic constraints avoid it.
  • Rewrite a closure-capturing hot path using a state-passing overload and the static lambda modifier.
  • Review a real LINQ-heavy method and identify which parts are orchestration (leave alone) versus per-element hot path (rewrite).
  • Read the high-performance .NET guide so the allocation-reduction techniques are fresh going into the loop.