ArrayPool and stackalloc — Avoiding Heap Allocations

The .NET garbage collector handles memory automatically, but frequent small allocations create GC pressure — the GC must periodically scan the heap, identify unreachable objects, and compact memory. In high-throughput ASP.NET Core applications, even small allocations inside frequently-called code paths accumulate into significant GC pauses. ArrayPool<T> provides a pool of reusable arrays, and stackalloc allocates fixed-size buffers on the stack, both avoiding heap allocation entirely for temporary buffers.

ArrayPool — Reusable Buffer Rental

// ── Rent and return pattern — the fundamental contract ─────────────────────
byte[] buffer = ArrayPool<byte>.Shared.Rent(4096);   // rent a buffer of at least 4096 bytes
try
{
    // Work with the buffer — note: Rent may return a LARGER buffer than requested
    int bytesRead = await stream.ReadAsync(buffer.AsMemory(0, 4096), ct);
    ProcessData(buffer.AsSpan(0, bytesRead));
}
finally
{
    ArrayPool<byte>.Shared.Return(buffer);   // ALWAYS return — even on exception
}

// ── Using statement pattern — cleaner with IDisposable wrapper ─────────────
// System.Buffers.MemoryOwner<T> (Microsoft.Toolkit.HighPerformance)
// or write your own:
public sealed class PooledBuffer<T> : IDisposable
{
    private readonly T[] _array;
    private bool _disposed;

    public PooledBuffer(int size)
    {
        _array   = ArrayPool<T>.Shared.Rent(size);
        Memory   = _array.AsMemory(0, size);
    }

    public Memory<T> Memory { get; }
    public Span<T>   Span   => Memory.Span;

    public void Dispose()
    {
        if (!_disposed)
        {
            ArrayPool<T>.Shared.Return(_array);
            _disposed = true;
        }
    }
}

// Usage with using — return is guaranteed
using var buffer2 = new PooledBuffer<byte>(4096);
int read = await stream.ReadAsync(buffer2.Memory, ct);
ProcessData(buffer2.Span[..read]);
Note: ArrayPool<T>.Shared.Rent(minimumLength) returns an array of at least the requested length — it may return a larger array from the pool. Always track and use only the portion you need: buffer.AsSpan(0, actualSize), not buffer.AsSpan(). Returned arrays may contain data from previous users — never rely on the buffer being zeroed unless you explicitly zero it with Array.Clear() or buffer.AsSpan().Clear(). Clearing adds overhead, so only do it when the data is security-sensitive.
Tip: Use ArrayPool for temporary buffers in hot paths — methods called per-request, per-message, or in tight loops. Benchmark before applying: allocating a small array on the heap costs microseconds; if the method is called once per HTTP request, the overhead is negligible compared to the database query. Profile with tools like BenchmarkDotNet and dotMemory to confirm allocations are a real bottleneck before optimising. Premature optimisation with ArrayPool makes code more complex for no measured benefit.
Warning: Forgetting to return a rented buffer to the pool causes a resource leak — the array is still in memory, held by the pool caller, and the pool cannot reuse it. Under sustained load, this depletes the pool, causing Rent to allocate new arrays (defeating the purpose) or throw. Always use a try/finally or IDisposable wrapper to guarantee return. The pool does not have a finaliser that returns arrays — it is a cooperative, caller-managed system.

Stackalloc — Stack-Allocated Buffers

// ── stackalloc — allocate on the stack, automatically freed on method exit ──
// Only valid for unmanaged types (int, byte, char, struct with no references)
// Size must be known at compile time or be a small constant

// Small fixed buffer — no heap allocation
Span<byte> buffer3 = stackalloc byte[256];
buffer3.Clear();   // zero it if security-sensitive

// Use it like any span
int length = FillBuffer(buffer3);
Console.WriteLine($"Filled {length} bytes");

// Conditional stackalloc — stack for small, heap for large
const int StackThreshold = 512;
int bufferSize = GetRequiredSize();

Span<char> chars = bufferSize <= StackThreshold
    ? stackalloc char[StackThreshold]
    : new char[bufferSize];   // heap for large buffers

// Build a string without intermediate allocation
int written = FormatData(chars, data);
string result = chars[..written].ToString();   // one allocation at the end

When to Use Each Approach

Approach Use When Limit
new byte[n] Simple, infrequent, or large buffers No limit (GC managed)
stackalloc Very small, fixed-size, hot-path, sync only ~1KB (stack overflow risk if too large)
ArrayPool.Rent Large temporary buffers in hot paths Any size; must return
Memory<T> Async-compatible slice of pooled/rented buffer Lifetime of underlying array

Common Mistakes

Mistake 1 — Large stackalloc (stack overflow)

❌ Wrong — allocating too much on the stack causes StackOverflowException:

Span<byte> huge = stackalloc byte[1_000_000];   // will crash!

✅ Correct — use stackalloc only for small buffers (< 1KB); use ArrayPool for larger ones.

Mistake 2 — Using rented buffer data from a previous rental (dirty data)

❌ Wrong — rented array may contain data from previous usage:

byte[] buf = ArrayPool<byte>.Shared.Rent(256);
// buf may contain bytes from the previous user — read stale data!

✅ Correct — zero or overwrite the buffer before use if old data would cause incorrect behaviour.

🧠 Test Yourself

Why must rented ArrayPool buffers always be returned in a finally block?