Home C# / .NET Span and Memory in C# — Zero-Allocation Slicing Explained

Span and Memory in C# — Zero-Allocation Slicing Explained

In Plain English 🔥
Imagine you have a massive sandwich and you want to give someone 'just the middle section' — you don't make a brand new sandwich, you just point to where the middle starts and ends. Span is that pointing finger. It says 'here's a window into existing memory' without copying a single byte. Memory is the same idea, but you can pass that window to a background thread and it won't blow up on you.
⚡ Quick Answer
Imagine you have a massive sandwich and you want to give someone 'just the middle section' — you don't make a brand new sandwich, you just point to where the middle starts and ends. Span is that pointing finger. It says 'here's a window into existing memory' without copying a single byte. Memory is the same idea, but you can pass that window to a background thread and it won't blow up on you.

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 and Memory 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 and Memory 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 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 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 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 the universal buffer abstraction in modern .NET.

SpanInternals.cs · CSHARP
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
using System;
using System.Runtime.InteropServices;

public static class SpanInternalsDemo
{
    public static void Run()
    {
        // --- Scenario 1: Span over a heap-allocated array (most common) ---
        int[] heapArray = { 10, 20, 30, 40, 50, 60 };

        // No allocation here — Span is just a pointer + length on the stack
        Span<int> fullView = heapArray;
        Span<int> middleSlice = fullView.Slice(1, 4); // points at index 1, length 4

        Console.WriteLine("Middle slice from heap array:");
        foreach (int value in middleSlice)
            Console.Write($"{value} "); // 20 30 40 50
        Console.WriteLine();

        // Mutating through the Span mutates the original array — they share memory
        middleSlice[0] = 999;
        Console.WriteLine($"heapArray[1] after mutation via Span: {heapArray[1]}"); // 999

        // --- Scenario 2: Span over a stackalloc buffer (truly zero heap allocation) ---
        // stackalloc is only safe inside unsafe or with Span — Span wraps it safely
        Span<byte> stackBuffer = stackalloc byte[128];
        stackBuffer.Fill(0xFF); // fill all 128 bytes with 255

        Console.WriteLine($"\nFirst byte of stack buffer: {stackBuffer[0]}");  // 255
        Console.WriteLine($"Stack buffer length: {stackBuffer.Length}");        // 128

        // --- Scenario 3: Span over unmanaged memory ---
        nint unmanagedPtr = Marshal.AllocHGlobal(64);
        try
        {
            // Wrapping unmanaged memory in a Span — no GC involvement at all
            unsafe
            {
                Span<byte> unmanagedView = new Span<byte>((void*)unmanagedPtr, 64);
                unmanagedView.Clear(); // zero it out safely
                unmanagedView[0] = 42;
                Console.WriteLine($"\nUnmanaged byte[0]: {unmanagedView[0]}"); // 42
            }
        }
        finally
        {
            Marshal.FreeHGlobal(unmanagedPtr); // you must still manage lifetime manually
        }

        // --- Slicing a ReadOnlySpan<char> from a string (the parser pattern) ---
        string csvLine = "Alice,30,Engineer";
        ReadOnlySpan<char> lineSpan = csvLine.AsSpan();

        int firstComma = lineSpan.IndexOf(',');
        ReadOnlySpan<char> nameField = lineSpan.Slice(0, firstComma); // no String.Substring!

        // SequenceEqual lets you compare without allocating a new string
        bool isAlice = nameField.SequenceEqual("Alice");
        Console.WriteLine($"\nName field is 'Alice': {isAlice}"); // True
    }
}
▶ Output
Middle slice from heap array:
20 30 40 50
heapArray[1] after mutation via Span: 999

First byte of stack buffer: 255
Stack buffer length: 128

Unmanaged byte[0]: 42

Name field is 'Alice': True
⚠️
Watch Out: Span is Stack-Only — AlwaysIf you try to store a Span in a class field, async method, or iterator, you'll get CS8345 or CS4012 at compile time. This isn't a quirk — it's a deliberate safety rule. If Span could live on the heap, it could outlive a stackalloc buffer it points to, causing a dangling pointer. The constraint is the safety mechanism.

Memory and ReadOnlyMemory — When You Need to Cross Async Boundaries

Span 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 simply can't cross an await boundary. This is where Memory comes in.

Memory 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, or string), plus an index and length. When you actually want to process the data, you call .Span on it to get a Span at that point — that's where the 'read through a stack-safe view' pattern kicks in.

The key production pattern is: accept Memory in your async method signatures, pass Span into your synchronous helper methods that do the actual parsing or computation. This keeps allocations zero while remaining async-friendly. IMemoryOwner and MemoryPool 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.

MemoryAsyncPipeline.cs · CSHARP
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182
using System;
using System.Buffers;
using System.Text;
using System.Threading.Tasks;

public static class MemoryAsyncPipelineDemo
{
    // Simulates reading a chunk of data asynchronously (e.g. from a socket)
    private static async Task<int> SimulateReadAsync(Memory<byte> destinationBuffer)
    {
        await Task.Delay(1); // represents real async I/O

        // Write some fake "received" data into the buffer
        byte[] fakePayload = Encoding.UTF8.GetBytes("HELLO:World");
        fakePayload.CopyTo(destinationBuffer);
        return fakePayload.Length; // return how many bytes were "read"
    }

    // Synchronous parser — takes Span<byte> because it never awaits
    private static (string command, string argument) ParseProtocolFrame(ReadOnlySpan<byte> frame)
    {
        int separatorIndex = frame.IndexOf((byte)':');
        if (separatorIndex < 0)
            return (Encoding.UTF8.GetString(frame), string.Empty);

        // Slice without any allocation — we're just adjusting a pointer + length
        ReadOnlySpan<byte> commandBytes = frame.Slice(0, separatorIndex);
        ReadOnlySpan<byte> argumentBytes = frame.Slice(separatorIndex + 1);

        // The only allocation is the final string we return — unavoidable for output
        return (Encoding.UTF8.GetString(commandBytes), Encoding.UTF8.GetString(argumentBytes));
    }

    public static async Task RunAsync()
    {
        // MemoryPool<byte>.Shared gives us a pooled buffer — no new byte[] on the heap
        using IMemoryOwner<byte> rentedBuffer = MemoryPool<byte>.Shared.Rent(minimumLength: 256);

        Console.WriteLine($"Rented buffer size: {rentedBuffer.Memory.Length} bytes");

        // Memory<byte> crosses the await boundary safely (it's a normal struct)
        int bytesRead = await SimulateReadAsync(rentedBuffer.Memory);

        Console.WriteLine($"Bytes received: {bytesRead}");

        // Now we're back from await — call .Span to get a synchronous view
        // Slice to only the bytes we actually received (don't parse garbage)
        ReadOnlySpan<byte> receivedData = rentedBuffer.Memory.Span.Slice(0, bytesRead);

        // Pass the Span to our zero-allocation parser
        var (command, argument) = ParseProtocolFrame(receivedData);

        Console.WriteLine($"Command : {command}");
        Console.WriteLine($"Argument: {argument}");

        // IMemoryOwner is IDisposable — the 'using' returns the buffer to the pool
        // No GC pressure from the buffer itself
    }

    // Demonstrating storing Memory<T> in a class field — impossible with Span<T>
    public class MessageBuffer
    {
        private readonly IMemoryOwner<byte> _owner;

        // Memory<byte> as a field: perfectly legal
        public Memory<byte> Data => _owner.Memory;

        public MessageBuffer(int capacity)
        {
            _owner = MemoryPool<byte>.Shared.Rent(capacity);
        }

        // Get a Span when you need to read/write synchronously
        public void WriteAscii(string text)
        {
            Span<byte> writableView = Data.Span; // this line would fail if Data were a field of type Span
            Encoding.ASCII.GetBytes(text, writableView);
        }

        public void Dispose() => _owner.Dispose();
    }
}
▶ Output
Rented buffer size: 256 bytes
Bytes received: 11
Command : HELLO
Argument: World
⚠️
Pro Tip: The Golden Rule of Memory vs SpanUse Memory in method signatures that contain await or store state. Use Span in method signatures that do the actual computation synchronously. Calling .Span on a Memory is free at runtime — it's just reading two fields. Design your API boundary around this split and you'll get async safety AND zero-allocation performance.

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 by repeatedly finding delimiters and slicing — no Split(), no Substring(), no List intermediate collection. We use a fixed-size Span 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.

ZeroAllocationCsvParser.cs · CSHARP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
using System;
using System.Runtime.CompilerServices;

public static class ZeroAllocationCsvParser
{
    private const int MaxFieldsPerRow = 32; // stack-allocate space for up to 32 fields

    /// <summary>
    /// Parses a single CSV line into its fields.
    /// Allocates ONLY the returned string array — all intermediate work is on the stack.
    /// </summary>
    public static string[] ParseLine(ReadOnlySpan<char> csvLine, char delimiter = ',')
    {
        if (csvLine.IsEmpty)
            return Array.Empty<string>();

        // Stack-allocate an array of Range structs to track field boundaries
        // Range is a lightweight (int start, int end) struct — no heap allocation
        Span<Range> fieldRanges = stackalloc Range[MaxFieldsPerRow];
        int fieldCount = 0;
        int currentStart = 0;

        for (int charIndex = 0; charIndex <= csvLine.Length; charIndex++)
        {
            // Treat end-of-span as a virtual delimiter to capture the last field
            bool isDelimiter = charIndex == csvLine.Length || csvLine[charIndex] == delimiter;

            if (isDelimiter)
            {
                if (fieldCount >= MaxFieldsPerRow)
                    throw new InvalidOperationException(
                        $"CSV line exceeds maximum of {MaxFieldsPerRow} fields.");

                // Record the range of this field — no string created yet
                fieldRanges[fieldCount++] = new Range(currentStart, charIndex);
                currentStart = charIndex + 1;
            }
        }

        // Only NOW do we allocate — creating the output strings the caller will use
        string[] results = new string[fieldCount];
        for (int fieldIndex = 0; fieldIndex < fieldCount; fieldIndex++)
        {
            ReadOnlySpan<char> fieldSpan = csvLine[fieldRanges[fieldIndex]];

            // Trim whitespace without allocating — Trim() on a span returns a span
            ReadOnlySpan<char> trimmedField = fieldSpan.Trim();

            // new string(ReadOnlySpan<char>) — this is the only heap allocation per field
            results[fieldIndex] = trimmedField.IsEmpty ? string.Empty : new string(trimmedField);
        }

        return results;
    }

    /// <summary>
    /// Processes each field without materializing ANY strings — peak zero-allocation.
    /// Use this when you're doing numeric parsing, validation, or routing — not string output.
    /// </summary>
    public static void ProcessFieldsWithoutAllocation(
        ReadOnlySpan<char> csvLine,
        char delimiter,
        FieldProcessor processor)
    {
        int currentStart = 0;
        int fieldIndex = 0;

        for (int i = 0; i <= csvLine.Length; i++)
        {
            if (i == csvLine.Length || csvLine[i] == delimiter)
            {
                ReadOnlySpan<char> field = csvLine.Slice(currentStart, i - currentStart).Trim();
                processor(fieldIndex++, field); // callback receives a span — no allocation
                currentStart = i + 1;
            }
        }
    }

    // Delegate type that receives a field as ReadOnlySpan<char> — avoids string allocation
    public delegate void FieldProcessor(int index, ReadOnlySpan<char> fieldValue);
}

// --- Demo entry point ---
public static class CsvParserDemo
{
    public static void Run()
    {
        string csvRow = "  Alice , 30 , Senior Engineer , London , true ";
        ReadOnlySpan<char> rowSpan = csvRow.AsSpan();

        // --- Version 1: Returns string[] — one allocation per field + the array ---
        Console.WriteLine("=== ParseLine (string[] output) ===");
        string[] fields = ZeroAllocationCsvParser.ParseLine(rowSpan);
        for (int i = 0; i < fields.Length; i++)
            Console.WriteLine($"  Field[{i}]: '{fields[i]}'");

        // --- Version 2: Callback with span — zero extra allocations ---
        Console.WriteLine("\n=== ProcessFieldsWithoutAllocation (zero alloc) ===");
        ZeroAllocationCsvParser.ProcessFieldsWithoutAllocation(
            rowSpan,
            delimiter: ',',
            processor: (index, fieldSpan) =>
            {
                // Parse an int inline without creating a string
                if (index == 1 && int.TryParse(fieldSpan, out int age))
                    Console.WriteLine($"  Age parsed inline (no string): {age}");
                else
                    Console.WriteLine($"  Field[{index}] length: {fieldSpan.Length} chars");
            }
        );

        // --- Demonstrate that spans correctly handle quoted/edge CSV ---
        Console.WriteLine("\n=== Edge case: single field ===");
        string[] singleField = ZeroAllocationCsvParser.ParseLine("OnlyOneField".AsSpan());
        Console.WriteLine($"  Fields found: {singleField.Length}, Value: '{singleField[0]}'");
    }
}
▶ Output
=== ParseLine (string[] output) ===
Field[0]: 'Alice'
Field[1]: '30'
Field[2]: 'Senior Engineer'
Field[3]: 'London'
Field[4]: 'true'

=== ProcessFieldsWithoutAllocation (zero alloc) ===
Field[0] length: 5 chars
Age parsed inline (no string): 30
Field[2] length: 15 chars
Field[3] length: 6 chars
Field[4] length: 4 chars

=== Edge case: single field ===
Fields found: 1, Value: 'OnlyOneField'
🔥
Interview Gold: Why stackalloc is Safe Herestackalloc inside a method is safe when wrapped in a Span because the Span's lifetime is tied to the method's stack frame. The compiler prevents you from returning that Span or storing it in a longer-lived variable. If you tried to return fieldRanges from this method, you'd get a compile-time error CS8352 — the safety net catches you before the runtime ever runs.

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 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 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.

SpanBenchmarkAndRules.cs · CSHARP
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
using System;
using System.Buffers;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

// Run with: dotnet run -c Release
// Add BenchmarkDotNet NuGet package first

[MemoryDiagnoser] // shows allocation column in BenchmarkDotNet output
[SimpleJob]
public class CsvParsingBenchmark
{
    private const string SampleRow = "Alice,30,Senior Engineer,London,true";

    // BASELINE: traditional approach — allocates on every call
    [Benchmark(Baseline = true)]
    public string[] TraditionalSplit()
    {
        // String.Split allocates a new string[] AND a new string for each field
        return SampleRow.Split(',');
    }

    // OPTIMISED: Span-based parser from the previous section
    [Benchmark]
    public string[] SpanBasedParse()
    {
        return ZeroAllocationCsvParser.ParseLine(SampleRow.AsSpan());
    }

    // PEAK: process without materialising any strings at all
    [Benchmark]
    public int SpanBasedCountCharsInAllFields()
    {
        int totalChars = 0;
        ZeroAllocationCsvParser.ProcessFieldsWithoutAllocation(
            SampleRow.AsSpan(),
            delimiter: ',',
            processor: (_, field) => totalChars += field.Length
        );
        return totalChars;
    }
}

// ============================================================
// PRODUCTION RULES — read these before shipping Span-based code
// ============================================================
public static class ProductionRules
{
    public static void Demonstrate()
    {
        // RULE 1: Never store the result of .AsSpan() across an await
        // This won't compile — it's a safety guarantee, not a limitation
        // async Task BadAsync(string input) {
        //     ReadOnlySpan<char> span = input.AsSpan(); // compile error CS4007
        //     await Task.Delay(1);
        //     Console.WriteLine(span[0]); // can never reach here
        // }

        // RULE 2: MemoryPool<T>.Shared is cheaper than new byte[] for buffers > 85KB
        // Arrays > 85KB go to the Large Object Heap (LOH) — renting avoids that entirely
        using IMemoryOwner<byte> largeBuffer = MemoryPool<byte>.Shared.Rent(100_000);
        Console.WriteLine($"Rented large buffer: {largeBuffer.Memory.Length} bytes — no LOH pressure");

        // RULE 3: ReadOnlySpan<char> comparisons are case-sensitive by default
        // Use MemoryExtensions.Equals for case-insensitive comparison
        ReadOnlySpan<char> headerName = "Content-Type".AsSpan();
        ReadOnlySpan<char> incoming = "content-type".AsSpan();

        bool caseSensitive = headerName.SequenceEqual(incoming);  // false
        bool caseInsensitive = MemoryExtensions.Equals(
            headerName, incoming, StringComparison.OrdinalIgnoreCase); // true

        Console.WriteLine($"Case-sensitive match: {caseSensitive}");    // False
        Console.WriteLine($"Case-insensitive match: {caseInsensitive}"); // True

        // RULE 4: stackalloc size should be capped and validated at runtime
        // Never stackalloc based on untrusted user input — stack overflow is unrecoverable
        int userInputLength = 64; // imagine this came from a request
        if (userInputLength > 1024)
            throw new ArgumentException("Input too large for stack buffer");

        Span<char> safeStackBuffer = stackalloc char[userInputLength];
        Console.WriteLine($"Safe stack buffer ready: {safeStackBuffer.Length} chars");
    }
}
▶ Output
// BenchmarkDotNet output (approximate — run Release build for real numbers):
// | Method | Mean | Allocated |
// |-------------------------------|-----------|----------:|
// | TraditionalSplit | 185.3 ns | 240 B |
// | SpanBasedParse | 98.7 ns | 176 B |
// | SpanBasedCountCharsInAllFields| 41.2 ns | 0 B |

// ProductionRules.Demonstrate() output:
Rented large buffer: 131072 bytes — no LOH pressure
Case-sensitive match: False
Case-insensitive match: True
Safe stack buffer ready: 64 chars
⚠️
Watch Out: stackalloc Size Must Never Come From Untrusted InputA stack overflow from a giant stackalloc kills your entire process immediately — no exception, no catch block, no graceful shutdown. Always validate the size against a hard-coded maximum before stackallocating. A cap of 1024–4096 bytes is typical for most parsing scenarios. If you need more, use MemoryPool.Shared instead.
Feature / AspectSpanMemory
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
Get a Span from itIs a SpanCall .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 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 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 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.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

⚠ Common Mistakes to Avoid

  • Mistake 1: 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 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
  • Mistake 2: Using Span 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 with Memory in the method signature or class field; call .Span inside each synchronous sub-method when you need to read or write the actual data
  • Mistake 3: 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.Shared.Rent() for larger payloads

Interview Questions on This Topic

  • QWhy is Span 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.
  • 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 and Memory, and what allocation profile would you expect after the change?
  • QWhat's the difference between Memory.Span and directly using a Span, and why does calling .Span inside an async method work when storing a Span field does not?

Frequently Asked Questions

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

No — Span 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 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 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 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 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.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousAttributes in C#Next →Pattern Matching in C#
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged