Span<T> and the Art of Allocation-Free C#
Most performance problems in .NET are invisible until the moment they aren't. The code is correct, the tests pass, the throughput looks fine on a developer laptop — and then, under real load, the application spends a surprising slice of its time in garbage collection, pausing to clean up after allocations no one consciously decided to make. A great deal of that pressure comes from a single habit: copying data when we only needed to look at it. Span<T> exists to break that habit, and learning to use it well is one of the highest-leverage skills a modern C# developer can pick up.
Why allocations cost more than they look
Every time you allocate an object on the managed heap — a new array, a substring, a temporary buffer — you are creating future work for the garbage collector. The allocation itself is cheap; the collection is not. .NET's generational GC sweeps short-lived objects out of Gen 0 frequently, and while that is fast, frequency adds up. Allocate enough transient garbage in a hot path and you trade a little CPU on every operation for periodic, application-wide pauses while the collector runs.
The trap is that the worst offenders look innocent. string.Substring allocates a brand-new string. Splitting a string allocates an array plus a string per segment. Slicing an array with LINQ or ToArray allocates a copy. None of these are wrong, but in a parser, a serializer, or a request hot path that runs millions of times, they are the difference between an application that scales and one that stutters.
What Span<T> actually is
A Span<T> is a small value type that represents a contiguous region of memory — a typed, bounds-checked window onto data that already exists somewhere. That "somewhere" can be a managed array, a block of stack memory, a string, or even native memory. Critically, a span does not own or copy the data; it points into it. Creating a span and slicing it allocates nothing on the heap.
csharp
int[] numbers = { 10, 20, 30, 40, 50 };
Span<int> all = numbers; // a window over the whole array
Span<int> middle = all.Slice(1, 3); // {20, 30, 40} — no copy, no allocation
middle[0] = 99; // writes straight through to numbers[1]
// numbers is now { 10, 99, 30, 40, 50 }Slice is the heart of it. Where Substring or ToArray would hand you a fresh copy, Slice hands you another window over the same memory. For read-only data you use ReadOnlySpan<T>, and a string gives you one for free through AsSpan().
Parsing without the garbage
Here is the classic before-and-after. Imagine parsing a line like "42,17,99" into integers. The idiomatic, allocation-heavy version:
csharp
// Allocates: an array, plus a string for every field.
string[] parts = line.Split(',');
int a = int.Parse(parts[0]);
int b = int.Parse(parts[1]);
int c = int.Parse(parts[2]);The span-based version reads the same logical data with zero heap allocations, because int.TryParse accepts a ReadOnlySpan<char> and slicing never copies:
csharp
ReadOnlySpan<char> span = line;
int first = span.IndexOf(',');
int second = span.Slice(first + 1).IndexOf(',') + first + 1;
int.TryParse(span.Slice(0, first), out int a);
int.TryParse(span.Slice(first + 1, second - first - 1), out int b);
int.TryParse(span.Slice(second + 1), out int c);It is more verbose, and that verbosity is the honest cost. But in a loop that processes a million lines, the second version simply does not generate the garbage the first one does.
stackalloc and ArrayPool: buffers without the heap
When you genuinely need a scratch buffer, you have two allocation-free options. For small, short-lived buffers, stackalloc carves space directly off the stack and gives you a Span<T> over it — no GC involvement at all:
csharp
Span<char> buffer = stackalloc char[64];
int written = FormatInto(buffer);
ReadOnlySpan<char> result = buffer.Slice(0, written);Keep stackalloc small — a few hundred bytes, not megabytes — because the stack is finite and overflowing it crashes the process. For larger or longer-lived buffers, rent from a shared pool instead of allocating, and return it when you are done:
csharp
byte[] buffer = ArrayPool<byte>.Shared.Rent(8192);
try
{
int read = stream.Read(buffer, 0, buffer.Length);
Process(buffer.AsSpan(0, read));
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}ArrayPool<T> recycles arrays across operations so a busy path reuses the same memory instead of allocating fresh buffers each time. The rented array may be larger than you asked for, which is why you always track the actual length and slice accordingly.
Memory<T>: the async-safe cousin
Span<T> has one hard limitation that trips up everyone eventually: it is a ref struct, which means it can only ever live on the stack. It cannot be a field of a class, cannot be boxed, cannot be captured by a lambda, and — most importantly — cannot survive across an await. The moment your hot path becomes asynchronous, the compiler will refuse to let a span cross the suspension point.
That is what Memory<T> is for. It represents the same idea — a window over contiguous memory — but as a regular struct that can be stored on the heap and carried across await boundaries. You hold a Memory<T> while awaiting, then call .Span to get a Span<T> for the synchronous slice of work:
csharp
async Task ProcessAsync(ReadOnlyMemory<byte> data)
{
await SomeAsyncCall(); // Memory survives the await
ReadOnlySpan<byte> span = data.Span; // get a Span only when ready to work
Handle(span);
}Modern stream and pipeline APIs are built around this pair, which is why Stream.ReadAsync now accepts a Memory<byte> directly.
The pitfalls worth knowing
The ref struct rules are the main source of confusion, and they exist for a good reason: the runtime must guarantee a span never outlives the memory it points at. Respect them and you are safe. Beyond that, the cardinal sin is slicing a span over a rented or stack buffer and then handing that slice somewhere it will be used later — once the buffer is returned to the pool or the stack frame unwinds, the data underneath is no longer yours. Spans are for processing data in place, now, not for stashing references to it.
And the most important pitfall of all is reaching for any of this too early.
When not to bother
Span<T> is a precision tool, not a default. The allocation-free style is harder to read, easier to get subtly wrong, and entirely wasted on code that runs occasionally. Application startup, configuration parsing, a handler that fires a few times a minute — none of these will ever notice the garbage from a Substring, and rewriting them around spans makes the codebase worse, not better. The right workflow is to write the clear version first, measure under realistic load with a profiler or a tool like BenchmarkDotNet, find the genuinely hot path that is generating real GC pressure, and apply Span<T> there and only there.
Used that way, it is transformative. The serializers, parsers, and request pipelines at the core of high-throughput .NET have been quietly rebuilt around spans over the last several years, and it is a large part of why modern .NET competes on raw performance with anything else. The art is not in using Span<T> everywhere. It is in knowing the few places where copying data you only meant to read is the thing standing between you and an application that scales — and reaching for the window instead of the copy exactly there.


