The cost is collection, not allocation
Allocating in .NET is cheap — a pointer bump in generation zero. What is not
cheap is what follows. Objects that survive a collection are promoted, and a
generation two collection has to trace a much larger graph. A method allocating
a few small strings per call is invisible; the same method at fifty thousand
calls a second produces sustained collection work, and the profile shows time in
the collector rather than in your code.
So the lever is not "allocate faster". It is "allocate less", and Span<T> is
the language's main tool for doing that without changing what the code means.
What a Span is
Span<T> is a pointer and a length. It does not own memory, it describes a
window onto memory that already exists — a string, an array, a stack buffer,
memory from a pool. Slicing it produces another pointer and length. No copy, no
allocation.
Set against the ordinary way of doing the same thing:
public static (string Sku, string Qty) ParseSlow(string line)
{
var i = line.IndexOf(',');
return (line.Substring(0, i), line.Substring(i + 1));
}
public static (ReadOnlySpan<char> Sku, ReadOnlySpan<char> Qty) ParseFast(
ReadOnlySpan<char> line)
{
var i = line.IndexOf(',');
return (line[..i], line[(i + 1)..]);
}
The second version does the same work with no heap traffic. A string converts
to a ReadOnlySpan<char> implicitly, so callers rarely change.
The related trick is parsing directly from the span, since the numeric types
take one:
if (int.TryParse(qty, out var quantity)) { }
Why it is restricted
Span<T> is a ref struct, which means it lives on the stack and the compiler
enforces that it cannot escape to the heap. You cannot store one in a field of a
class, box it, put it in a list, or use it across an await or a yield.
That looks arbitrary until you see what it protects. A span may point at a stack
buffer, and a stack frame disappears when its method returns. If the span could
outlive the frame — captured in a field, or resumed after an await on a
different thread — it would point at memory that has been reused, and the result
would be silent corruption rather than an exception. The restriction is the
guarantee.
When you need the same idea across an await or in a field, Memory<T> is the
heap-capable equivalent. It costs an indirection: you call .Span to get a
usable window at the point of use, inside a synchronous section.
public async Task ProcessAsync(ReadOnlyMemory<byte> buffer, CancellationToken ct)
{
await _sink.WriteAsync(buffer, ct);
Inspect(buffer.Span);
}
Span rarely arrives alone. Three companions come up in the same conversation.
stackalloc puts a small buffer on the stack with no heap involvement at
all, which suits a fixed, genuinely small working area. It must be bounded — a
size taken from input is a stack overflow waiting to happen, so the pattern is
to stack-allocate below a threshold and rent above it.
ArrayPool<T> rents a buffer and returns it, which is the answer for
anything large or variable. The discipline is that a rented array must be
returned on every path, including exceptions, and that its length is at least
what you asked for rather than exactly, so you slice it rather than using
.Length.
string.Create builds a string of known length by writing into its buffer
directly, which removes the intermediate allocations a builder would make.
Together these cover the usual shape: read into a pooled buffer, parse over
spans of it, and allocate only the values you actually keep.
Measure, then decide
The honest caveat, and the thing a good candidate volunteers. This code is
harder to read than the version it replaces, the restrictions surprise the next
maintainer, and most methods do not run often enough for any of it to matter.
Rewriting a path that runs once per request to save two allocations is a net
loss.
The sequence is to profile, confirm that collection is a meaningful share of
time, find the specific path, and measure it:
dotnet run -c Release
The Allocated column is the one the change is aimed at. If it was already
small, the rewrite was not the problem you had.
One more thing worth knowing for the same conversation: objects above roughly
85,000 bytes go on the large object heap, which is not compacted by default. A
path repeatedly allocating large arrays fragments it, and the process grows
while the live set stays small — a case where pooling is the fix and no amount
of span work helps, because the allocation you needed to remove was the big one.
Span does not make allocation faster, it removes it, and the reason that helps
is that the bill in this runtime arrives at collection time rather than at
allocation time.
© 2026 Preptima. Originally published at preptima.com (opens in a new tab).