.net made easy[]

MemoryPool<T> in .NET: Pooling Memory Beyond Arrays

AdvancedLesson 204 of 315·Production .NET·17 min read·Updated

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:

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 with using
  • RAII-style cleanup — much harder to forget
  • Fits code already built around Memory<T> and async-friendly APIs

How It Works

RENT → USE → AUTOMATIC RETURN VIA using
1. RENT — GET AN IMemoryOwner<T>
using IMemoryOwner<byte> owner = MemoryPool<byte>.Shared.Rent(8192);
  • owner is 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 — check owner.Memory.Length rather than assuming
▼
2. USE IT VIA .Memory (AND .Span WHEN YOU NEED FAST ACCESS)
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);
▼
3. RETURN HAPPENS AUTOMATICALLY — NO EXPLICIT CALL NEEDED
  • Because owner was declared with using, its Dispose() runs automatically at the end of the enclosing scope — that's what returns the rented memory to the pool
  • No separate Return call to remember, and no try/finally to write by hand — using already 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:

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

A hotel key card vs. a library card you have to remember to return

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

OWNERSHIP AS A FIRST-CLASS, DISPOSABLE CONCEPT
1. IMemoryOwner<T> MAKES "WHO'S RESPONSIBLE FOR RETURNING THIS" EXPLICIT
  • 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 (the try/finally pattern from the previous lesson).
  • IMemoryOwner<T> makes that responsibility a real, typed object: whoever holds the IMemoryOwner<T> owns the memory and is responsible for disposing it. Passing around the owner (rather than the raw Memory<T>) makes that responsibility explicit and transferable in a way plain arrays never could.
▼
2. Dispose() IS WHERE THE ACTUAL RETURN HAPPENS
  • Calling owner.Dispose() — whether explicitly or automatically via using — is what triggers the pool to reclaim the underlying memory, conceptually equivalent to ArrayPool<T>'s Return, just invoked through the standard .NET disposal pattern instead of a bespoke method call.
  • Using owner.Memory (or a Span<T> derived from it) after disposal is a misuse — exactly like reading from a rented ArrayPool<T> array after calling Return on it — and should be avoided the same way: don't hold onto the memory past the owner's lifetime.
▼
3. THE DEFAULT SHARED POOL IS OFTEN ARRAY-BACKED — BUT THAT'S AN IMPLEMENTATION DETAIL
  • In the mainstream .NET implementation, MemoryPool<T>.Shared commonly hands out memory backed by ordinary arrays under the hood — so in practice, its everyday performance characteristics resemble ArrayPool<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-called Return
  • 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 need Memory<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
Rule of thumb: Start with 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

ArrayPool<T> = borrow a 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


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.