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 anawaitoryieldboundary. — 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 directly | ✅ | via .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 anawait.Spancan't go async because aref structcan never become a state-machine field — that's the guarantee, not a bug.- Keep the async surface in
Memory<T>, drop to.Spanat the synchronous point of use, and the per-element work stays zero-allocation. Memory<T>borrows; it doesn't own. Pair it withIMemoryOwner<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?