Articles
C# Span and Memory Interview Questions: Lifetimes, Slices, and Async Boundaries
Prepare for C# Span and Memory interview questions with slice traces, ref safety, C# 13 async boundaries, pooling bugs, and ownership contracts.
A parser receives a rented byte array, takes a slice, starts an asynchronous write, and immediately returns the array to the pool. The code can look efficient and still send corrupted data. The useful interview question is not merely whether Span<T> allocates. It is who owns the storage, who may access it, and when that permission ends.
For C# Span and Memory interview questions, build every answer around that distinction: a view describes a region; it does not establish an independent lifetime or transfer ownership. This guide uses original code-review exercises grounded in Microsoft documentation. They are reasoning examples, not reports of a particular employer's interview or measured performance results.
For a concrete starting exercise, open Design a packet reassembler API and decide whether incomplete payloads are copied, borrowed, or transferred before choosing any buffer type.
What is the difference between Span and Memory?
Span<T> is a stack-only ref struct representing contiguous elements. The underlying elements need not be stack allocated: an array-backed span refers to managed heap storage. Memory<T> is an ordinary struct that can be stored in an object and retained across asynchronous suspension. Its .Span property provides a synchronous view. These are representation differences, not a promise that either type owns its backing buffer. See Microsoft's Memory and Span usage guidelines.
In an interview, separate three questions before choosing a signature. Does the operation mutate elements? Must it retain access after returning? Who releases the underlying storage? A synchronous parser might accept ReadOnlySpan<byte>. An asynchronous transport might accept ReadOnlyMemory<byte>. An API accepting IMemoryOwner<byte> needs an explicit ownership-transfer contract.
Do not describe ReadOnlySpan<T> as a snapshot. It restricts writes through that view; another alias can still mutate an array behind it. Similarly, readonly elements that are reference types do not make the referenced objects immutable. “Read-only access” and “immutable data” solve different problems.
| Requirement | Candidate parameter | Contract to explain |
|---|---|---|
| Parse during the call | ReadOnlySpan<byte> | Borrow storage for this synchronous operation |
| Fill a caller-provided region | Span<byte> | Mutate only the agreed region; report bytes written |
| Await a network operation | ReadOnlyMemory<byte> | Keep storage valid until the returned operation completes |
| Hand off a rented allocation | IMemoryOwner<byte> | Name the component responsible for eventual disposal |
Does slicing copy the data? Trace the aliases
Consider this small example inside a synchronous method:
int[] values = { 10, 20, 30, 40 };
Span<int> middle = values.AsSpan(1, 2);
ReadOnlySpan<int> observed = values;
middle[0] = 99;
int[] snapshot = middle.ToArray();
values[2] = 77;
The expected final array is [10, 99, 77, 40]. The slice sees [99, 77]; observed sees the changed backing array; snapshot remains [99, 30]. The important distinction is the explicit ToArray() copy. Microsoft's Slice API describes a subregion of the current span, not a newly allocated collection.
Explain the address mapping, not just the result. middle[0] corresponds to values[1], and middle[1] corresponds to values[2]. Copying the span value copies a small description of that region, not those integers. Consequently, two views can overlap even when their variables have different names.
Now change the exercise: a function receives an input span and an output span that overlap. Before writing a custom forward-copy loop, state whether overlapping input is allowed. A transformation that reads future input after overwriting it may produce the wrong result. Choosing a library operation with a documented overlap contract, rejecting overlap, or making an explicit temporary copy are different designs—not interchangeable optimizations.
Boundary tests should include an empty slice at the end, a start beyond the end, a negative length, and a requested region larger than the remaining input. Avoid hiding overflow in your own start + length validation. Prefer the established slicing API when its exception behavior matches the public contract.
Which spans can safely leave a method?
“Never return a span” is an overcorrection. The issue is whether the referenced storage remains valid in the caller's context. A slice of caller-provided storage can be returned when the language's escape rules allow it. A span pointing to a method's local stack allocation cannot outlive that method.
// Intentional compile-time failure: the storage dies on return.
static Span<int> Bad()
{
Span<int> local = stackalloc int[4];
return local;
}
// Borrows the caller's storage; does not allocate a result array.
static ReadOnlySpan<byte> Payload(ReadOnlySpan<byte> packet)
{
if (packet.Length < 2)
throw new ArgumentException("Missing header");
return packet.Slice(2);
}
Microsoft's ref-safety explanation makes this an escape-scope problem. The compiler protects the lifetime of tracked references; it does not infer your application-level agreement with a buffer pool.
For the payload function, ask what the caller does next. Immediate parsing is straightforward. Saving the result in a normal object field is not possible with a span. Saving a Memory<byte> slice may be representable, but only if the caller also preserves the backing storage's lease. Converting a design from Span to Memory changes what can be stored; it does not repair premature pool return.
For stackalloc, also discuss size. An attacker-controlled packet length is not a safe stack-allocation size. Use a small bounded threshold, account for the call stack, and avoid repeated stack allocations inside a loop. Do not assume uninitialized stack storage contains zeros. Microsoft's stackalloc reference documents these limitations.
Can you use Span inside an async method?
Ask which C# language version the project uses. Before C# 13, declaring these ref-struct locals inside async methods was disallowed. C# 13 relaxed the restriction for synchronous regions, but a span cannot remain live across an await boundary. The current ref struct reference and C# 13 feature overview describe the version distinction.
An interview-safe design keeps the borrowed view in a synchronous helper and passes Memory across the async operation:
static byte First(ReadOnlySpan<byte> data)
{
if (data.IsEmpty) throw new ArgumentException("Empty packet");
return data[0];
}
static async Task<byte> InspectAsync(ReadOnlyMemory<byte> data)
{
await Task.Yield();
return First(data.Span);
}
The helper prevents a span local from spanning the suspension point. It does not prove that data still contains the original bytes. The caller must keep its storage valid and must not mutate it concurrently unless the API explicitly permits that behavior.
Also distinguish async from returning a Task. A synchronous method may inspect a span and return a task representing unrelated work without retaining that span. The method signature alone does not tell you whether a borrowed buffer escapes. Follow the data into closures, continuations, background queues, and callbacks.
These snippets illustrate documented rules; they are not a compiler transcript. When using them as a local exercise, set the language version explicitly and keep intentional rejection cases separate from the runnable project. A build failure caused by an older language version is different from a genuine cross-await lifetime violation.
Find the bug in this pooled asynchronous write
Suppose the caller rents a buffer, fills it, and starts a write:
byte[] rented = ArrayPool<byte>.Shared.Rent(1024);
int used = Fill(rented.AsSpan(0, 1024));
Task pending = stream.WriteAsync(rented.AsMemory(0, used)).AsTask();
ArrayPool<byte>.Shared.Return(rented); // Too early.
await pending;
The method Fill is a placeholder for a synchronous producer that returns a valid used length. The bug is the ordering: the pool may lend the same array to another caller while the write still consumes it. Keeping a managed reference alive is insufficient because the application relinquished exclusive use.
The corrected ownership skeleton is:
byte[] rented = ArrayPool<byte>.Shared.Rent(1024);
try
{
int used = Fill(rented.AsSpan(0, 1024));
await stream.WriteAsync(rented.AsMemory(0, used), cancellationToken);
}
finally
{
ArrayPool<byte>.Shared.Return(rented, clearArray: true);
}
The finalizer here is a finally block, not an object finalizer. It runs after the awaited operation finishes, including fault or cancellation. Clearing is a deliberate confidentiality/cost choice for this example, not a substitute for respecting lifetime. The ArrayPool.Return contract prohibits continued use and double return.
| Event | Safe ownership action | Failure to catch |
|---|---|---|
| Producer throws before write | Return once through finally | Leak on setup failure |
| Write is still pending | Retain the rental | Return followed by another renter's mutation |
| Cancellation is requested | Await actual completion | Treat the request itself as completion |
| Write faults | Observe failure, then release | Fire-and-forget task still accessing storage |
| Write succeeds | Return once, stop accessing | Double return or retained slice used later |
Notice another trap: the rented array may be larger than requested. Send only the initialized, meaningful region, not the full rental. Validate the producer's returned length. Otherwise, correct ownership can coexist with an accidental disclosure of unrelated bytes.
How do you defend an allocation or performance claim?
Say exactly which operation avoids a copy. Taking a view over an existing array does not require copying its elements. That does not establish that the surrounding request path allocates nothing. Parsing into objects, formatting diagnostic strings, creating tasks, growing collections, or materializing a result can still allocate.
Design a comparison with the same outputs, malformed-input behavior, and input distribution. Measure bytes allocated per operation, throughput, latency, and retained memory under representative concurrency. Include small inputs where setup overhead dominates and large inputs where copying matters. Separate warmup from measurement and report the runtime and build configuration.
A slice can also retain a large backing array through a long-lived Memory reference. Avoiding a tiny copy may keep a much larger object reachable. In a cache of small parsed fields, copying the needed bytes can therefore be the more economical lifetime decision. This is a workload hypothesis to measure, not a universal instruction to copy or pool everything.