Advanced 5 min · March 06, 2026

CS4007 - Async Span Prevents Dangling Pointer

A compile-time error CS4007 blocks Span<T> in async methods, preventing dangling pointers from stackalloc.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
Quick Answer
  • Span is a 16-byte ref struct providing a zero-allocation view over contiguous memory (heap, stack, or native).
  • Memory is a regular struct that can cross await boundaries — call .Span to get a stack-safe view.
  • ReadOnlySpan allows parsing strings without Substring — no allocations for intermediate results.
  • stackalloc + Span eliminates heap allocation for fixed-size buffers, but size must be bounded to avoid StackOverflowException.
  • MemoryPool.Shared.Rent() provides pooled buffers that avoid Large Object Heap pressure for buffers over 85 KB.

Every millisecond your web API spends allocating strings and arrays is a millisecond the garbage collector has to eventually clean up. At scale — thousands of requests per second — that tax compounds into measurable latency spikes and GC pauses that ruin your P99 numbers. The .NET team didn't add Span<T> and Memory<T> as academic exercises; they built them because the runtime itself needed a way to parse HTTP headers, JSON, and binary protocols without spraying the heap with short-lived allocations.

Before these types existed, the only way to work with a 'slice' of an array or a substring was to call Array.Copy or String.Substring — both of which allocate brand-new objects. If you were parsing a 10 KB HTTP request and needed 40 substrings, you'd create 40 heap objects just to read data you already had. Span<T> and Memory<T> solve this by letting you create a lightweight view over existing memory — whether that memory lives on the stack, the heap, or even unmanaged native memory — with no allocation at all.

By the end of this article you'll understand the internal representation of both types, know exactly when each one is appropriate, be able to write genuinely zero-allocation parsers, and understand the safety rules that make the compiler and runtime enforce correctness. You'll also walk away knowing the three most common production mistakes teams make when they first adopt these types.

How Span Works Under the Hood — Stack-Only Views Into Any Memory

Span<T> is a ref struct — that single fact drives every constraint you'll encounter. A ref struct can only live on the stack. It can never be boxed, stored in a field of a regular class, used as a generic type argument, or captured by a lambda. The compiler enforces all of this at compile time, so you get a hard error rather than a runtime surprise.

Internally, Span<T> is just two fields: a managed pointer (ref T) to the first element, and an integer length. On 64-bit systems that's 16 bytes total. There's no array object, no string object, no allocation. When you call someSpan[3], the JIT generates a single bounds-checked pointer dereference — essentially the same code you'd get from a raw unsafe pointer, but with the safety guarantee that the index is validated.

The key insight is that Span<T> can point at three completely different memory regions without changing its API: a managed array on the heap, a stackalloc block on the stack, or a block of unmanaged memory obtained from Marshal.AllocHGlobal. Your parsing code doesn't care which one — it just reads and writes through the Span interface identically. This is what makes Span<T> the universal buffer abstraction in modern .NET.

Memory and ReadOnlyMemory — When You Need to Cross Async Boundaries

Span<T> is perfect for synchronous, stack-contained operations. But the moment you hit an await, the current stack frame evaporates and resumes on a potentially different thread. A ref struct can't survive that transition, so Span<T> simply can't cross an await boundary. This is where Memory<T> comes in.

Memory<T> is a regular struct — not a ref struct — so it can be stored in class fields, used in async methods, returned from lambdas, and put in collections. It wraps the same concept (a segment of contiguous memory with an offset and length) but does so through a level of indirection: it holds a reference to the owner object (array, MemoryManager<T>, or string), plus an index and length. When you actually want to process the data, you call .Span on it to get a Span<T> at that point — that's where the 'read through a stack-safe view' pattern kicks in.

The key production pattern is: accept Memory<T> in your async method signatures, pass Span<T> into your synchronous helper methods that do the actual parsing or computation. This keeps allocations zero while remaining async-friendly. IMemoryOwner<T> and MemoryPool<T> extend this further by giving you a rented, lifetime-managed buffer — critical for socket and pipe-based I/O where you need pooled buffers that survive multiple await points.

Building a Real Zero-Allocation CSV Parser — Putting It All Together

Theory only sticks when you build something real. Let's write a CSV line parser that processes an entire row without a single heap allocation except the final result array. This is the kind of code you'd find inside a high-throughput data ingestion pipeline or a game server parsing player commands at 60 fps.

The core technique is iterating over a ReadOnlySpan<char> by repeatedly finding delimiters and slicing — no Split(), no Substring(), no List<string> intermediate collection. We use a fixed-size Span<Range> to record where each field starts and ends, then we materialize strings only at the very end when the caller actually needs them.

This pattern mirrors exactly how System.Text.Json and ASP.NET Core's routing engine work internally. Understanding it means you can read — and contribute to — performance-critical library code with confidence.

Performance Benchmarks, Gotchas, and Production Rules

Knowing the API is one thing. Knowing when you've actually eliminated allocation — and when you've accidentally added it back — is what separates production-ready code from aspirational code. BenchmarkDotNet is your ground truth here; gut feelings about allocations are reliably wrong.

The most dangerous false economy is calling .ToString() or creating a new string inside a hot path you thought was allocation-free. One ToString() call on a ReadOnlySpan<char> inside a loop that runs a million times creates a million strings. Profile first with dotnet-trace or PerfView; optimize second.

Another production reality: Span<T> slicing is O(1) — it's literally adding an integer to a pointer. But accessing elements through a Span in a tight loop is only as fast as unsafe pointer access when the JIT eliminates bounds checks. The JIT does eliminate bounds checks when it can prove the index is within range (e.g. iterating with a for loop up to .Length). If you're using arbitrary computed indices, you may not get that optimization. When it matters, benchmark both the Span version and the unsafe version and let the numbers decide.

Choosing Between Span and Memory — Decision Tree for Real Code

Newcomers often ask: "Should I use Span<T> or Memory<T> in my API?" The answer depends entirely on the lifetime of the slice and where the data will be processed. This decision tree will help you pick the right type the first time, avoiding the compile-time error or unnecessary indirection.

Start with two questions: 1) Does this slice need to live past an await? 2) Will this value be stored in a field, collection, or captured by a lambda? If yes to either, use Memory<T>. If the slice is purely used synchronously within a single method scope, use Span<T>.

For public APIs that accept data to be processed asynchronously (e.g., a method that queues work to a background thread), always use Memory<T>. The caller can convert from a Span to Memory only if the underlying memory is heap-based (array or string). That conversion is a cheap O(1) operation. But if you force a caller to provide Span<T> and they need async processing, they're stuck. Design for the async case by default.

Span<T> vs Memory<T> — Side-by-Side
Feature / AspectSpan<T>Memory<T>
Struct kindref struct (stack-only)Regular struct (heap-safe)
Can cross await boundaryNo — compile error CS4007Yes — designed for async
Can be a class fieldNo — compile error CS8345Yes — full support
Can point at stackallocYes — primary use caseNo — stackalloc is stack-only
Can point at unmanaged memoryYes — via unsafe constructorYes — via MemoryManager<T>
Get a Span<T> from itIs a Span<T>Call .Span property (free)
Use in LINQ / genericsNo — ref struct restrictionYes — normal type parameter
Allocation costZero — 16 bytes on stackZero — 24 bytes on stack
Lifetime enforcementCompile-time by ref struct rulesRuntime / IDisposable pattern
Ideal use caseSynchronous parsers, hot loopsAsync I/O, pooled buffers

Key Takeaways

  • Span<T> is a 16-byte ref struct that holds a pointer and a length — it creates a zero-allocation view over any contiguous memory (heap array, stackalloc, or unmanaged) and is enforced as stack-only by the compiler
  • Memory<T> is the async-safe sibling — use it in method signatures that contain await or need class fields, then call .Span inside synchronous helpers to do the actual work
  • The pattern stackalloc + Span<T> eliminates heap allocation entirely for fixed-size intermediate buffers, but you must validate the size against a hard cap before allocating or risk an unrecoverable StackOverflowException
  • MemoryPool<T>.Shared.Rent() is the production tool for variable-size buffers in async I/O paths — it avoids Large Object Heap pressure for buffers over 85KB and keeps allocation off the critical path
  • Design your API boundary: accept Memory<T> in public/async signatures, use Span<T> internally in synchronous hot paths — this gives callers maximum flexibility while keeping your implementation allocation-free

Common Mistakes to Avoid

  • Calling String.Substring inside a loop instead of slicing a Span
    Symptom: Allocations don't drop despite using Span elsewhere; dotMemory shows thousands of short-lived strings.
    Fix: Obtain a ReadOnlySpan<char> once with AsSpan(), then use Slice() and IndexOf() exclusively inside the loop; only call new string(span) when handing the result back to non-Span-aware code.
  • Using Span<T> in an async method or as a class field
    Symptom: Compiler errors CS4007 ('Spans may not be used in async methods') or CS8345 ('cannot be used as a field') which confuse developers who then try to cast or box their way around them.
    Fix: Replace Span<T> with Memory<T> in the method signature or class field; call .Span inside each synchronous sub-method when you need to read or write the actual data.
  • stackallocating a buffer sized from untrusted or unbounded input
    Symptom: The process crashes with a fatal StackOverflowException that bypasses all try/catch blocks and terminates the app with no logging.
    Fix: Always guard stackalloc with an explicit size check (e.g. if (size > 4096) throw) and fall back to MemoryPool<T>.Shared.Rent() for larger payloads.

Interview Questions on This Topic

  • QWhy is Span<T> a ref struct, and what specific things does that prevent you from doing with it? Walk me through what would go wrong if it weren't a ref struct.Mid-levelReveal
    Span<T> is a ref struct to ensure it can never outlive the memory it references. If Span<T> were a regular struct, you could store it in a heap object (e.g., a class field) that survives a stackalloc buffer's lifetime. That would create a dangling pointer — the classic use-after-free bug. Being a ref struct prevents: storing as a class field, using in async methods (state machine on heap), boxing, using as a generic type argument, or capturing in a lambda. The compiler enforces all of these to guarantee memory safety without a GC scan.
  • QYou have a high-throughput HTTP server parsing request headers. Currently it uses string.Split and Substring and your GC Gen0 collections are spiking. How would you redesign the parsing code using Span<T> and Memory<T>, and what allocation profile would you expect after the change?SeniorReveal
    First, I'd obtain a ReadOnlySpan<char> from the raw header buffer via .AsSpan(). Then I'd parse each header by finding delimiters (':', '\r\n') using IndexOf and Slice — no allocation. I'd store the resulting header name-value pairs as Memory<char> if I need them beyond the current request's synchronous phase, or as ReadOnlySpan<char> if processed immediately. After the change, the only allocations would be the final strings I output (if any) and the small Memory<T> values (each 24 bytes plus reference to the owning buffer). Gen0 collections would drop proportionally to the number of headers parsed per request — often 5–10x reduction.
  • QWhat's the difference between Memory<T>.Span and directly using a Span<T>, and why does calling .Span inside an async method work when storing a Span<T> field does not?Mid-levelReveal
    Memory<T>.Span is a property that returns a new Span<T> that points to the same memory region. It's safe to call within an async method as long as the returned Span is used only within the synchronous portion of the method — before the next await. The Span returned is not stored beyond that synchronous scope. In contrast, storing a Span<T> in a field would mean the field's value could survive an await (since the class instance lives on the heap), violating the ref struct constraint. The compiler prevents the field scenario entirely. The .Span property is the bridge: it creates a temporary stack-only view from the heap-safe Memory<T>.

Frequently Asked Questions

Can I use Span with async/await in C#?

No — Span<T> is a ref struct and the compiler will reject it inside async methods with error CS4007. This is a safety rule, not a bug. Use Memory<T> in async method signatures instead, then call .Span on it inside synchronous sub-methods where you need to read or write the data.

Is Span faster than arrays in C#?

Span<T> doesn't make array access faster — the underlying data is identical. The performance gain comes from eliminating allocations: instead of creating a new array or string for every sub-slice operation, you create a Span that points into the existing data. Fewer allocations means fewer GC collections, which is where the real speedup appears in practice.

What is the difference between ReadOnlySpan and string in C#?

A string is an immutable heap-allocated object. ReadOnlySpan<char> is a zero-allocation view into a region of character data — it could be pointing at part of an existing string, a char array, or a stackalloc buffer. You get ReadOnlySpan<char> from a string via .AsSpan() at zero cost, and you can slice, search, and compare it without ever creating new string objects. Only when you need to pass the result to an API that requires a string do you call new string(span), which is the single allocation point.

What happens if I pass a Span to a method that expects IReadOnlyCollection?

It won't compile — Span<T> does not implement IReadOnlyCollection<T> because it's a ref struct and cannot participate in generic interfaces. If you need to work with collection interfaces, you must copy the Span content into an array (defeating zero-allocation) or change the API to accept ReadOnlySpan<T> directly. This is a common friction point when adopting Span<T> in existing code.

Can Memory be used with stackalloc buffers?

No — stackalloc buffers live on the stack and cannot be referenced by a heap-safe struct like Memory<T>. If you have a stackalloc buffer, you must stay within the synchronous scope and use Span<T> directly. If you need to pass stackalloc data to an async method, you must first copy it to a heap array or a rented buffer.

🔥

That's C# Advanced. Mark it forged?

5 min read · try the examples if you haven't

Previous
Attributes in C#
8 / 15 · C# Advanced
Next
Pattern Matching in C#