MemoryPool<T> in .NET: Pooling Memory Beyond Arrays
Same pooling idea as ArrayPool<T> — reshaped for a world where the memory isn't always a plain array.
The previous lesson solved a real problem: stop reallocating the same buffer over and over, rent it from a shared pool instead. But it solved it specifically for T[] — plain arrays. Recall two lessons back: Memory<T> exists precisely because not every scenario that needs a memory view can be pinned to a plain array — it might wrap native or unmanaged memory instead, memory an ordinary T[] can never represent. If your code is already working in terms of Memory<T> — because it's async, or because it's designed against an IMemoryOwner<T>-based API — ArrayPool<T>'s array-shaped contract doesn't quite fit.
In this lesson, you'll meet MemoryPool<T> — the Memory<T>-flavored counterpart to ArrayPool<T>, and you'll see how its IMemoryOwner<T> return value gives you automatic, RAII-style cleanup instead of a manual Return call.
What Is It?
The Simple Explanation
MemoryPool<T> is ArrayPool<T>'s sibling: same underlying idea — borrow reusable memory instead of allocating fresh each time — but it hands you back a Memory<T>-compatible object, wrapped in something that knows how to clean up after itself automatically.
The Technical Definition
MemoryPool<T> (in System.Buffers) is a resource pool for Memory<T>-based memory. MemoryPool<T>.Shared gives you the default, process-wide instance. Its core operation:
Rent(int minBufferSize = -1)— returns anIMemoryOwner<T>, an object that owns a rented block of memory and exposes it through a.Memoryproperty.
There's no separate Return method to call. Instead, IMemoryOwner<T> implements IDisposable — disposing it is what returns the underlying memory to the pool. That's the whole design difference from ArrayPool<T> in one sentence: ownership and cleanup are modeled explicitly, through the same IDisposable/using pattern you already use for files, connections, and every other disposable resource in .NET.
Why Does It Exist?
The Problem — Memory<T> doesn't have to be an array
Two lessons back, Memory<T> was introduced as the heap-friendly, storable counterpart to Span<T> — commonly backed by an array, but not required to be. It can also wrap native or unmanaged memory, memory obtained from platform interop, or other custom memory sources that were never a T[] to begin with. ArrayPool<T> is built specifically around lending out and reclaiming T[] instances — it has no vocabulary for pooling memory that isn't shaped like a plain array in the first place.
The Solution
MemoryPool<T> generalizes the pooling idea to whatever Memory<T> can represent, not just arrays. Its default (MemoryPool<T>.Shared) is commonly array-backed under the hood in the mainstream .NET implementation, but the abstraction itself — and any custom MemoryPool<T> implementation — isn't limited to that. Pairing it with IMemoryOwner<T> also solves a second, smaller problem: manual Return calls are easy to forget (as the previous lesson's Common Mistakes covered) — modeling ownership as a disposable object lets you lean on using to make returning automatic and much harder to skip.
Big Picture
ArrayPool<T>
- Rents a plain
T[] - Manual
Return(array)call required - You must remember to return it — easy to forget, no compiler help
- Fits code that naturally works with arrays and
Span<T>
MemoryPool<T>
- Rents an
IMemoryOwner<T>, exposing.Memory - Return happens via
Dispose()— automatic withusing - RAII-style cleanup — much harder to forget
- Fits code already built around
Memory<T>and async-friendly APIs
How It Works
using IMemoryOwner<byte> owner = MemoryPool<byte>.Shared.Rent(8192);
owneris a disposable object that owns the rented memory for as long as it's alive- Just like
ArrayPool<T>.Rent, the actual buffer may be sized somewhat differently than the exact number requested — checkowner.Memory.Lengthrather than assuming
Memory<byte> buffer = owner.Memory;
int bytesRead = await upload.ReadAsync(buffer[..8192]); // Memory<T> survives the await, as covered previously
Span<byte> span = buffer.Span[..bytesRead]; // convert to Span<T> for the fast, synchronous part
ProcessChunk(span);
- Because
ownerwas declared withusing, itsDispose()runs automatically at the end of the enclosing scope — that's what returns the rented memory to the pool - No separate
Returncall to remember, and notry/finallyto write by hand —usingalready guarantees cleanup runs on every exit path, exceptions included
Simple Example — the same upload loop, rewritten with MemoryPool<T>
async Task ProcessUploadAsync(Stream upload)
{
using IMemoryOwner<byte> owner = MemoryPool<byte>.Shared.Rent(8192);
Memory<byte> buffer = owner.Memory;
while (true)
{
int bytesRead = await upload.ReadAsync(buffer[..8192]);
if (bytesRead == 0) break;
ProcessChunk(buffer.Span[..bytesRead]);
}
// owner.Dispose() runs automatically here, returning the memory to the pool —
// no explicit Return call, no try/finally required
}
Compare this to the previous lesson's ArrayPool<T> version: the logic is identical — rent once, reuse across the loop, clean up when done — but there's no manual Return call and no explicit try/finally. The using declaration on owner handles both jobs at once, exactly the way it already handles closing a file or a database connection.
Real-World Example
MemoryPool<T> shows up specifically where code is already committed to the Memory<T>/IMemoryOwner<T> shape — often because it's crossing async boundaries or interoperating with lower-level, pipeline-style APIs:
- Custom implementations of streaming, pipeline-based APIs (in the spirit of
System.IO.Pipelines) that are built aroundMemory<T>end-to-end rather than raw arrays. - Libraries exposing buffers across an async boundary where the caller shouldn't need to know or care whether the underlying memory happens to be array-backed or something else.
- Any code where you want the compiler-enforced,
using-driven guarantee that a rented buffer is returned, rather than relying on every caller to remember a manualReturn.
For the far more common case — a plain, synchronous or simply-async buffer that's naturally a byte[] — ArrayPool<T> remains the simpler, more directly-applicable tool, which is exactly why it's typically reached for first.
Analogy
Returning a rented ArrayPool<T> array is like a library card system where you personally have to remember to bring the book back — nothing stops you from just keeping it, and the library doesn't automatically know when you're done. MemoryPool<T>'s IMemoryOwner<T> is like a hotel key card tied to a checkout date: when your stay (the using scope) ends, access is revoked and the room is returned to the pool of available rooms automatically — you don't have to personally walk to the front desk and hand the card back for the system to work correctly.
Under the Hood
- With
ArrayPool<T>, the array itself carries no information about whether it came from a pool or who's responsible for returning it — that responsibility lives entirely in your code's discipline (thetry/finallypattern from the previous lesson). IMemoryOwner<T>makes that responsibility a real, typed object: whoever holds theIMemoryOwner<T>owns the memory and is responsible for disposing it. Passing around the owner (rather than the rawMemory<T>) makes that responsibility explicit and transferable in a way plain arrays never could.
- Calling
owner.Dispose()— whether explicitly or automatically viausing— is what triggers the pool to reclaim the underlying memory, conceptually equivalent toArrayPool<T>'sReturn, just invoked through the standard .NET disposal pattern instead of a bespoke method call. - Using
owner.Memory(or aSpan<T>derived from it) after disposal is a misuse — exactly like reading from a rentedArrayPool<T>array after callingReturnon it — and should be avoided the same way: don't hold onto the memory past the owner's lifetime.
- In the mainstream .NET implementation,
MemoryPool<T>.Sharedcommonly hands out memory backed by ordinary arrays under the hood — so in practice, its everyday performance characteristics resembleArrayPool<T>closely. - The real value of the abstraction is that your code never has to know or depend on that detail — it works uniformly whether the pool's actual backing memory is a managed array or something else entirely, which is exactly the point of programming against
Memory<T>/IMemoryOwner<T>in the first place.
Common Confusion
1. "MemoryPool<T> is strictly better than ArrayPool<T>, so I should always use it"
Not quite — they serve different shapes of code. If you genuinely need a plain T[] (many APIs still expect exactly that), ArrayPool<T> gives you one directly, with no extra indirection through Memory<T>/.Span. Reach for MemoryPool<T> specifically when your code is already working in Memory<T> terms — not as a blanket upgrade.
2. "Disposing the IMemoryOwner<T> clears the memory's contents, like ArrayPool<T>'s clearArray option"
Disposal returns the memory to the pool — it doesn't, by itself, guarantee the contents are zeroed for the next renter, mirroring ArrayPool<T>'s own default (non-clearing) behavior. If leftover contents matter for your scenario, that's a concern to handle deliberately, the same way it was in the previous lesson.
3. "I need MemoryPool<T> any time I'm working with Memory<T>"
Not at all — plenty of code creates a Memory<T> from an ordinary, non-pooled array (array.AsMemory()) with no pooling involved whatsoever. MemoryPool<T> is specifically for when you want the reuse/pooling benefit and you're working in Memory<T> terms — the two ideas (using Memory<T>, and pooling) are independent of each other.
Common Mistakes
Mistake 1 — Using owner.Memory after disposing the owner
Holding onto owner.Memory (or a Span<T> derived from it) in a variable that outlives the using block that owns it.
Memory<byte> leaked;
using (IMemoryOwner<byte> owner = MemoryPool<byte>.Shared.Rent(1024))
{
leaked = owner.Memory; // still valid as a reference, but the pool now owns this memory again
} // owner disposed here — 'leaked' may be reused/overwritten by an unrelated future Rent
Never let a Memory<T> obtained from an IMemoryOwner<T> escape the scope that owns it — use it only while the owner itself is still alive and undisposed.
Mistake 2 — Reaching for MemoryPool<T> when a plain array would do
Adding MemoryPool<T> and its extra .Memory/.Span indirection to code that just needs a straightforward, synchronous byte[] buffer.
Default to ArrayPool<T> for plain array needs — it's simpler and gives you the array directly. Reach for MemoryPool<T> when the surrounding code is genuinely built around Memory<T>.
Mistake 3 — Forgetting the using declaration entirely
Calling MemoryPool<byte>.Shared.Rent(...) without assigning it to a using-declared variable — the RAII benefit only exists if you actually use the pattern.
Always pair Rent with using (either a using declaration or a using block) — that's the entire mechanism that makes automatic return work.
When Should I Use It?
Reach for MemoryPool<T> when
- Your code is already working with
Memory<T>-based or async-friendly APIs and needs pooled backing memory to match - You want disposal-driven,
using-guaranteed cleanup rather than a manually-calledReturn - You're building an abstraction that shouldn't need to assume its memory is array-backed
Reach for ArrayPool<T> when
- You need a plain
T[]— still extremely common, and often simpler when you don't needMemory<T>'s extra flexibility - The surrounding code isn't already committed to
Memory<T>— no reason to add the extra layer - You're comfortable managing the rent/return lifecycle yourself with
try/finally
ArrayPool<T> by default — it's the simpler, more directly applicable tool for the common "I just need a reusable byte array" case. Reach for MemoryPool<T> specifically when you're already committed to Memory<T>/IMemoryOwner<T> elsewhere in the same code, and want its automatic, disposal-driven cleanup.
Mental Model
T[], remember to give it back yourself.MemoryPool<T> = borrow an
IMemoryOwner<T>, giving it back is Dispose()'s job.Remember: same underlying idea — reuse instead of reallocate — different shape of API to fit different shapes of surrounding code. Pick based on whether your code already speaks
T[] or Memory<T>.
Key Takeaway
MemoryPool<T>exists becauseMemory<T>doesn't have to be array-backed, so pooling for it needed its own API, distinct from array-onlyArrayPool<T>.MemoryPool<T>.Shared.Rent()returns anIMemoryOwner<T>— anIDisposablewhoseDispose()is what returns the memory to the pool.using var owner = MemoryPool<T>.Shared.Rent(...);gives you automatic, guaranteed-on-every-exit-path cleanup — contrasted directly withArrayPool<T>'s manual, must-rememberReturncall from the previous lesson.- Reach for
MemoryPool<T>when your code is genuinely working inMemory<T>/async terms; reach forArrayPool<T>— still extremely common, and often simpler — when a plainT[]is all you actually need.
Check Your Understanding
You've seen how MemoryPool<T> relates to, and differs from, ArrayPool<T>. Let's confirm the distinction is clear.
1. What does MemoryPool<byte>.Shared.Rent(4096) actually return?
Show answer
Correct: B
Why B is correct: MemoryPool<T>.Rent returns an IMemoryOwner<T> — an object that owns the rented memory, exposes it via .Memory, and returns it to the pool automatically when its Dispose() method runs.
Why A is incorrect: That's ArrayPool<T>.Rent's return type. MemoryPool<T> deliberately returns something different, precisely to support the ownership/disposal model this lesson covers.
Why C is incorrect: Rent doesn't return a Span<byte> directly — you get an owner, then access .Memory, and from there .Span when you need fast synchronous access.
Why D is incorrect: Rent is a synchronous method that returns immediately — no Task or await is involved in obtaining the buffer itself.
Reinforcement: The return type itself is the whole design story — IMemoryOwner<T> makes ownership and cleanup explicit, disposable concepts.
2. How does a rented MemoryPool<T> buffer actually get returned to the pool?
Show answer
Correct: B
Why B is correct: Disposing the IMemoryOwner<T> — whether called explicitly or triggered automatically by a using declaration/block — is precisely what returns the underlying memory to the pool. There's no separate, manually-invoked Return method.
Why A is incorrect: This describes ArrayPool<T>'s API shape, not MemoryPool<T>'s — MemoryPool<T> deliberately uses disposal instead of a separate return method.
Why C is incorrect: Nothing happens automatically just because the enclosing method returns — you need an actual using (or explicit Dispose() call) tied to the owner's scope for the return to occur.
Why D is incorrect: The memory absolutely can and should be returned — that's the entire point of the IMemoryOwner<T>/IDisposable pattern.
Reinforcement: using declarations pair naturally with MemoryPool<T> precisely because disposal is the return mechanism.
3. You're writing a synchronous helper method that just needs a temporary byte[] scratch buffer for a few lines of code, with no async involved and no existing Memory<T>-based API in the picture. Which is the more appropriate choice?
Show answer
Correct: B
Why B is correct: When a plain array is all that's genuinely needed, and there's no surrounding Memory<T>-based design to fit into, ArrayPool<T> is the simpler, more direct choice — you get the array immediately, with no extra layer of indirection.
Why A is incorrect: Neither pool is universally "more modern" or preferred — they're suited to different shapes of code, as this lesson's comparison covers directly.
Why C is incorrect: Pooling is entirely appropriate in synchronous, hot-path code — in fact, that's a very common and effective use case for ArrayPool<T> specifically.
Why D is incorrect: Combining both adds unnecessary complexity for no benefit here — pick the one tool that matches what your code actually needs.
Reinforcement: Choosing between the two pools comes down to what shape your surrounding code already is in — plain array code reaches for ArrayPool<T>, Memory<T>-based/async code reaches for MemoryPool<T>.
You now have both pooling tools in your kit, and know exactly when each one fits. Next: a precise, deep look at ref, in, and out — the parameter-passing modifiers that make some of this module's performance techniques possible in the first place.