Everything you loved about Span<T> — except the one restriction that makes it useless the moment you need to store it or await past it.
Try this and the compiler will stop you cold:
public class BufferHolder
{
private Span<byte> _buffer; // compile error: cannot use ref struct 'Span<byte>' as a field
}
public async Task ProcessAsync(Span<byte> data)
{
await Task.Delay(100);
Use(data); // compile error: cannot use 'data' after 'await'
}
These aren't quirky edge-case restrictions — they're the direct, unavoidable consequence of what makes Span<T> safe in the first place, as the last two lessons flagged and deferred. A ref struct can only live on the stack, tied to one synchronous method call. A class field lives on the heap for as long as the object does. An async method's local state gets moved into a heap-allocated state machine the moment it needs to survive an await. None of those destinations can give the compiler the guarantee a Span<T> needs — so the compiler simply refuses.
In this lesson, you'll meet Memory<T> and ReadOnlyMemory<T> — the heap-friendly counterparts to Span<T>/ReadOnlySpan<T>, designed specifically to be stored, passed into async methods, and held across await, then converted back into a fast Span<T> only at the moment you're ready to do real work with it.
Memory<T> represents the same idea as Span<T> — a view onto a contiguous region of memory, without owning or copying it — but packaged as an ordinary struct instead of a ref struct. That one difference in how it's declared changes everything about where it's allowed to live.
Memory<T> (in System) is an ordinary, heap-friendly struct that also represents a contiguous region of memory — typically backed by an array, though it can wrap other memory sources too. Unlike Span<T>, it is not a ref struct: it's a completely normal value type, which means it can be:
await, as part of an async method's state machineWhat it gives up in exchange is direct, fast indexing — you don't index into a Memory<T> the way you index into a Span<T>. Instead, when you're ready to actually read or write the data, you call its .Span property, which hands you back a genuine, fast Span<T> — at which point all of Span<T>'s usual restrictions apply again, for exactly as long as you're using it. ReadOnlyMemory<T> is its read-only counterpart, following precisely the same relationship ReadOnlySpan<T> has to Span<T> from the previous lesson.
Span<T>'s safety comes entirely from being a ref struct, restricted to the stack, with a lifetime tied to one uninterrupted method call. That's precisely what makes it unsuitable the moment you need memory-view-like behavior to outlive a single synchronous stretch of code — which is an extremely common requirement in real applications: an object that needs to remember a buffer for later use, a lambda that needs to capture a slice, and above all, any asynchronous operation, since await can suspend and resume execution in ways that break the "one continuous stack frame" assumption Span<T> depends on.
Memory<T> deliberately gives up the "lives only on the stack" guarantee to gain the opposite one: it can live wherever an ordinary struct can live, including the heap, for as long as needed. It still represents "a view onto memory, no copying" — it just doesn't hand you fast, direct indexing on its own. Instead, you carry a Memory<T> around through the parts of your code that need to store it or cross an await, and only convert it to a Span<T> — via .Span — at the precise, synchronous moment you're ready to actually read or write bytes quickly. That conversion is cheap and the resulting Span<T> is just as fast as one created any other way; it's simply scoped narrowly, to only the safe, synchronous window where a ref struct is allowed to exist.
ref struct — stack-onlyawait.Span firstawaitThink of it as two tools for two different jobs on the same underlying idea: Memory<T> is what you hold onto while you're not actively working with the data; Span<T> is what you switch to for the brief, synchronous moment you actually are.
byte[] buffer = new byte[4096];
Memory<byte> memory = buffer.AsMemory(); // no copy — same idea as AsSpan()
public class PendingWrite
{
public Memory<byte> Data { get; } // perfectly legal — Memory<T> is not a ref struct
public PendingWrite(Memory<byte> data) => Data = data;
}
Span<byte> — completely fine with Memory<byte>void WriteHeader(Memory<byte> memory)
{
Span<byte> span = memory.Span; // fast, direct access — scoped to this synchronous method
span[0] = 0xFF;
span[1] = 0x01;
}
.Span is cheap — it's not a copy, just a conversion back to the fast viewSpan<byte> is subject to the usual ref struct rules again — this method can use it freely, but can't store it or hand it across an awaitpublic async Task<int> SumAfterDelayAsync(Memory<int> numbers)
{
await Task.Delay(50); // suspension point — a Span<int> parameter would not compile here
// Now that we're past the await, convert to a Span<int> for the actual work
Span<int> span = numbers.Span;
int sum = 0;
foreach (int n in span)
sum += n;
return sum;
}
int[] values = [1, 2, 3, 4, 5];
int total = await SumAfterDelayAsync(values.AsMemory());
Console.WriteLine(total); // 15
What's happening: numbers is a Memory<int> parameter, so it survives the await Task.Delay(50) without any trouble — the method's compiler-generated state machine simply stores it as a field, exactly like any other ordinary struct or reference held across a suspension. Only after resuming does the method convert it to a Span<int> via .Span, and only for the brief, fully synchronous loop that follows.
You've called this method throughout Advanced Part IV without necessarily connecting it to this lesson: Stream.ReadAsync accepts a Memory<byte>, not a Span<byte>:
public abstract class Stream
{
// Note the parameter type — Memory<byte>, not Span<byte>
public virtual ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default);
}
async Task ReadFileAsync(Stream fileStream)
{
byte[] buffer = new byte[4096];
int bytesRead = await fileStream.ReadAsync(buffer.AsMemory());
Span<byte> validData = buffer.AsSpan(0, bytesRead); // now do fast, synchronous work
Process(validData);
}
Now the design makes complete sense: ReadAsync is, by definition, an operation that suspends and resumes — that's the entire point of it being async. If ReadAsync had accepted a Span<byte> instead, the method could never actually be asynchronous in any useful way, because a Span<byte> parameter cannot legally survive the suspension an await inside the method would require. Memory<byte> is the only choice that lets an I/O API be genuinely asynchronous while still describing "here's a buffer to fill" in span-like, zero-copy terms.
A Span<T> is like actually holding the item in your hands — you can use it immediately, but you can't put it in a locker and walk away, because you're physically holding it. A Memory<T> is like a claim ticket for that same item: you can put the ticket in your pocket, hand it to someone else, carry it across a lunch break (an await) — and whenever you're ready to actually use the item, you hand the ticket to the counter (call .Span) and get the real, usable thing back in your hands for as long as you need it right then.
The ticket itself isn't useful for doing the actual work — you can't write with a claim ticket — but it's exactly what you need for the part of the process where you're not actively using the item: storage, transport, waiting.
Span<T> lesson, a ref struct can only ever exist on the stack — the compiler enforces this so that its internal reference (which may point at stack memory, pinned memory, or similar) can never outlive the memory it targets.await — is a direct, necessary consequence of that one stack-only rule, not a separate set of arbitrary limitations. The full rule set is covered in its own dedicated lesson later in this Part.Memory<T> is declared as an ordinary struct — internally, it typically holds a reference to the backing array (or a similar memory-owning object) plus a start index and length, none of which require the stack-only guarantee a raw interior pointer would..Span on a Memory<T> constructs a Span<T> pointing at the same underlying data — a cheap, direct operation, not a copy.ref struct, so it immediately picks back up every one of Span<T>'s restrictions — which is exactly why you call .Span as late as possible, right before the synchronous work, rather than once at the top of a method and holding onto the result.They're not competing for the same job. Span<T> is for the moment you're actually reading or writing data, synchronously. Memory<T> is for the moment you're not — storing, passing across an await, capturing. You typically use both together in the same piece of code: Memory<T> to carry the reference, Span<T> to do the work.
You'd lose exactly the benefit that made this whole module worth learning: Memory<T> doesn't support fast, direct indexing the way Span<T> does — every access effectively has to go through .Span first. For a hot synchronous loop, working directly with a Span<T> the whole time is both simpler and faster; reach for Memory<T> specifically at the boundary where storage or async is genuinely required.
No — exactly like slicing a Span<T>, converting a Memory<T> to a Span<T> via .Span never copies anything. It's a cheap construction of a new view over the exact same underlying memory the Memory<T> was already referencing.
Writing async Task ProcessAsync(Span<byte> data) and being confused when it won't compile the moment the method body contains an await.
If a method is genuinely async and needs the buffer both before and after an await, its parameter has to be Memory<byte> (or ReadOnlyMemory<byte>) — convert to .Span only in the fully synchronous portions.
async Task BadAsync(Memory<byte> memory)
{
Span<byte> span = memory.Span;
await Task.Delay(10); // compile error: 'span' is used after this await
span[0] = 1;
}
Don't call .Span until after every await that comes before the work you need it for — the compiler will catch this anyway, but understanding why makes the error obvious on sight instead of confusing.
Using Memory<T> parameters throughout a codebase "for consistency," even in purely synchronous helper methods that never store or capture the data.
Default to Span<T>/ReadOnlySpan<T> for ordinary synchronous methods — they're simpler, and every caller with a Memory<T> can trivially pass .Span to one anyway. Reach for Memory<T> specifically at genuine storage or async boundaries.
async method or type that must survive an awaitMemory<T> is the safer, more flexible default for such surfacesSpan<T>/ReadOnlySpan<T> directly, it's simpler and faster to work with.Span once, at the top, and work with thatasync, or the value needs to live in a field, reach for Memory<T>. If you're doing the actual, synchronous byte-pushing work right now, reach for Span<T> — usually by calling .Span on a Memory<T> you already have.
await?" If yes, Memory<T>. If it's all happening right now, synchronously, Span<T>.
Memory<T>/ReadOnlyMemory<T> are ordinary structs — not ref struct — so they can be stored in fields, captured, and held across an await, exactly where Span<T>/ReadOnlySpan<T> cannot go..Span — cheap, no copying, but subject to ref struct rules again from that point forward.Stream.ReadAsync accept Memory<byte> rather than Span<byte> — a genuinely asynchronous operation must be able to suspend and resume, which a Span<T> parameter cannot survive.Memory<T> through storage and async boundaries; convert to Span<T> right before doing the actual synchronous work, as late as possible.You've seen why Memory<T> exists and how it partners with Span<T>. Let's confirm the distinction is solid.
1. What is the fundamental reason Memory<T> can be stored as a field of a class, while Span<T> cannot?
Correct: B
Why B is correct: Span<T>'s safety comes entirely from being a ref struct, confined to the stack. Memory<T> is deliberately declared as an ordinary struct instead, which is exactly why it can be stored anywhere an ordinary struct can — including class fields.
Why A is incorrect: Speed isn't the deciding factor here — it's a structural/safety distinction about where each type is legally allowed to exist.
Why C is incorrect: Both Span<T> and Memory<T> work with the same range of element types — the restriction discussed here has nothing to do with value vs. reference types as T.
Why D is incorrect: Memory<T> doesn't copy any data — it's still a zero-copy view, just one built without the stack-only restriction.
Reinforcement: The ref struct vs. ordinary struct distinction is the entire reason these two types exist side by side, each suited to a different kind of lifetime.
2. Why does Stream.ReadAsync accept a Memory<byte> parameter instead of a Span<byte>?
Correct: B
Why B is correct: An async method that genuinely performs I/O needs to be able to suspend at an await and resume later. A Span<byte> parameter cannot survive that suspension, so a truly asynchronous ReadAsync could never accept one — Memory<byte> is the only option that supports both the async lifetime and zero-copy buffer access.
Why A is incorrect: Once you call .Span on a Memory<byte>, the resulting access is just as fast as a native Span<byte> — this isn't a speed difference, it's a lifetime/safety one.
Why C is incorrect: Span<byte> represents byte arrays (and slices of them) perfectly well — the issue is specifically about surviving an async suspension, not representational capability.
Why D is incorrect: This is a deliberate, load-bearing design decision directly tied to what ref struct restrictions make possible — not an accident of history.
Reinforcement: Any time you see an async API accept Memory<T> where you might expect Span<T>, it's because that method's whole point is to suspend and resume — exactly the situation Span<T> cannot survive.
3. What does calling .Span on a Memory<byte> value actually do?
Correct: B
Why B is correct: Just like slicing a Span<T>, converting via .Span never copies data — it builds a new view (a Span<byte>) over the same memory the Memory<byte> already pointed to, cheaply and directly.
Why A is incorrect: No allocation or copying happens — this would defeat the entire zero-copy premise both types share.
Why C is incorrect: The original Memory<byte> is completely unaffected and remains fully usable — .Span is a read-only, repeatable conversion, not a one-time consuming operation.
Why D is incorrect: Calling .Span multiple times on the same Memory<byte> is perfectly normal and safe — each call just constructs another view over the same data.
Reinforcement: .Span is the cheap, repeatable bridge from the "can be stored anywhere" world of Memory<T> back to the "fast, direct access" world of Span<T>.
4. A method calls Span<byte> span = memory.Span; and then, later in the same method, hits an await before using span again. What happens?
Correct: B
Why B is correct: Once you call .Span, you have a genuine Span<byte> again, with all the same ref struct restrictions as always — including the ban on being used across an await. The compiler catches this at compile time, which is exactly why you should call .Span as late as possible, after any awaits that come before the work you actually need it for.
Why A is incorrect: There's no automatic conversion back to Memory<T> — the compiler simply refuses to let the code compile in the first place.
Why C is incorrect: This is caught as a compile-time error, well before the program would ever run.
Why D is incorrect: The code never reaches runtime in this state — the compiler rejects it outright, precisely to prevent this kind of undefined-behavior scenario from ever occurring.
Reinforcement: Getting .Span doesn't grant it any special exemption — it's an ordinary Span<T> from that point on, with every one of the usual restrictions in full force.
You now know exactly when to reach for Memory<T> versus Span<T> — and why async I/O in .NET is shaped the way it is. Next: how to avoid allocating the buffers themselves in the first place, with ArrayPool<T>.
dotnetmadeeasy.com — Learn C# and .NET, the right way.