Memory<T>/ReadOnlyMemory<T>: heap-storable buffers for async code, ownership and API usage.
Memory<T> and ReadOnlyMemory<T> are ordinary structs (not ref structs) that describe a slice of an array, a string (read-only), or memory from a MemoryManager<T>. Because they never point at the stack, they can be stored in fields, captured in lambdas and used across await, which spans cannot. You call .Span in synchronous code to do the actual work. Slicing is cheap and does not copy. MemoryMarshal.TryGetArray gets the underlying ArraySegment<T> for older APIs. Memory does not own anything: whoever rented or allocated the buffer owns it, so a Memory<T> over a pooled array is invalid once that array is returned. Async I/O APIs (Stream.ReadAsync(Memory<byte>), Socket, PipeReader, JsonSerializer with UTF-8) use them.
Memory<T> and ReadOnlyMemory<T> represent the same kind of contiguous region as spans, but they're ordinary structs, not ref structs — so they can be stored on the heap, kept in fields, captured by lambdas and passed across await. When you need to work with the data, you get a span via .Span. I'd explain why they exist, .Span, slicing, MemoryMarshal.TryGetArray, ownership and lifetime rules, and where they appear in Stream, PipeReader and System.Text.Json.
awaitSpan<T> holds a managed pointer that may point to the stack, so it must stay on the stack. Memory<T> instead holds an object reference (an array, a string, or a MemoryManager<T>) plus an index and length — nothing that can dangle into a stack frame. So it can be:
public sealed class Message(ReadOnlyMemory<byte> body) { public ReadOnlyMemory<byte> Body { get; } = body; } // field: OK
public async Task<int> ReadIntoAsync(Stream stream, Memory<byte> buffer, CancellationToken ct)
{
int read = await stream.ReadAsync(buffer, ct); // Memory<T> crosses await
Process(buffer.Span[..read]); // span used synchronously
return read;
}
.Span worksmemory.Span inspects the underlying object:
T[] → span over the array segment,string (for ReadOnlyMemory<char>) → span over the string's chars,MemoryManager<T> → calls its GetSpan() (native memory, pinned buffers, custom pools).This involves a type check and a few instructions — cheap, but not free: in loops, get the span once (var span = memory.Span;) and index the span.
ReadOnlyMemory<byte> packet = buffer.AsMemory(0, length);
ReadOnlyMemory<byte> header = packet[..8];
ReadOnlyMemory<byte> payload = packet.Slice(8);
Slicing is O(1) and allocation-free (adjusts index/length), like spans.
MemoryMarshal.TryGetArraySome older APIs need an ArraySegment<T>/array:
if (MemoryMarshal.TryGetArray(memory, out ArraySegment<byte> segment))
legacySocket.Send(segment.Array!, segment.Offset, segment.Count);
else
legacySocket.Send(memory.ToArray()); // fallback copy (e.g. native memory)
TryGetArray succeeds only for array-backed memory. MemoryMarshal.TryGetString does the same for string-backed ReadOnlyMemory<char>. Never assume the backing store is an array.
Memory<T> doesn't own the memory — it's a view. The important questions are who owns the buffer and how long the view is valid:
IMemoryOwner<T> from MemoryPool<T>, an ArrayPool rental, a PipeReader buffer) controls lifetime; when it's disposed/returned, every Memory<T>/Span<T> over it becomes invalid (but still "works" — reading reused or freed memory: silent corruption).Memory<T> borrows it for the duration of the call; it mustn't store it beyond that unless the API documents ownership transfer.Memory<T>, the caller must guarantee the buffer outlives your use (or transfer ownership via IMemoryOwner<T>).IMemoryOwner<T> must dispose it exactly once, after all consumers are done.PipeReader, the ReadOnlySequence<byte> from ReadAsync is valid only until AdvanceTo is called.Stream: ReadAsync(Memory<byte>, CancellationToken) and WriteAsync(ReadOnlyMemory<byte>, CancellationToken) (returning ValueTask), the efficient async overloads.Socket.ReceiveAsync(Memory<byte>, ...).System.IO.Pipelines: PipeWriter.GetMemory(sizeHint) returns Memory<byte> to write into, then Advance(n) and FlushAsync; PipeReader yields ReadOnlySequence<byte> (a chain of ReadOnlyMemory<byte> segments).System.Text.Json: JsonSerializer.Deserialize<T>(ReadOnlySpan<byte>), Utf8JsonReader over ReadOnlySequence<byte>, JsonDocument.Parse(ReadOnlyMemory<byte>) (keeps a reference to the memory, so the buffer must stay valid while the document is used).StringBuilder.GetChunks() yields ReadOnlyMemory<char> chunks.Channel<ReadOnlyMemory<byte>> for passing buffers between producers and consumers (with care for ownership).A TCP gateway read frames into ArrayPool buffers and passed ReadOnlyMemory<byte> slices to a channel for async processing — but returned the buffer to the pool immediately after enqueuing. Consumers occasionally processed data from other frames (the buffer had been reused), producing corrupted messages. We changed the contract to pass IMemoryOwner<byte> (ownership transfer) through the channel; the consumer disposed it after processing. Corruption disappeared, and the ownership rule was documented on the channel's message type.
The interviewer checks whether you know when to use memory types and understand buffer ownership.
Green flags
.Span for work.TryGetArray and the Stream/Socket/Pipelines APIs.IMemoryOwner<T>.Red flags
public readonly struct Memory<T>
{
private readonly object? _object; // T[], string (for char), or MemoryManager<T>
private readonly int _index; // may encode flags in high bits (e.g. pre-pinned)
private readonly int _length;
}
16 bytes on 64-bit. Because _object is a normal reference, the GC tracks it like any field.
MemoryManager<T>Abstract class for custom memory (native memory, memory-mapped files, pinned pools): implements GetSpan(), Pin()/Unpin() (returning a MemoryHandle with a pointer), and Dispose. Memory<T>.Pin() pins array-backed memory (via GCHandle) or delegates to the manager — used for async I/O with native APIs.
ReadOnlySequence<T>Represents possibly non-contiguous data as a linked list of ReadOnlyMemory<T> segments (ReadOnlySequenceSegment<T>). Pipelines produce these; parsers use SequenceReader<T> to read across segment boundaries without copying. IsSingleSegment + FirstSpan gives a fast path.
Stream.ReadAsync(Memory<byte>) returns ValueTask<int>: when data is already buffered, it completes synchronously without allocating a Task. Prefer these overloads over ReadAsync(byte[], int, int).
| Situation | Use |
|---|---|
| Synchronous processing | Span<T>/ReadOnlySpan<T> |
Crossing await, stored in fields, captured |
Memory<T>/ReadOnlyMemory<T> |
| Ownership transfer | IMemoryOwner<T> |
| Non-contiguous buffers | ReadOnlySequence<T> |
API design: async methods take Memory<T>; their synchronous helpers take spans.
ReadOnlyMemory<char> slice of a string in a class and read it later via .Span.MemoryMarshal.TryGetArray on array-backed and MemoryPool-backed memory.PipeReader, ReadOnlySequence<byte> and AdvanceTo.ArrayPool and a stored Memory<T>, then fix with IMemoryOwner<T>.using System.Buffers;
using System.Runtime.InteropServices;
public sealed class Chunk(ReadOnlyMemory<byte> data) // Memory can be a field; Span cannot
{
public ReadOnlyMemory<byte> Data { get; } = data;
public int Checksum()
{
int sum = 0;
foreach (byte b in Data.Span) sum = unchecked(sum * 31 + b); // .Span for synchronous work
return sum;
}
}
public static class Program
{
public static async Task Main()
{
using var stream = new MemoryStream(Enumerable.Range(0, 10_000).Select(i => (byte)i).ToArray());
byte[] rented = ArrayPool<byte>.Shared.Rent(4096); // the renter owns this array
try
{
Memory<byte> buffer = rented.AsMemory(0, 4096);
var chunks = new List<Chunk>();
int read;
while ((read = await stream.ReadAsync(buffer)) > 0) // Memory crosses await
{
// Copy before keeping: the pooled buffer is reused on the next read
chunks.Add(new Chunk(buffer[..read].ToArray()));
}
Console.WriteLine($"{chunks.Count} chunks, first checksum {chunks[0].Checksum()}");
if (MemoryMarshal.TryGetArray<byte>(buffer, out ArraySegment<byte> segment))
Console.WriteLine($"array-backed: offset {segment.Offset}, count {segment.Count}");
}
finally
{
ArrayPool<byte>.Shared.Return(rented); // after this, 'buffer' must not be used
}
string text = "NSW,VIC,QLD,WA";
ReadOnlyMemory<char> region = text.AsMemory(4, 3); // slice of a string, no copy
Console.WriteLine(region.ToString()); // VIC
}
}
Memory<T> after the buffer is returnedWhat happens: storing slices of pooled buffers beyond their lifetime.
Why it is wrong: silent data corruption.
Do instead: clear ownership rules; transfer IMemoryOwner<T> when needed.
.Span in tight loopsWhat happens: memory.Span[i] inside loops.
Why it is wrong: repeated type checks.
Do instead: get the span once.
What happens: casting or reflecting to get the array.
Why it is wrong: memory may be native or string-backed.
Do instead: MemoryMarshal.TryGetArray with a fallback.
PipeReader buffers after AdvanceToWhat happens: keeping slices of the read sequence. Why it is wrong: the pipe may reuse the memory. Do instead: copy data you need to keep before advancing.
What happens: trying async Task Foo(Span<byte> buffer).
Why it is wrong: not allowed.
Do instead: take Memory<byte> and use .Span synchronously.
ToArray() everywhereWhat happens: converting memory to arrays to call APIs. Why it is wrong: copies and allocations. Do instead: use memory/span overloads.
IMemoryOwner<T>What happens: rented memory never returned.
Why it is wrong: pool depletion and more allocations.
Do instead: using the owner.
JsonDocument over transient buffersWhat happens: parsing a pooled buffer and returning it while the document is still used. Why it is wrong: the document references the buffer. Do instead: keep the buffer alive until the document is disposed, or deserialise to objects.
Memory types are where performance code meets ownership design. The type system does not track who owns a buffer, so the team has to, through conventions and types:
| Intention | Type to pass |
|---|---|
| "Use this during the call" | ReadOnlySpan<T> / Span<T> |
| "Use this until the returned task completes" | ReadOnlyMemory<T> / Memory<T> |
| "You own this now and must release it" | IMemoryOwner<T> |
| "Keep a copy" | an array or immutable collection |
A senior engineer documents these rules on public APIs and is suspicious of any Memory<T> stored in a field next to an ArrayPool call. Most pooled-buffer production bugs are lifetime bugs, not performance bugs.
Span<T> is a ref struct holding a reference and a length: a typed, bounds-checked view over contiguous memory, whether that memory is an array, a stackalloc buffer, native memory or part of a string (through ReadOnlySpan<char>). Slicing (span[2..6], Slice) creates a new view without copying, so parsers and formatters can work on pieces of a buffer with no allocations. Because it can point at the stack, it is stack-only: it cannot be a field of a class, be boxed, be captured by a lambda, or live across await/yield (C# 13 allows span locals in async and iterator methods as long as they do not cross those points). Helpers in MemoryExtensions (IndexOf, SequenceEqual, Sort, Trim) are vectorised. CollectionsMarshal.AsSpan(list) exposes a list's backing array, but adding to the list invalidates it.
MemoryPool<T> rents buffers as IMemoryOwner<T>: an IDisposable object whose .Memory property is the buffer and whose Dispose() returns it. That makes ownership explicit: whoever holds the owner must dispose it, and you can transfer it (through a channel, a queue or a method) so the receiver disposes. MemoryPool<T>.Shared is backed by ArrayPool<T>.Shared. The memory may be larger than requested, so slice to the length you use. Use it over ArrayPool<T> when buffers cross components or async boundaries or when you want to swap the memory source. System.IO.Pipelines gets segments from a MemoryPool<byte>; Kestrel uses its own pinned block pool for sockets. A custom MemoryManager<T> can expose native or pinned memory as Memory<T>.
Pooling bugs are lifetime bugs: the pool gives a returned array to the next renter, so any code still using it reads or writes someone else's data. The usual causes are use after return (returning in finally while an async continuation, a queued Memory<T> or a lazy enumerable still uses the array), double return (two renters then share one array), treating array.Length as the data length (Rent returns an array at least as large as requested, with old contents), not clearing buffers that held secrets (Return(array, clearArray: true)), and returning arrays not rented from that pool. To debug: reproduce under concurrency, add canaries or a poisoning wrapper pool in tests that fills returned arrays with a marker and detects double returns, trace System.Buffers.ArrayPoolEventSource events, and review every rent for a single owner and a single return in finally.
How to tell it (STAR)
**Situation:** our [document upload service] stored a small fraction of files with corrupt content, only under load. **Task:** as the service owner I had to find and fix it before an audit. **Action:** I added checksums at each stage, proved the data changed inside our queue, and found we returned a pooled buffer while the worker still held a Memory slice of it. I changed the queue to pass IMemoryOwner<byte> so the worker owned and released the buffer, and added a poisoning test pool. **Result:** corruption dropped to [zero] across [two million] uploads and the ownership rule is now part of our code review checklist.
Last updated 30 Oct 2026.