← Open in the full interactive course (progress tracking, search & more)

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.

What Is It?

The Simple Explanation

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.

The Technical Definition

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:

What 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.

Why Does It Exist?

The Problem — the exact thing Span<T> genuinely cannot do

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.

The Solution

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.

Big Picture

Span<T> / ReadOnlySpan<T>

Memory<T> / ReadOnlyMemory<T>

Think 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.

How It Works

STORE AS Memory<T>, WORK AS Span<T>
1. CREATE A Memory<T> FROM AN ARRAY
byte[] buffer = new byte[4096];
Memory<byte> memory = buffer.AsMemory(); // no copy — same idea as AsSpan()
2. STORE IT — IN A FIELD, ACROSS AN AWAIT, WHEREVER YOU NEED
public class PendingWrite
{
    public Memory<byte> Data { get; } // perfectly legal — Memory<T> is not a ref struct
    public PendingWrite(Memory<byte> data) => Data = data;
}
3. WHEN YOU'RE READY TO ACTUALLY WORK WITH IT, CONVERT TO A SPAN
void WriteHeader(Memory<byte> memory)
{
    Span<byte> span = memory.Span; // fast, direct access — scoped to this synchronous method
    span[0] = 0xFF;
    span[1] = 0x01;
}

Simple Example

public 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.

Real-World Example — this is exactly why Stream.ReadAsync looks the way it does

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.

Analogy

A claim ticket vs. the item itself

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.

Under the Hood

WHY THE ref struct RESTRICTION IS THE WHOLE STORY
1. ref struct = STACK-ONLY, BY COMPILER DECREE
2. Memory<T> SIDESTEPS THIS BY NOT BEING A ref struct AT ALL
3. .Span IS WHERE THE TWO WORLDS MEET

Common Confusion

1. "Memory<T> is just a slower version of Span<T>"

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.

2. "I should just use Memory<T> everywhere and skip Span<T> entirely"

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.

3. "Getting .Span from a Memory<T> copies the data"

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.

Common Mistakes

Mistake 1 — Trying to make a method parameter Span<T> when it needs to be async

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.

Mistake 2 — Holding onto a .Span result across an await inside the same method

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.

Mistake 3 — Reaching for Memory<T> when a method is entirely synchronous

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.

When Should I Use It?

Reach for Memory<T> when

Skip it when

Rule of thumb: If the signature needs to be async, 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.

Mental Model

Span<T> = the tool for the moment you're working.
Memory<T> = the tool for every moment you're not.
.Span = the bridge between them, called as late as possible.

Remember: the deciding question is never "which is faster?" — it's "does this value need to survive storage, capture, or an await?" If yes, Memory<T>. If it's all happening right now, synchronously, Span<T>.

Key Takeaway


Check Your Understanding

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?

Show answer

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>?

Show answer

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?

Show answer

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?

Show answer

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.