Safe stackalloc: span usage, size limits, hybrid pooling pattern, loops and SkipLocalsInit.
stackalloc reserves memory on the current thread's stack; assigned to a Span<T> (C# 7.2+) it needs no unsafe code, is freed automatically when the method returns and costs nothing for the GC. It only works for unmanaged element types (byte, char, int, unmanaged structs). The stack is small (commonly around 1 MB per thread on Windows, and other platforms differ), and a stack overflow cannot be caught: the process dies. So use it only for small, bounded sizes, with the hybrid pattern: stackalloc below a fixed threshold (for example 256 bytes to 1 KB), otherwise rent from ArrayPool<T> and return it in finally. Never stackalloc in a loop, because memory is only released when the method returns. [SkipLocalsInit] skips zeroing the buffer for speed, which is safe only if you write before you read.
stackalloc allocates a block of memory on the current thread's stack, which is freed automatically when the method returns — no GC involvement. In modern C# you assign it to a Span<T>, which keeps it memory-safe (bounds checked, no pointers). It's excellent for small, bounded, temporary buffers. The danger is that the stack is small and a stack overflow kills the process, so safe usage means capping the size and falling back to ArrayPool<T>, never allocating in loops, and understanding SkipLocalsInit.
stackalloc into Span<T>Span<byte> hash = stackalloc byte[32]; // SHA-256 output
SHA256.HashData(input, hash);
string hex = Convert.ToHexString(hash);
Span<char> buffer = stackalloc char[64];
if (value.TryFormat(buffer, out int written, "N2", CultureInfo.InvariantCulture))
writer.Write(buffer[..written]);
unsafe keyword needed when assigning to a Span<T>/ReadOnlySpan<T> (C# 7.2+).ref struct, so it can't escape the method (can't be returned, stored or used across await).stackalloc can be used in nested expressions (Span<int> s = n < 128 ? stackalloc int[n] : new int[n];).The element type must be unmanaged: primitives, enums, pointers, or structs containing only unmanaged fields (recursively). You can't stackalloc string[] or arrays of structs containing references, because the GC doesn't scan stack-allocated buffers for references.
StackOverflowException can't be caught — the runtime terminates the process immediately (no finally, no logging). In a web app, every in-flight request on that instance fails and the container restarts.stackalloc with a size controlled by user input or unbounded data.const int StackThreshold = 256; // chars
char[]? rented = null;
Span<char> buffer = length <= StackThreshold
? stackalloc char[StackThreshold]
: (rented = ArrayPool<char>.Shared.Rent(length));
try
{
buffer = buffer[..length];
Fill(buffer);
return Process(buffer);
}
finally
{
if (rented is not null) ArrayPool<char>.Shared.Return(rented);
}
stackalloc (here StackThreshold) so the JIT can optimise, then slice.finally. Clear them first if they held sensitive data.stackalloc in loopsfor (int i = 0; i < items.Count; i++)
{
Span<byte> tmp = stackalloc byte[256]; // NOT freed per iteration!
}
Stack memory from stackalloc is released only when the method returns, not at the end of the loop body. Each iteration allocates more stack — 1,000 iterations × 256 bytes = 256 KB; a larger loop overflows the stack. Hoist the stackalloc outside the loop and reuse the buffer (analyser CA2014 warns about this).
SkipLocalsInitBy default, the C# compiler emits the localsinit flag, so the runtime zeroes stack-allocated memory and locals. For large stackalloc buffers that you'll fully overwrite, zeroing is wasted work. [SkipLocalsInit] (on a method, type, or module; requires <AllowUnsafeBlocks>true</AllowUnsafeBlocks>) removes the flag:
[SkipLocalsInit]
static int Encode(ReadOnlySpan<char> input, Span<byte> output) { Span<byte> scratch = stackalloc byte[512]; ... }
Risk: the buffer contains garbage from previous stack frames — reading before writing can leak data or produce nondeterministic bugs. Use only when every byte is written before being read.
ArrayPool diminishes while risk grows.A service computed HMAC signatures for webhooks using stackalloc byte[payload.Length] for the encoded payload. A partner occasionally sent multi-megabyte payloads, and the process crashed with a stack overflow — no exception logs, just container restarts and failed in-flight requests. We switched to the hybrid pattern (stack for ≤ 1 KB, ArrayPool above) and computed the HMAC incrementally over streams for large payloads. Crashes stopped; small payloads stayed allocation-free.
The interviewer checks you can use stack memory for performance without risking crashes.
Green flags
stackalloc with Span<T> and knows the unmanaged requirement.finally.SkipLocalsInit does.Red flags
stackalloc compiles toSpan<byte> s = stackalloc byte[32]; emits the IL localloc instruction, which bumps the stack pointer by the requested size (rounded/aligned) and returns a pointer; the compiler wraps it in a span. With localsinit, the runtime zeroes the block. The JIT may also emit stack probes for large allocations to touch guard pages correctly.
stackalloc lets the JIT allocate in the prologue and sometimes avoid dynamic stack adjustment.stackalloc T[Const] then slice to the needed length.On overflow, the OS raises a guard-page fault; the CLR can't reliably run managed code (no stack left), so it fails fast. In containers, the process exits with a signal/exit code; Kubernetes/ACA restarts it. Logs show only the crash, sometimes with a "Stack overflow" message in stderr.
| Buffer need | Option |
|---|---|
| Small, bounded, temporary | stackalloc |
| Medium/large temporary | ArrayPool<T>.Shared.Rent |
| Async or stored | MemoryPool<T>/arrays |
| Fixed small inline buffers in structs | [InlineArray(N)] (C# 12) |
| Formatting strings | string.Create, interpolated handlers, ValueStringBuilder |
stackalloc in loops.stackalloc in code reviews.Applied at module level ([module: SkipLocalsInit]), it affects all methods — including locals that the C# compiler normally relies on being zero in some unsafe scenarios. Safe C# still requires definite assignment of locals, so ordinary locals are fine; the main exposure is stackalloc buffers and Unsafe.SkipInit.
stackalloc inside a loop and watch CA2014; run with many iterations to see the crash (in a throwaway process).[SkipLocalsInit] and read a buffer before writing to observe garbage.using System.Buffers;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Text;
public static class Hashing
{
private const int StackLimit = 512; // fixed, small, not controlled by input
[SkipLocalsInit] // needs <AllowUnsafeBlocks>true</AllowUnsafeBlocks>; we always write before reading
public static string Sha256Hex(string input)
{
int maxBytes = Encoding.UTF8.GetMaxByteCount(input.Length);
byte[]? rented = null;
Span<byte> utf8 = maxBytes <= StackLimit
? stackalloc byte[StackLimit]
: (rented = ArrayPool<byte>.Shared.Rent(maxBytes));
try
{
int written = Encoding.UTF8.GetBytes(input, utf8);
Span<byte> hash = stackalloc byte[SHA256.HashSizeInBytes]; // 32 bytes: always safe
SHA256.HashData(utf8[..written], hash);
return Convert.ToHexString(hash);
}
finally
{
if (rented is not null) ArrayPool<byte>.Shared.Return(rented, clearArray: true); // may hold user data
}
}
public static int CountVowels(IEnumerable<string> words)
{
Span<char> lower = stackalloc char[64]; // allocated ONCE, outside the loop
int count = 0;
foreach (string word in words)
{
ReadOnlySpan<char> source = word.AsSpan(0, Math.Min(word.Length, lower.Length));
int n = source.ToLowerInvariant(lower);
foreach (char c in lower[..n]) if ("aeiou".Contains(c)) count++;
}
return count;
}
}
public static class Program
{
public static void Main()
{
Console.WriteLine(Hashing.Sha256Hex("hello")); // stack path
Console.WriteLine(Hashing.Sha256Hex(new string('x', 10_000))); // pooled path, no stack risk
Console.WriteLine(Hashing.CountVowels(["Sydney", "Melbourne", "Adelaide"]));
}
}
stackalloc from inputWhat happens: stackalloc byte[request.Length].
Why it is wrong: large inputs overflow the stack and kill the process.
Do instead: cap with a constant threshold and use ArrayPool otherwise.
stackalloc in loopsWhat happens: allocating a buffer inside each iteration. Why it is wrong: stack memory accumulates until the method returns. Do instead: hoist the buffer outside the loop (CA2014).
What happens: the fallback path rents and never returns.
Why it is wrong: pool exhaustion and more allocations.
Do instead: return in finally.
What happens: trying to return the span or keep it in a field. Why it is wrong: compile errors (and dangling memory if forced unsafely). Do instead: copy results out before returning.
SkipLocalsInit carelesslyWhat happens: reading parts of uninitialised buffers. Why it is wrong: garbage data, nondeterministic bugs, possible leaks. Do instead: only skip init when every element is written first.
What happens: stackalloc byte[16_384] in methods called from deep middleware.
Why it is wrong: combined stack use may overflow.
Do instead: keep stack buffers small.
What happens: processing the full threshold-sized buffer. Why it is wrong: includes garbage or zeroes beyond the data. Do instead: slice to the used length.
stackalloc where allocation doesn't matterWhat happens: micro-optimising rarely called code. Why it is wrong: adds risk and complexity. Do instead: use it only on measured hot paths.
stackalloc is the cheapest buffer in .NET, but its failure mode is the worst: an uncatchable process crash. That asymmetry drives the senior rules: fixed small sizes, never input-controlled, never in loops, and always a pooled fallback.
| Rule | Reason |
|---|---|
| Constant size, 256 B to 1 KB | predictable stack use, simpler JIT code |
| Pool fallback | large inputs stay safe |
| No loops or recursion | stack use must be bounded |
| SkipLocalsInit only with write-before-read | correctness and security |
| Clear pooled buffers holding secrets | data leaks between requests |
I also mention that the BCL uses exactly this pattern internally, so following it is idiomatic rather than clever.
The stack is a per-thread region that holds method frames: locals, arguments, return addresses and saved registers. Allocating is just moving the stack pointer, and everything is freed when the method returns. The heap is shared memory for objects whose lifetime is not tied to one method; the generational garbage collector frees them once they are unreachable.
The common line "value types live on the stack, reference types on the heap" is only half true. Storage follows the declaration: a value-type local usually lives on the stack (or in a register), but a value-type field lives inside its object on the heap, and boxing, lambdas that capture locals, iterators and async methods move data to the heap. Reference-type objects are conceptually heap objects (only the reference sits on the stack), although the current JIT's escape analysis can place a small object that provably never leaves the method on the stack as an optimisation. The language guarantees lifetime and copy semantics, not a location.
Pooling bugs are lifetime bugs: the pool gives a returned array to the next renter, so any code still using it reads or writes someone else's data. The usual causes are use after return (returning in finally while an async continuation, a queued Memory<T> or a lazy enumerable still uses the array), double return (two renters then share one array), treating array.Length as the data length (Rent returns an array at least as large as requested, with old contents), not clearing buffers that held secrets (Return(array, clearArray: true)), and returning arrays not rented from that pool. To debug: reproduce under concurrency, add canaries or a poisoning wrapper pool in tests that fills returned arrays with a marker and detects double returns, trace System.Buffers.ArrayPoolEventSource events, and review every rent for a single owner and a single return in finally.
Span<T> is a ref struct holding a reference and a length: a typed, bounds-checked view over contiguous memory, whether that memory is an array, a stackalloc buffer, native memory or part of a string (through ReadOnlySpan<char>). Slicing (span[2..6], Slice) creates a new view without copying, so parsers and formatters can work on pieces of a buffer with no allocations. Because it can point at the stack, it is stack-only: it cannot be a field of a class, be boxed, be captured by a lambda, or live across await/yield (C# 13 allows span locals in async and iterator methods as long as they do not cross those points). Helpers in MemoryExtensions (IndexOf, SequenceEqual, Sort, Trim) are vectorised. CollectionsMarshal.AsSpan(list) exposes a list's backing array, but adding to the list invalidates it.
How to tell it (STAR)
**Situation:** our [API gateway] instances were crashing without any exception in the logs. **Task:** I was on call and had to stop the restarts. **Action:** I enabled crash dumps, found a stack overflow in a hashing helper that sized stackalloc from the header length, and replaced it with a fixed small stack buffer plus an ArrayPool fallback. **Result:** crashes stopped immediately, and our fuzz tests now include oversized inputs for every buffer helper.
Last updated 30 Oct 2026.