C# Span<T> and Memory<T> in ASP.NET Core: Zero-Allocation Patterns β Enterprise Decision Guide

High-allocation code is the silent tax on enterprise ASP.NET Core APIs. Every unnecessary heap allocation feeds the garbage collector, competes with application logic for CPU time, and widens the latency tail under load. Since .NET Core 2.1, C# has shipped two low-level types β Span<T> and Memory<T> β purpose-built to let you slice, parse, and transform contiguous memory regions without allocating a single object on the heap. Understanding when to reach for each one, and when neither is the right tool, is a decision that belongs in every senior .NET developer's toolbox.
π Want implementation-ready .NET source code you can drop straight into your project? Join Coding Droplets on Patreon for exclusive tutorials, premium code samples, and early access to new content. π https://www.patreon.com/CodingDroplets
What Are Span<T> and Memory<T>?
Span<T> is a ref struct β a stack-only, contiguous view over any kind of memory: managed arrays, stack-allocated buffers, or native memory obtained through interop. Because it lives entirely on the stack, it can never be boxed, stored on the heap, or captured by a lambda. It is the most performant option for synchronous, hot-path code.
Memory<T> is the heap-compatible counterpart. It wraps the same contiguous memory regions but carries an additional indirection that lets it be stored in fields, passed across await boundaries, and used inside IAsyncEnumerable<T> pipelines. The trade-off is a small performance cost compared to Span<T> and a slightly more complex ownership model.
Both types are fundamentally read-write views, not owners of memory. Ownership β and therefore lifetime management β is a separate concern that IMemoryOwner<T> and ArrayPool<T> address.
When Should You Use Span<T>?
Use Span<T> when all three of the following are true:
1. The operation is synchronous. Span<T> cannot cross await points. If your method is async, you cannot hold a Span<T> alive across the await. Attempting to do so is a compile-time error.
2. You are on a hot path. Parsing request headers, splitting query strings, tokenising CSV rows in a background ingestion job, or slicing binary protocol frames β these are exactly the scenarios where eliminating allocations delivers measurable throughput improvements.
3. The data source is already contiguous. Span<T> works over managed arrays, stackalloc buffers, and MemoryMarshal-obtained native pointers. It does not compose across disjointed segments.
Concrete ASP.NET Core contexts where Span<T> earns its place:
- Custom middleware that inspects request paths without allocating substrings (use
AsSpan()onrequest.Path.Value) - Binary protocol parsers in gRPC custom codecs or custom WebSocket frames
- In-process string parsing for structured log ingestion pipelines
System.Text.Jsoncustom converters where you receive aReadOnlySpan<byte>for the raw UTF-8 payload
When Should You Use Memory<T>?
Use Memory<T> when the data processing spans one or more await boundaries or when you need to store the buffer reference beyond the current stack frame:
- Async I/O pipelines using
System.IO.PipelinesβPipeReader.ReadAsyncreturnsReadResult, and the buffer segment is expressed asReadOnlySequence<byte>, where individual segments are backed byMemory<byte> IAsyncEnumerable<Memory<byte>>streaming from network sockets or blob storage- Background services that read chunks from a
Stream, accumulate them, and flush to a downstream writer β all without allocating intermediatebyte[]copies - Custom
IOutputFormatterimplementations in ASP.NET Core Web API where you write to aPipeWriteracross multiple async steps
The Key Constraint: Span<T> Cannot Survive an Await
This is the single most important rule. Enterprise .NET teams that discover Span<T> sometimes over-apply it, then hit the compiler wall: "A ref struct cannot be used as a type argument" or "Cannot use ref struct type in async method." The compiler enforces this intentionally β a Span<T> pinned to a specific stack frame cannot outlive that frame, and await suspends the current frame.
The migration path: start with Span<T> at the innermost synchronous parsing layer, convert to Memory<T> at the boundary where async begins. This pattern β synchronous slice with Span<T>, async hand-off with Memory<T> β is exactly how System.IO.Pipelines is architected internally in Kestrel.
ArrayPool<T> and IMemoryOwner<T>: The Ownership Layer
Neither Span<T> nor Memory<T> owns the underlying buffer. When you need to rent a temporary buffer from a pool, use ArrayPool<T>.Shared.Rent(minimumLength) for short-lived synchronous work, or MemoryPool<T>.Shared.Rent() for async scenarios that require IMemoryOwner<T> β which implements IDisposable and returns the buffer to the pool on Dispose.
Failing to return rented arrays is the most common production mistake teams make when adopting these types. A rented byte[] that escapes its using block becomes a memory leak disguised as "improved performance." Always pair rentals with a try/finally or a using statement.
In enterprise APIs under high concurrency, ArrayPool<T> dramatically reduces GC pressure for temporary buffers: instead of allocating a new byte[8192] per request (which becomes a Gen 1 survivor after a single LOH threshold crossing), you amortise the allocation cost across thousands of requests.
What Is the Best Way to Handle Zero-Allocation Parsing in ASP.NET Core?
For synchronous parsers that don't need to cross async boundaries, Span<T> with SequenceReader<T> or MemoryMarshal gives the lowest possible allocation profile. For async pipelines, System.IO.Pipelines with PipeReader/PipeWriter is the production-hardened answer β it is what Kestrel itself uses to parse HTTP/1.1 and HTTP/2 frames with near-zero allocations per request. For most application-layer parsing (not framework-layer), Memory<T> with a rented ArrayPool<T> buffer strikes the right balance between performance and code maintainability.
Decision Matrix
| Scenario | Use Span<T> | Use Memory<T> | Use ArrayPool<T> |
|---|---|---|---|
| Synchronous hot-path parser | β | β (overhead not needed) | β (rent the source buffer) |
| Async I/O pipeline | β (cannot await) | β | β |
| Store in a class field | β (ref struct) | β | β |
| Pass to generic type parameter | β | β | β |
| Stack-allocated buffer | β (stackalloc) | β | β |
| Large temporary buffer (>85KB) | β | β | β (avoid LOH) |
| System.IO.Pipelines | Via GetSpan() / GetMemory() | β | Internal to Pipelines |
Anti-Patterns to Avoid
1. Using Span<T> as a return type for public API methods. Callers cannot store it. Use Memory<T> or ReadOnlyMemory<T> if the caller needs to hold the slice.
2. Forgetting to call Advance after GetSpan / GetMemory on a PipeWriter. Failing to advance commits zero bytes and silently discards your write.
3. Slicing beyond the rented buffer length. ArrayPool<T>.Rent returns an array that is at least the requested size, often larger. Slice explicitly to the logical length, not the rented length.
4. Using these types in hot-reload-sensitive development workflows without profiler validation. The performance gains are real, but they only matter at scale. Profile first β using BenchmarkDotNet and the .NET Memory Allocations profiler β before introducing this complexity into a team codebase.
5. Mixing ReadOnlySpan<T> and Span<T> carelessly in parsing loops. ReadOnlySpan<T> prevents writes to the source; if downstream logic inadvertently needs to mutate the buffer (e.g., in-place UTF-8 lowercasing), you will hit a runtime constraint that is not always obvious from the method signatures.
For background on ASP.NET Core's request processing pipeline where these types surface naturally, see our guide on ASP.NET Core Middleware vs Action Filters vs Endpoint Filters. For caching patterns that reduce the volume of data these types need to parse repeatedly, see ASP.NET Core Response Compression: Enterprise Decision Guide.
External Authority Links:
- Memory<T> and Span<T> usage guidelines β Microsoft Docs
- System.IO.Pipelines documentation β Microsoft Docs
β Prefer a one-time tip? Buy us a coffee β every bit helps keep the content coming!
Frequently Asked Questions
What is the difference between Span<T> and Memory<T> in C#?
Span<T> is a stack-only ref struct that provides zero-overhead access to contiguous memory regions. It cannot be stored in fields or used across await boundaries. Memory<T> is the heap-compatible alternative that adds a thin indirection layer, enabling async usage, field storage, and generic type parameter compatibility at a small performance cost.
Can I use Span<T> in async methods in ASP.NET Core?
No. Span<T> is a ref struct and cannot be used across await suspension points. The compiler enforces this restriction. For async methods that need to work with buffer slices, use Memory<T> or ReadOnlyMemory<T> instead.
When should an enterprise team adopt Span<T> in ASP.NET Core APIs? When profiling identifies hot-path allocation pressure in synchronous parsing, serialisation, or string-handling code. Adoption makes sense in custom middleware, binary protocol parsers, and high-throughput data ingestion services. Avoid introducing it speculatively β the code complexity is only justified when allocation reduction produces measurable latency or throughput improvements.
What is ArrayPool<T> and how does it relate to Span<T> and Memory<T>?
ArrayPool<T> is a thread-safe pool of reusable arrays that eliminates repeated heap allocations for temporary buffers. Span<T> and Memory<T> are views over memory β they do not own the underlying array. ArrayPool<T> provides the owned, pooled array that you then wrap in a Span<T> or Memory<T> slice. Always return rented arrays via ArrayPool<T>.Shared.Return or IMemoryOwner<T>.Dispose() to avoid leaks.
How does System.IO.Pipelines relate to Span<T> and Memory<T> in ASP.NET Core?
System.IO.Pipelines is built on Memory<byte> and exposes data to application code via ReadOnlySequence<byte>, whose segments are backed by Memory<byte>. The PipeWriter API exposes GetSpan and GetMemory for writing, bridging the synchronous and async worlds. Kestrel uses Pipelines internally to parse HTTP requests with near-zero per-request allocations.
Is ReadOnlySpan<T> different from Span<T>?
Yes. ReadOnlySpan<T> is the immutable variant β you cannot write through it. Use ReadOnlySpan<T> when passing data to parsers or comparers that must not modify the source, and Span<T> when you need in-place mutation (e.g., encoding transformations, byte-swapping, compression preprocessing). Prefer ReadOnlySpan<T> for inputs in public API signatures to make intent explicit.
Does using Span<T> and Memory<T> make debugging harder in enterprise teams?
It can. Stack-only ref structs do not show up in heap dumps, and their lifetime is tied to stack frames rather than object graphs. Teams should invest in BenchmarkDotNet micro-benchmarks and the dotnet-trace / dotnet-counters toolchain to validate allocation improvements before and after adoption, and document the intent of pooled-buffer usage patterns in code reviews.


