Parsing a 4 KB network buffer routinely needs to hand a middle section to another method. Passing buffer[100..200] as a byte[] allocates a fresh array and copies 100 bytes; doing that per packet turns parsing into a stream of short-lived allocations the garbage collector must later reclaim. A Span<T> describes that same section as a (reference, length) pair over the original buffer, so the sub-view costs nothing to create and shares the bytes it points at.

A span owns nothing. It is a small value type — a managed reference to the first element in view plus an int length — laid over memory that lives elsewhere: a managed array, a stackalloc block, or native memory. Slicing returns another span over the same backing store with a shifted reference and a new length; no element is copied, which is why a write through a slice is visible in the original buffer. Being a ref struct confines it to the stack, so it can never outlive the memory it describes.

Core shape: existing contiguous buffer → (ref-to-first, length) window → Slice adjusts ref+length with no copy → shared memory across views → O(1) span storage regardless of window size.

Visualization pending

Planned StepTrace: a (pointer, length) window card laid over part of an existing array, where slicing produces a narrower window over the same memory — no copy — and a write through the slice mutates the shared backing element. No matching renderer exists in engine.js yet.

Representation and non-ownership

A Span<T> holds two fields: a managed reference (ref T) to the first element in view and an int length. It stores none of the elements itself, so its footprint is constant whether the window covers 2 elements or 2 million. Indexing span[i] dereferences first + i after checking 0 <= i < length, giving array-style access with a bounds check and no hop through an owner object.

Slice(start, length) builds a new span whose reference is first + start and whose length is the requested count. Nothing is allocated and nothing is copied — the result is a narrower view of the same elements. A store through either view writes the shared backing element, so a span deliberately aliases its source rather than isolating a copy.

Three properties follow from the design:

  • Non-owning. The backing store — a managed array, a stackalloc block, or a native pointer — is allocated and freed elsewhere. The span is a window and never frees anything. A List<T> (dynamic array) can expose its contiguous backing array as a span through CollectionsMarshal.AsSpan, and an array converts directly with AsSpan().
  • Stack-only. Span<T> is a ref struct. The runtime keeps it on the stack and forbids every move to the heap, which is what stops a window from outliving the buffer beneath it.
  • Read-only variant. ReadOnlySpan<T> is the same window with writes removed, so it can wrap immutable data such as a string (as ReadOnlySpan<char>). Span<T> converts implicitly to it; the reverse is disallowed.

Complexity

OperationTimeHeap allocationAux space
Construct a span over a bufferO(1)noneO(1)
Element access span[i]O(1), bounds-checkednoneO(1)
Slice(start, length)O(1)none — same memoryO(1)

The defining column is allocation: every operation is constant time and copies nothing. A span is two fields, so its own footprint is O(1) independent of the window length; the elements live in memory it does not own. Producing the same sub-view as a distinct array — array[100..200] typed as byte[] — instead costs O(n) time and an O(n) allocation for the copied elements.

Where the stack-only window breaks down

The restrictions all follow from one rule: a non-owning window must never outlive its buffer, so the runtime pins it to the stack and refuses every path to the heap.

Storage and capture are blocked at compile time. A Span<T> cannot be boxed, assigned to a class field, captured in a lambda or closure, used as an ordinary generic type argument (absent an allows ref struct constraint, added in C# 13 / .NET 9), or held across an await or yield boundary — each of those would place the window on the heap, where the buffer’s lifetime no longer constrains it. Code that must keep a view in a field or carry it across async suspension uses Memory<T> instead: a heap-storable handle whose .Span yields a Span<T> at the synchronous point of use.

Lifetime still binds even inside the stack. A span over a stackalloc buffer is valid only within the method that allocated it; the escape rules block returning it, because the stack frame — and the buffer — vanish on return. A span over native memory that has since been freed is worse: nothing in the type system records the free, so indexing the span reads whatever now occupies the reclaimed address rather than the original elements.

ReadOnlySpan<T> narrows these rules rather than lifting them: it still cannot escape to the heap, and it additionally rejects writes, so an attempt to mutate through a ReadOnlySpan<char> obtained from a string fails to compile rather than corrupting an interned literal.

Reference drawer

Comparison

TypeSub-view costHeap-storableCrosses await / lives in a fieldBacking storeStronger case
Span<T>O(1), no copyNo (ref struct)NoArray, stackalloc, or native memoryZero-copy slicing on synchronous hot paths
ArraySegment<T>O(1) viewYesYesManaged array onlyA heap-storable array window from before Span<T> existed
Memory<T>O(1) sliceYesYesArray or other owned bufferA view must live on the heap or cross an async boundary

Span<T> is the zero-copy, zero-allocation view for synchronous code that touches contiguous memory — parsing, formatting, buffer manipulation — and it pays for that speed by being unable to leave the stack. Memory<T> accepts one level of indirection to become heap-storable and async-safe, which is the deciding factor whenever a view must sit in a field or survive an await. ArraySegment<T> fills the same heap-storable niche for managed arrays only and predates both. A real copy — a fresh array — is warranted just once: when the data must outlive the buffer it came from.

Questions

References

  • Span<T> struct — API reference for the constructors, Slice, and the ref struct constraints that keep it on the stack.
  • Memory and spans — Microsoft’s ownership, lifetime, and consumption rules covering when a view should be Span<T> versus Memory<T>.
  • All About Span: Exploring a New .NET Mainstay — Stephen Toub’s design walkthrough of the two-field layout, slicing, and the ref struct motivation.