Span and Memory in .NET: Zero-Copy Data Processing

Span and Memory are .NET generic types that represent contiguous regions of memory without owning or copying it. They enable you to slice arrays, strings, and native memory buffers with zero allocatio

Introduction#

Span and Memory are .NET generic types that represent contiguous regions of memory without owning or copying it. They enable you to slice arrays, strings, and native memory buffers with zero allocation overhead. Understanding them is essential for high-performance .NET code — HTTP servers, parsers, serializers, and protocol implementations.

The Problem They Solve#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// WITHOUT Span: every substring allocates a new string
string input = "name=Alice&age=30&city=NYC";

string[] parts = input.Split('&');       // allocates string[]
foreach (var part in parts)
{
    string[] kv = part.Split('=');       // allocates string[] per pair
    string key = kv[0];                  // allocates string
    string value = kv[1];               // allocates string
    ProcessKeyValue(key, value);
}
// 5 string allocations per key-value pair + GC pressure

// WITH Span: zero allocations
ReadOnlySpan<char> span = input.AsSpan();
while (!span.IsEmpty)
{
    int amp = span.IndexOf('&');
    ReadOnlySpan<char> pair = amp < 0 ? span : span[..amp];
    span = amp < 0 ? default : span[(amp + 1)..];

    int eq = pair.IndexOf('=');
    if (eq < 0) continue;
    ReadOnlySpan<char> key = pair[..eq];
    ReadOnlySpan<char> value = pair[(eq + 1)..];
    ProcessKeyValue(key, value); // pass spans, not strings
}
// Zero heap allocations

Span Fundamentals#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Span<T> is a ref struct — lives only on the stack
// Can point to: array, stack memory, unmanaged memory

// Slice an array (no copy)
int[] array = { 1, 2, 3, 4, 5, 6, 7, 8 };
Span<int> span = array.AsSpan();
Span<int> middle = span[2..6]; // { 3, 4, 5, 6 } — no allocation

// Modifications to the span modify the original array
middle[0] = 99;
Console.WriteLine(array[2]); // 99

// Stack allocation with stackalloc
Span<byte> buffer = stackalloc byte[256]; // on the stack, no GC
buffer.Fill(0);
FillHeader(buffer[..16]);
FillPayload(buffer[16..]);

// ReadOnlySpan for immutable views
ReadOnlySpan<byte> readOnly = buffer; // implicit conversion

// String to span — no allocation
string s = "hello world";
ReadOnlySpan<char> chars = s.AsSpan();
ReadOnlySpan<char> word = chars[..5]; // "hello" — no allocation

Memory vs Span#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// Span<T>: ref struct, stack-only, cannot be stored in fields or across await
// Memory<T>: regular struct, can be stored in fields, can cross await boundaries

public class BinaryParser
{
    private readonly Memory<byte> _buffer; // OK: Memory in a field

    public BinaryParser(byte[] data)
    {
        _buffer = data.AsMemory();
    }

    public async Task<int> ParseAsync()
    {
        // Can use Memory<T> across await
        await Task.Yield();

        // Get Span from Memory when you need to work with the data
        Span<byte> span = _buffer.Span;
        return BinaryPrimitives.ReadInt32BigEndian(span);
    }
}

// Span<T> CANNOT cross await
public async Task WrongAsync(byte[] data)
{
    Span<byte> span = data.AsSpan(); // fine here

    await Task.Delay(100); // ERROR: cannot use span across await
    // span is inaccessible here
}

// Correct: use Memory<T> across await, Span<T> for synchronous work
public async Task CorrectAsync(byte[] data)
{
    Memory<byte> memory = data.AsMemory();

    await Task.Delay(100);

    Span<byte> span = memory.Span; // get span for sync processing
    ProcessSync(span);
}

Parsing Binary Protocols#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
using System.Buffers.Binary;

// Parse a simple binary frame:
// [4 bytes: total length] [2 bytes: message type] [N bytes: payload]

public record Frame(ushort MessageType, ReadOnlyMemory<byte> Payload);

public static bool TryParseFrame(ReadOnlySpan<byte> data, out Frame frame, out int consumed)
{
    frame = default;
    consumed = 0;

    if (data.Length < 6) // minimum header size
        return false;

    int totalLength = BinaryPrimitives.ReadInt32BigEndian(data);
    if (data.Length < totalLength)
        return false; // incomplete frame

    ushort messageType = BinaryPrimitives.ReadUInt16BigEndian(data[4..]);
    ReadOnlySpan<byte> payload = data[6..totalLength];

    frame = new Frame(messageType, payload.ToArray()); // only allocate here
    consumed = totalLength;
    return true;
}

// Processing a stream of frames
public static IEnumerable<Frame> ParseFrames(ReadOnlySpan<byte> buffer)
{
    while (!buffer.IsEmpty)
    {
        if (!TryParseFrame(buffer, out var frame, out int consumed))
            break;

        yield return frame;
        buffer = buffer[consumed..];
    }
}

String Parsing Without Allocation#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
using System;

public static class HttpHeaderParser
{
    // Parse "Content-Type: application/json; charset=utf-8"
    // Returns the media type without allocating strings
    public static bool TryGetMediaType(
        ReadOnlySpan<char> headerValue,
        out ReadOnlySpan<char> mediaType)
    {
        mediaType = default;

        int semicolon = headerValue.IndexOf(';');
        ReadOnlySpan<char> candidate = semicolon < 0
            ? headerValue
            : headerValue[..semicolon];

        mediaType = candidate.Trim();
        return !mediaType.IsEmpty;
    }

    // Parse query string key=value pairs
    public static bool TryGetQueryParam(
        ReadOnlySpan<char> query,
        ReadOnlySpan<char> key,
        out ReadOnlySpan<char> value)
    {
        value = default;

        while (!query.IsEmpty)
        {
            int amp = query.IndexOf('&');
            ReadOnlySpan<char> pair = amp < 0 ? query : query[..amp];
            query = amp < 0 ? default : query[(amp + 1)..];

            int eq = pair.IndexOf('=');
            if (eq < 0) continue;

            if (pair[..eq].Equals(key, StringComparison.OrdinalIgnoreCase))
            {
                value = pair[(eq + 1)..];
                return true;
            }
        }

        return false;
    }
}

// Usage
string header = "application/json; charset=utf-8";
HttpHeaderParser.TryGetMediaType(header.AsSpan(), out var mediaType);
// mediaType is "application/json" — no string allocation

ArrayPool and IBufferWriter#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
using System.Buffers;

// Rent large buffers from a pool instead of allocating
public static async Task<int> ReadFrameAsync(Stream stream, CancellationToken ct)
{
    byte[] buffer = ArrayPool<byte>.Shared.Rent(4096);
    try
    {
        int read = await stream.ReadAsync(buffer.AsMemory(0, 4096), ct);
        Span<byte> data = buffer.AsSpan(0, read);

        // Process the data
        return BinaryPrimitives.ReadInt32BigEndian(data);
    }
    finally
    {
        ArrayPool<byte>.Shared.Return(buffer); // return to pool
    }
}

// IBufferWriter<byte> for building output without intermediate copies
public static void WriteFrame(IBufferWriter<byte> writer, ushort type, ReadOnlySpan<byte> payload)
{
    int totalLength = 6 + payload.Length;

    Span<byte> header = writer.GetSpan(6);
    BinaryPrimitives.WriteInt32BigEndian(header, totalLength);
    BinaryPrimitives.WriteUInt16BigEndian(header[4..], type);
    writer.Advance(6);

    Span<byte> dest = writer.GetSpan(payload.Length);
    payload.CopyTo(dest);
    writer.Advance(payload.Length);
}

MemoryMarshal for Low-Level Operations#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
using System.Runtime.InteropServices;

// Reinterpret cast between types — for protocol work
public static float ReadFloat(ReadOnlySpan<byte> data)
{
    // Read 4 bytes as float without allocation
    return BinaryPrimitives.ReadSingleBigEndian(data);
}

// Cast byte span to struct span (zero-copy struct array parsing)
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct SensorReading
{
    public uint Timestamp;
    public float Value;
    public byte Channel;
}

public static ReadOnlySpan<SensorReading> ParseReadings(ReadOnlySpan<byte> data)
{
    // Treat raw bytes as an array of SensorReading structs
    // Zero allocation, zero copy
    return MemoryMarshal.Cast<byte, SensorReading>(data);
}

Benchmarks#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
using BenchmarkDotNet.Attributes;

[MemoryDiagnoser]
public class ParsingBenchmarks
{
    private const string Input = "name=Alice&age=30&city=NYC&country=US";

    [Benchmark(Baseline = true)]
    public string WithStrings()
    {
        var parts = Input.Split('&');
        foreach (var part in parts)
        {
            var kv = part.Split('=');
            if (kv[0] == "city") return kv[1];
        }
        return null;
    }

    [Benchmark]
    public bool WithSpan()
    {
        ReadOnlySpan<char> span = Input.AsSpan();
        while (!span.IsEmpty)
        {
            int amp = span.IndexOf('&');
            ReadOnlySpan<char> pair = amp < 0 ? span : span[..amp];
            span = amp < 0 ? default : span[(amp + 1)..];

            int eq = pair.IndexOf('=');
            if (eq < 0) continue;

            if (pair[..eq].Equals("city", StringComparison.Ordinal))
                return true;
        }
        return false;
    }
}

// Results (approximate):
// WithStrings: 250ns, 320B allocated
// WithSpan:     80ns,   0B allocated

Conclusion#

Span<T> eliminates allocations in performance-critical paths by providing a slice view over any contiguous memory — arrays, strings, stack buffers, or native pointers. Use Memory<T> when you need to store spans across await boundaries. Combine with ArrayPool<T> for buffer reuse and IBufferWriter<T> for zero-copy output. These types are foundational to high-performance .NET code and are used extensively in ASP.NET Core’s HTTP pipeline, System.Text.Json, and System.IO.Pipelines.

Contents