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.
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.
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:
Rent(int minimumLength) — hands you an array from the pool that is at least minimumLength elements long, allocating a new one only if the pool has nothing suitable available.Return(T[] array, bool clearArray = false) — hands the array back to the pool so a future Rent call can reuse it.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.
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.
new byte[8192] every iterationArrayPool<byte>.Shared.Rent(8192) — reuses an existing array whenever one's availableReturn it when done — it goes right back into the poolbyte[] buffer = ArrayPool<byte>.Shared.Rent(8192);
int bytesRead = await upload.ReadAsync(buffer.AsMemory(0, 8192));
ProcessChunk(buffer.AsSpan(0, bytesRead)); // use only the bytes you actually asked for / received
ArrayPool<byte>.Shared.Return(buffer);
Rent callasync 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.
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:
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.
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.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.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.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.ArrayPool<byte>.Shared.Return(buffer, clearArray: true);
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.clearArray: true zeroes the array before it goes back into the pool, closing that gap.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.
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.
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.
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).
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.
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.
T[] buffer, allocated and discarded repeatedly, on a genuinely hot path (high call frequency, or per-request in a high-throughput service)Stream, a socket, a custom parser) naturally works in terms of plain arrays or Span<T>/Memory<T> over arraysMemory<T>/IMemoryOwner<T>-based pooling instead of a raw array — that's MemoryPool<T>, the next lessonArrayPool<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.
finally — or the pool never sees it again.ArrayPool<T> stops helping you at all.
ArrayPool<T>.Shared.Rent(minimumLength) / .Return(array) let you reuse arrays instead of repeatedly allocating and discarding them — directly reducing GC pressure on hot paths, and avoiding the extra cost of non-compacted LOH churn for large buffers.Rent may return an array larger than requested — never trust array.Length; track and use your own logical/used length instead.Return — ideally in a finally, or via a small disposable wrapper — or that specific array simply never rejoins the pool. This isn't a GC-sense leak; it's a silently lost pooling benefit.clearArray: true on Return zeroes the buffer for security-sensitive data, at a real performance cost — it isn't the default because most buffers get overwritten before being read anyway.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?
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?
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?
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?
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.