A hot path allocates heavily and garbage collection is showing up in your profile. What does Span give you that a substring does not?
Span is a stack-only view over memory you already have, so slicing it costs nothing while Substring allocates a new string and copies. It removes allocations rather than making them faster, which is the right lever in a runtime where the cost is collection pressure rather than the allocation itself.
What the interviewer is scoring
- Whether the candidate frames the win as removing allocations rather than speeding them up
- That a Span is understood as a view over existing memory rather than a container
- Does the candidate know why a Span cannot be stored in a field or awaited across
- Whether Memory is named as the answer for asynchronous or stored cases
- That measurement is proposed before rewriting anything
Answer
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:
// Allocates. Each Substring copies characters into a new string on the heap.
public static (string Sku, string Qty) ParseSlow(string line)
{
var i = line.IndexOf(',');
return (line.Substring(0, i), line.Substring(i + 1));
}
// Allocates nothing. Both results are windows onto the caller's string.
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)) { /* no intermediate string */ }
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); // Memory can cross the await
Inspect(buffer.Span); // Span only inside a sync call
}
The rest of the allocation-free toolkit
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 # with BenchmarkDotNet and [MemoryDiagnoser]
# Method Mean Allocated
# ParseSlow 82.4 ns 112 B
# ParseFast 19.1 ns 0 B
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.
Likely follow-ups
- Why can a Span not be used in an async method?
- When would you reach for Memory instead, and what does it cost?
- What is the large object heap and when does it start mattering here?
- How would you prove this change was worth making?
Related questions
- How does the .NET garbage collector decide what to collect, and how would you make a hot path allocate less?hardAlso on garbage-collection and memory6 min
- What happens when you append to a slice, and when do two slices stop sharing memory?mediumAlso on memory and garbage-collection5 min
- Memory keeps climbing in a Go service under load. How do you find out why, and what can you actually tune?hardAlso on garbage-collection and memory5 min
- A long-running Python service keeps climbing in memory and never comes back down. How do you work out where the memory went?hardAlso on memory and garbage-collection5 min
- How do you tell whether a value escapes to the heap, and how would you find the allocations that are costing you?hardAlso on garbage-collection6 min
- How do generators reduce memory use, and when are they the wrong choice?mediumAlso on memory4 min
- How would you rewrite a row-by-row pandas transformation, and what is SettingWithCopyWarning telling you?mediumAlso on memory5 min
- Why would you stream a large file rather than read it into memory, and what is backpressure?mediumAlso on memory4 min