← Open in the full interactive course (progress tracking, search & more)

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>

WITH ArrayPool<T>

How It Works

RENT → USE → RETURN
1. RENT — ASK FOR AT LEAST N ELEMENTS
byte[] buffer = ArrayPool<byte>.Shared.Rent(8192);
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);

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
2. FORGETTING Return DOESN'T LEAK MEMORY — IT JUST LOSES THE POOLING BENEFIT
3. THE clearArray OPTION ON Return
ArrayPool<byte>.Shared.Return(buffer, clearArray: true);

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

Skip it when

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.


dotnetmadeeasy.com — Learn C# and .NET, the right way.