← Back to Blog
Series· Part 2 of 2

Zero-Allocation C#

Zero-Allocation C#, Part 2 — Memory<T>: When a Buffer Has to Survive an await

July 8, 2026

Zero-Allocation C#, Part 2 — Memory<T>: When a Buffer Has to Survive an await

This is part 2 of a two-part series on zero-allocation C#. Part 1 covered Span<T> and the synchronous hot path. This part picks up exactly where that left off: the moment Span<T> stops working.


Where the synchronous trick runs out

At the end of Part 1, I said Span<T> buys its speed with one promise: it never leaves the stack. That promise is also its ceiling.

Think about where buffer-heavy code actually lives in a real service. It's almost never a tight synchronous loop — it's I/O. Reading an HTTP request body, pulling bytes off a socket, streaming a file, draining a queue. Every bit of it is async, because blocking a thread on I/O is the cardinal sin of a scalable server. And every bit of it wants the same thing the synchronous hot path wanted: a reusable buffer you can slice into without allocating on each call.

So you reach for the tool from Part 1 — you make a Span over your buffer, start the read, and then use it:

async Task<int> CountAsync(Stream stream)
{
    var data = new byte[8];
    Span<byte> view = data;                    // a Span over the buffer
    int read = await stream.ReadAsync(data);   // await the read...
    return Count(view[..read]);                // ...then use the Span
}

And the compiler stops you:

Instance of type System.Span<byte> cannot be preserved across an await or yield boundary. — CS4007

view is declared before the await and used after it, so it would have to survive the suspension — and surviving a suspension is the one thing a Span can't do. The exact place you most want a cheap, reusable buffer — async I/O, where the buffer lives across every read — is the one place Span<T> refuses to go. So what do you do?

(If you last tried this a year or two ago, the wall was in a different spot: on .NET 8 / C# 12 the compiler wouldn't let you declare a Span local in an async method at all. C# 13 relaxed that — spans are welcome in async methods now, as long as no single span is alive across an await. Good news for synchronous stretches; no help at all for a buffer that must persist across the read.)

Why a Span can't cross an await

The error makes sense once you know what the compiler does to an async method. It rewrites it into a state machine, and every local that has to survive an await becomes a field on that state machine — an object that lives on the heap.

A ref struct like Span<T> is, by definition, a type that can never be a field of a class and can never sit on the heap. That's its one guarantee, and it's exactly what makes a Span free. So a Span that would need to persist across an await can't be turned into a state-machine field — the compiler has nowhere to put it, and it stops you.

The key word is persist. A Span you create and finish using between two awaits is fine — it never has to be saved anywhere. The problem is only a Span whose lifetime straddles the suspension point. And a reusable I/O buffer is precisely that: you fill it, await, read from it, await again. It has to live across the awaits, so it can't be a Span.

That gap is the entire reason Memory<T> is in the framework.

The concept: Memory<T> holds, Span<T> works

Memory<T> is the heap-friendly sibling of Span<T>. It represents the same idea — a window over a contiguous block of memory you already have — but it's an ordinary struct, not a ref struct. That one difference changes everything about where it's allowed to live:

Span<T>Memory<T>
Lives on the stack only❌ (heap-OK)
Can be a field / array element
Survives an await
Indexable, sliceable
Has the fast span operations directlyvia .Span

The pattern that falls out of this is the one to memorize: carry the buffer across the await as Memory<T>, then call .Span to do the actual work in a synchronous spot.

static async Task<int> CountNewlinesAsync(Stream stream)
{
    var backing = new byte[8];
    Memory<byte> mem = backing;                   // Memory, not Span
    int total = 0, read;
    while ((read = await stream.ReadAsync(mem)) > 0)
        total += CountNewlines(mem.Span[..read]); // hand the Span to a sync method
    return total;
}

static int CountNewlines(ReadOnlySpan<byte> span)
{
    int n = 0;
    foreach (byte b in span)
        if (b == (byte)'\n') n++;
    return n;
}

Stream.ReadAsync takes a Memory<byte> precisely because it has to hold that buffer across the await. When the read completes and we're back on synchronous ground, .Span gives us the fast, zero-allocation view to scan. The expensive per-byte work happens through a Span; the await happens through a Memory. (For read-only buffers there's ReadOnlyMemory<T>, the immutable counterpart you'll see on APIs like WriteAsync.)

The trap: Memory<T> is a view, not an owner

This is the part that turns into a 2 a.m. production bug. Memory<T> does not own the memory it points at — it's just a window, exactly like Span<T>. If the thing backing it goes away while async work is still in flight, you have a dangling view over memory that may have been reused for something else.

The classic version of this bug is pairing Memory<T> with ArrayPool:

// ❌ subtle corruption
var rented = ArrayPool<byte>.Shared.Rent(4096);
Memory<byte> mem = rented;
Task work = ProcessAsync(mem);          // still running...
ArrayPool<byte>.Shared.Return(rented);  // ...but we just gave the array back
await work;                              // mem now points at a recycled buffer

You returned the array to the pool while ProcessAsync was still holding a Memory over it. Another part of your program rents that same array, writes to it, and now your in-flight read is reading someone else's data. No exception — just wrong answers, intermittently, under load.

The fix is to make ownership explicit and tie it to a lifetime. MemoryPool<T> hands you an IMemoryOwner<T> whose using scope is the buffer's lifetime, so the rental is only returned after every await has completed:

static async Task<int> CountNewlinesPooledAsync(Stream stream)
{
    using IMemoryOwner<byte> owner = MemoryPool<byte>.Shared.Rent(4096);
    Memory<byte> mem = owner.Memory;
    int total = 0, read;
    while ((read = await stream.ReadAsync(mem)) > 0)
        total += CountNewlines(mem.Span[..read]);
    return total;   // `using` returns the buffer here — after all the awaits
}

The rule of thumb: a Memory<T> must never outlive its backing store. When the backing store is pooled, let an IMemoryOwner<T> and a using block draw that boundary for you.

Takeaway: the whole relationship in one line

  • Span<T> is for working on a buffer; Memory<T> is for holding one across an await.
  • Span can't go async because a ref struct can never become a state-machine field — that's the guarantee, not a bug.
  • Keep the async surface in Memory<T>, drop to .Span at the synchronous point of use, and the per-element work stays zero-allocation.
  • Memory<T> borrows; it doesn't own. Pair it with IMemoryOwner<T>/MemoryPool<T> so the buffer's lifetime can't end while async work still needs it.

Together with Part 1, that's the full picture: Span<T> for synchronous hot paths, Memory<T> for the asynchronous ones, and a shared set of rules that all trace back to a single promise — this memory never copies, and it never outlives what it points at.

One question for you: Have you ever shipped the ArrayPool + Memory dangling-buffer bug — the kind that only shows up under concurrency — and how did you finally catch it?


More in “Zero-Allocation C#