Skip to content
QSWEQB
hardConceptCodingSeniorStaff

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.

4 min readUpdated 2026-07-26

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

Further reading

spanmemoryallocationgarbage-collectionperformance