.net made easy[]

ArrayPool<T> in C#: Reuse Buffers and Cut Allocations

AdvancedLesson 203 of 315·Production .NET·22 min read·Updated

Why allocate a fresh buffer a thousand times a second when you could borrow the same one, over and over?

Picture a request handler that processes an uploaded file in chunks:

async Task ProcessUploadAsync(Stream upload)
{
    while (true)
    {
        byte[] buffer = new byte[8192]; // a fresh 8 KB array — every single iteration
        int bytesRead = await upload.ReadAsync(buffer);
        if (bytesRead == 0) break;

        ProcessChunk(buffer.AsSpan(0, bytesRead));
        // 'buffer' becomes garbage the instant this loop iteration ends
    }
}

Every trip through that loop allocates a brand-new 8,192-byte array, uses it for a few microseconds, and then abandons it as garbage. Under light load, this is invisible. Under real, sustained load — thousands of uploads a minute, each looping hundreds of times — you're handing the GC a relentless stream of short-lived, sizable arrays to trace and reclaim, exactly the kind of hot-path allocation pressure Advanced Part I's generational GC lesson and Part IV's high-throughput async lesson both warned you to watch for.

In this lesson, you'll meet ArrayPool<T> — a shared pool of reusable arrays that lets you borrow a buffer instead of allocating one, and hand it back when you're done so the next caller can reuse the exact same memory.

What Is It?

The Simple Explanation

ArrayPool<T> is a shared "library" of pre-allocated arrays you can check out and return, instead of allocating a new array from scratch every time you need one. Borrow one when you need it; give it back when you're done; the next person who needs a similarly-sized array gets the exact same one, already sitting in memory, ready to go.

The Technical Definition

ArrayPool<T> (in System.Buffers) is a resource pool that manages pre-allocated instances of T[], organized internally into buckets of different sizes. ArrayPool<T>.Shared gives you a process-wide, thread-safe default instance, appropriate for the vast majority of use cases. Its two core operations:

Why Does It Exist?

The Problem

Repeatedly allocating a sizable array in a hot loop or hot request path creates real, measurable GC pressure — every allocation is memory the GC eventually has to trace and reclaim, and at high request volume, that adds up to genuine, visible overhead. It gets worse the bigger the buffer: as you learned in Advanced Part I's generational GC lesson, any single object at or above the 85,000-byte threshold is allocated directly on the Large Object Heap, bypassing Gen0/Gen1 entirely — and the LOH isn't compacted by default, so repeatedly allocating and discarding large buffers can accumulate fragmentation over time. A buffer of, say, 64 KB used for file or network I/O sits right in the range where this starts to matter.

The Solution

ArrayPool<T> breaks the allocate-use-discard cycle. Instead of a fresh array every iteration, you rent an existing one from a shared pool, use it, and return it — so the very same underlying array gets reused by the next caller, over and over, with the pool absorbing the allocation cost exactly once (or a small number of times, as the pool grows to meet demand) rather than on every single iteration.

Big Picture

WITHOUT ArrayPool<T>

  • new byte[8192] every iteration
  • Each buffer becomes garbage almost immediately
  • GC has to trace and reclaim every single one
  • At 8,192 bytes, well under the LOH threshold — but plenty of real buffers (image processing, file I/O) cross it, adding LOH fragmentation risk on top

WITH ArrayPool<T>

  • ArrayPool<byte>.Shared.Rent(8192) — reuses an existing array whenever one's available
  • Return it when done — it goes right back into the pool
  • The same handful of physical arrays get reused across thousands of iterations
  • GC pressure drops dramatically — far fewer objects to trace

How It Works

RENT → USE → RETURN
1. RENT — ASK FOR AT LEAST N ELEMENTS
byte[] buffer = ArrayPool<byte>.Shared.Rent(8192);
  • Hands back an existing pooled array if one big enough is available — otherwise allocates a new one
  • Important: the returned array may be larger than 8,192 — more on this below
▼
2. USE IT — TRACK YOUR OWN LOGICAL LENGTH
int bytesRead = await upload.ReadAsync(buffer.AsMemory(0, 8192));
ProcessChunk(buffer.AsSpan(0, bytesRead)); // use only the bytes you actually asked for / received
▼
3. RETURN — ALWAYS, EVEN IF SOMETHING THROWS
ArrayPool<byte>.Shared.Return(buffer);
  • Puts the array back into the pool, available for the next Rent call
  • Skipping this doesn't leak memory in the GC sense — the array is still a normal, eventually-collectible object — it just silently loses you the pooling benefit for that array, permanently

Simple Example — the correct, safe pattern

async Task ProcessUploadAsync(Stream upload)
{
    byte[] buffer = ArrayPool<byte>.Shared.Rent(8192);
    try
    {
        while (true)
        {
            // buffer.Length may be >= 8192, but we only ever ask for/use up to 8192
            int bytesRead = await upload.ReadAsync(buffer.AsMemory(0, 8192));
            if (bytesRead == 0) break;

            ProcessChunk(buffer.AsSpan(0, bytesRead)); // only the valid, just-read portion
        }
    }
    finally
    {
        ArrayPool<byte>.Shared.Return(buffer); // guaranteed to run, even if ProcessChunk throws
    }
}

What changed from the Hook example: one buffer is rented outside the loop and reused across every iteration — not reallocated each time — and it's returned exactly once, in a finally block, so it goes back to the pool no matter how the method exits, including via an exception. Note also that the code deliberately reads and processes at most 8,192 bytes at a time — the exact amount requested — rather than assuming buffer.Length tells it how much space is "really" there.

Real-World Example

ArrayPool<T> is exactly the kind of tool that matters most under sustained, high-volume load — which is exactly why it's used pervasively inside ASP.NET Core and the wider .NET framework itself, for things like buffering request/response bodies and formatting output. Application-level scenarios where it earns its keep:

Analogy

A library of reusable takeout containers, not disposable ones

Imagine a busy kitchen that used a brand-new disposable container for every single order, then threw it in the trash the moment the food was served. Wasteful, but it works. Now imagine instead a shelf of sturdy, reusable containers: grab one that's big enough, use it, wash it, put it back on the shelf for the next order. Same job done, dramatically less waste generated.

ArrayPool<T> is that shelf. Rent is grabbing a container — possibly a slightly bigger one than you strictly needed, if that's the nearest size the shelf has. Return is putting it back, clean, for someone else to use. Forget to put it back, and it's not lost forever — it's still a perfectly good container sitting in a drawer somewhere — it just never makes it back to the shelf where it could keep being useful.

Under the Hood

THE TWO GOTCHAS THAT ACTUALLY MATTER
1. Rent CAN GIVE YOU A LARGER ARRAY THAN YOU ASKED FOR
  • Internally, ArrayPool<T> organizes its available arrays into size "buckets" (commonly rounded up to powers of two internally). Rent(8192) may, entirely legitimately, hand you back an array of length 16,384 if that's the smallest bucket with something available.
  • This is real, documented, load-bearing behavior — not a bug and not something you can rely on being absent. It's what lets the pool reuse a smaller set of distinct array sizes efficiently instead of needing an exact-size match for every possible request.
  • The consequence: never assume array.Length equals the number you passed to Rent. Track your own logical/used length separately (a local variable, a count returned from a read call) and only ever read or write within that tracked range — exactly what the Simple Example does by consistently using 8192 and bytesRead, never buffer.Length.
▼
2. FORGETTING Return DOESN'T LEAK MEMORY — IT JUST LOSES THE POOLING BENEFIT
  • An array you rent but never return is still, physically, an ordinary managed array object. If nothing else references it once your method's local variable goes out of scope, it becomes unreachable and the GC collects it completely normally — this is not a memory leak in the traditional sense.
  • What you actually lose is quieter: that specific array can never be handed back out by a future Rent call, so the pool's benefit for that particular allocation is gone for good, and the pool may end up allocating a fresh replacement array to make up for it. Do this pervasively — say, by never calling Return anywhere in a hot path — and you've quietly turned ArrayPool<T> back into new T[] with extra steps.
  • This is exactly why wrapping the rent/use/return sequence in a try/finally (or a small disposable wrapper type that calls Return in Dispose) matters: it guarantees the array actually makes it back to the pool even when an exception is thrown partway through using it.
▼
3. THE clearArray OPTION ON Return
ArrayPool<byte>.Shared.Return(buffer, clearArray: true);
  • By default, Return does not zero out the array's contents — the next caller who Rents that same physical array will see whatever bytes were left over from your use of it.
  • For most callers, this is completely fine: the very next thing you do with a freshly-rented buffer is typically overwrite it (read new data into it, write new data to it) before ever reading from it — so any leftover contents are irrelevant and immediately discarded.
  • For security- or privacy-sensitive data — cryptographic key material, authentication tokens, personal data — leftover contents genuinely matter, since the next renter (potentially a different, unrelated part of your application) could otherwise observe fragments of data it was never meant to see. Passing clearArray: true zeroes the array before it goes back into the pool, closing that gap.
  • It isn't the default because clearing costs real time proportional to the array's size, and paying that cost on every single return — even for the overwhelming majority of ordinary, non-sensitive buffers — would undercut much of the pool's performance benefit. It's an opt-in, deliberately reserved for the cases that actually need it.

Common Confusion

1. "Rent(8192) always gives me exactly an 8192-length array"

Not guaranteed, and as covered above, frequently untrue in practice. Rent guarantees the array is at least the length you asked for — never less, possibly more. Code that indexes up to array.Length instead of its own tracked logical length is a latent bug waiting to process garbage data from a previous use of an oversized rented array.

2. "Forgetting to Return causes a memory leak"

Not in the GC sense — the array remains a perfectly ordinary, collectible object once nothing references it. What you actually lose is the reuse benefit for that specific array; the pool simply never sees it again and may allocate a new one to compensate. It's a performance/efficiency loss, not a correctness or leak problem.

3. "clearArray should always be true, just to be safe"

For most workloads this trades away real performance for no actual benefit, since the buffer's contents get overwritten before being read anyway. Reserve clearArray: true specifically for buffers that held sensitive data and might otherwise leak leftover bytes to an unrelated future renter.

Common Mistakes

Mistake 1 — Trusting array.Length after renting

Iterating for (int i = 0; i < buffer.Length; i++) over a rented buffer, assuming it's exactly the size requested.

byte[] buffer = ArrayPool<byte>.Shared.Rent(100);
for (int i = 0; i < buffer.Length; i++) //  buffer.Length could be 128, not 100 — processes garbage bytes 100-127
    Process(buffer[i]);

Track and use your own requested/logical length explicitly, everywhere: for (int i = 0; i < 100; i++), or better, work through a properly-bounded Span<T> slice: buffer.AsSpan(0, 100).

Mistake 2 — Not returning on an exception path

Rent, then a plain sequence of statements with no try/finally — if anything throws before the Return call at the end, the array never makes it back.

Always wrap rent/use/return in a try/finally, or use a small IDisposable wrapper type around the rent/return pair so a using block guarantees it — following the same RAII discipline you already use for other disposable resources.

Mistake 3 — Returning the same array twice, or using it after returning it

Calling Return more than once on the same array, or continuing to read/write a buffer after it's already been returned — both corrupt the pool's internal bookkeeping and can lead to two unrelated parts of your code silently sharing (and stomping on) the same array.

Treat Return as transferring ownership away, exactly once, at exactly one place — typically the single finally block that also did the corresponding Rent.

When Should I Use It?

Reach for ArrayPool<T> when

  • You need a plain T[] buffer, allocated and discarded repeatedly, on a genuinely hot path (high call frequency, or per-request in a high-throughput service)
  • The buffer is sizable enough that repeated allocation is a real, measured (or well-understood) source of GC pressure — not merely a small, infrequent array
  • The API you're working with (a Stream, a socket, a custom parser) naturally works in terms of plain arrays or Span<T>/Memory<T> over arrays

Skip it when

  • The array is small, or the code path is cold (rarely executed) — the added complexity isn't worth it
  • You specifically need Memory<T>/IMemoryOwner<T>-based pooling instead of a raw array — that's MemoryPool<T>, the next lesson
  • You haven't measured or reasoned through an actual allocation problem — reach for pooling after profiling shows it matters, not preemptively everywhere arrays appear
Rule of thumb: Reach for ArrayPool<T>.Shared specifically on paths that allocate the same-shaped buffer repeatedly, at real frequency, under real load — a file-upload loop, a socket-reading loop, a per-request scratch buffer in a high-throughput API. For everyday, infrequent array needs, new T[n] remains simpler and entirely appropriate.

Mental Model

Rent = borrow, don't buy. May hand you more than you asked for.
Return = give it back, always — in a finally — or the pool never sees it again.
array.Length ≠ your requested length. Track your own logical length.

Remember: not returning isn't a leak, it's a missed reuse. But miss it enough times and ArrayPool<T> stops helping you at all.

Key Takeaway


Check Your Understanding

You've seen how renting and returning avoids repeated allocation. Let's check the gotchas actually landed.

1. After byte[] buffer = ArrayPool<byte>.Shared.Rent(500);, what can you say for certain about buffer.Length?

Show answer

Correct: B

Why B is correct: Rent guarantees an array of at least the requested length, but the pool organizes arrays into size buckets internally and may hand back a larger one if that's the smallest available match — real, documented behavior, not an edge case to dismiss.

Why A is incorrect: This is the exact assumption the lesson warns against — relying on it leads to processing stale or garbage data beyond your intended range.

Why C is incorrect: Rent never returns an array shorter than requested — that would violate the entire contract of the method.

Why D is incorrect: buffer.Length is a perfectly well-defined value immediately after Rent returns — you just can't assume it equals your requested length.

Reinforcement: Always track your own logical/used length separately from array.Length when working with rented arrays.

2. A developer rents an array, processes data with it, and forgets to call Return. What is the actual consequence?

Show answer

Correct: B

Why B is correct: A rented array that's never returned is still a completely normal managed array. Once nothing references it, the GC collects it exactly as it would any other object. What's actually lost is more subtle: the pool never gets that array back, so it can't be reused — a lost efficiency, not a leak or a crash.

Why A is incorrect: A forgotten Return doesn't prevent garbage collection — the array remains perfectly reclaimable once unreferenced, so this doesn't cause an OutOfMemoryException on its own.

Why C is incorrect: ArrayPool<T> has no mechanism to detect or reject based on missing returns — it simply has fewer arrays available to hand out, potentially allocating a new one instead.

Why D is incorrect: There's no automatic timeout-based return mechanism — a forgotten Return stays forgotten indefinitely, permanently losing that array's pooling benefit.

Reinforcement: Forgetting Return is a silent efficiency loss, not a correctness bug or a leak — but it's still worth avoiding rigorously with try/finally or a disposable wrapper.

3. Why is clearArray: true not the default behavior for ArrayPool<T>.Shared.Return?

Show answer

Correct: B

Why B is correct: Zeroing out an array's contents takes time proportional to its size, and doing that on every single Return call — even for the overwhelming majority of buffers whose contents get overwritten before being read anyway — would undercut much of the pool's performance benefit. It's left as an opt-in specifically for cases (sensitive data) where leftover contents genuinely matter.

Why A is incorrect: Clearing and size-tracking are unrelated concerns — the pool tracks bucket sizes independently of whether an array's contents are cleared.

Why C is incorrect: There's no such technical restriction — Return(array, clearArray: true) works on any rented array, regardless of how it was originally allocated.

Why D is incorrect: clearArray works uniformly across any element type T — the option isn't restricted by whether T is a value type or reference type.

Reinforcement: Performance-cost-vs-benefit trade-offs like this are common in pooling APIs — the default favors the common case (overwrite-before-read), with an explicit opt-in for the less common but genuinely important sensitive-data case.

4. Why does the Simple Example wrap the rent/use/return sequence in a try/finally block instead of just calling Return as the last line of the method?

Show answer

Correct: B

Why B is correct: If an exception is thrown while using the rented array — say, from within ProcessChunk — a plain sequential Return call written after that code would simply never execute, and the array would never make it back to the pool. Placing Return in a finally block guarantees it runs on every exit path, exception or not.

Why A is incorrect: try/finally has no meaningful runtime performance cost in the non-exceptional case — its purpose here is correctness under failure, not speed.

Why C is incorrect: Nothing in ArrayPool<T>'s API enforces this structurally — it's a recommended pattern for correctness, not a compiler-enforced requirement.

Why D is incorrect: The two approaches behave identically only when nothing throws — the entire point of the pattern is to handle the case where something does.

Reinforcement: The same RAII discipline you already apply to other disposable resources (files, connections) applies here — guarantee cleanup runs regardless of how the method exits.

You can now cut real GC pressure out of hot buffer-processing paths with ArrayPool<T> — and you know exactly why array.Length can't be trusted after renting. Next: the Memory<T>-flavored version of the same idea.