The legacy code tax
Most performance optimization work in .NET is not about writing clever assembly instructions or rewriting algorithms. It is about cleaning up after our past selves.
For years, C# encouraged a coding style that prioritized developer convenience over runtime efficiency. We parsed text by splitting strings, decoded binary packet segments by copying subarrays, and built payloads using string interpolation.
These patterns are simple to write and read, but they carry a heavy tax: Garbage Collector (GC) pressure.
Every string slice is a heap allocation. Every array copy is another Gen0 object to track. In high-throughput services, database interfaces, or network layers, these allocations quickly saturate the GC, leading to micro-stutters and increased latency.
In my introduction to Span
Now, let's look at how to apply spans to refactor three classic, allocation-heavy C# APIs into zero-allocation, high-performance code.
Case Study 1: Delimited string parsing (CSV / Log files)
Parsing delimited text lines (like logs, CSVs, or query strings) is a daily task.
The classic legacy pattern relies on string.Split(), string.Substring(), and int.Parse() on substrings.
The old, allocation-heavy way
public sealed class NaiveLogParser { public static LogRecord ParseLogLine(string line) { // 1. Allocates an array of strings (the parts) var parts = line.Split('|'); // 2. Allocates a temporary string for the substring var tsString = parts[0].Substring(1, parts[0].Length - 2); var timestamp = DateTime.Parse(tsString); // 3. Allocates a temporary string for Trim() var level = parts[1].Trim(); // 4. Parses directly, but parts[2] was already allocated var code = int.Parse(parts[2]); return new LogRecord(timestamp, level, code); } }
This short method triggers at least 5 heap allocations every time it parses a single line. If you are parsing a 100,000-line log file, that's half a million heap allocations just to extract basic data.
The zero-alloc span refactor
To make this allocation-free, we change the input from string to ReadOnlySpan<char>.
Instead of splitting the string into arrays, we walk the span using indexers and slice it.
Because slicing a span only creates a new view (a ref struct) over the original string memory,
zero allocations occur.
using System; public sealed class SpanLogParser { public static bool TryParseLogLine(ReadOnlySpan<char> line, out LogRecord record) { record = default; // Slice 1: Timestamp var firstPipe = line.IndexOf('|'); if (firstPipe == -1) return false; var tsSpan = line[..firstPipe]; // Strip quotes using span slicing (zero allocations!) if (tsSpan.StartsWith("\"") && tsSpan.EndsWith("\"")) { tsSpan = tsSpan[1..^1]; } // Use the span-compatible TryParse overload if (!DateTime.TryParse(tsSpan, out var timestamp)) return false; // Slice 2: Level var remaining = line[(firstPipe + 1)..]; var secondPipe = remaining.IndexOf('|'); if (secondPipe == -1) return false; // Trim() on a span returns a sliced ReadOnlySpan<char> (zero allocations!) var levelSpan = remaining[..secondPipe].Trim(); // Slice 3: Code var codeSpan = remaining[(secondPipe + 1)..]; if (!int.TryParse(codeSpan, out var code)) return false; // We only allocate the final strings that need to outlive the parsing stack record = new LogRecord(timestamp, levelSpan.ToString(), code); return true; } }
Why this works
line.IndexOf('|')returns an index without allocating.tsSpan = line[..firstPipe]slices the span by value (stack-only), creating no heap objects.DateTime.TryParseandint.TryParsehave native overloads that acceptReadOnlySpan<char>. They parse values directly out of the sliced segment without needing a intermediatestring.
Case Study 2: Custom binary packet decoding
When writing network protocols, we often need to parse fields out of raw bytes.
The naive approach slices bytes using Buffer.BlockCopy or LINQ operations, then deserializes them.
The old, allocation-heavy way
public sealed class NaivePacketReader { public static Packet ReadPacket(byte[] buffer, int offset) { // 1. Allocates a temporary byte array to read the payload length byte[] lengthBytes = new byte[4]; Buffer.BlockCopy(buffer, offset, lengthBytes, 0, 4); int length = BitConverter.ToInt32(lengthBytes, 0); // 2. Allocates another temporary byte array for the type flag byte[] typeBytes = new byte[2]; Buffer.BlockCopy(buffer, offset + 4, typeBytes, 0, 2); short type = BitConverter.ToInt16(typeBytes, 0); // 3. Allocates a fresh array for the payload byte[] payload = new byte[length]; Buffer.BlockCopy(buffer, offset + 6, payload, 0, length); return new Packet(length, type, payload); } }
This method allocates three separate array headers on the heap,
forcing the GC to track their lifetimes, and relies on Buffer.BlockCopy which has overhead.
The zero-alloc span refactor
We can wrap our raw buffer in ReadOnlySpan<byte> and read values directly out of
it using BinaryPrimitives. If we need the payload, we return it as a slice of the original buffer,
avoiding the allocation of a sub-array.
using System; using System.Buffers.Binary; public sealed class SpanPacketReader { public static bool TryReadPacket(ReadOnlySpan<byte> buffer, out PacketInfo info) { info = default; if (buffer.Length < 6) return false; // 1. Read primitives directly from the byte span (zero allocations, endianness-safe) int length = BinaryPrimitives.ReadInt32LittleEndian(buffer[..4]); short type = BinaryPrimitives.ReadInt16LittleEndian(buffer[4..6]); if (buffer.Length < 6 + length) return false; // 2. Represent the payload as a slice pointing directly to the original memory segment ReadOnlySpan<byte> payload = buffer.Slice(6, length); info = new PacketInfo(length, type, payload); return true; } }
Why this works
BinaryPrimitives.ReadInt32LittleEndianreads bytes directly from the span using pointer-like casts under the hood. No temporary arrays are instantiated.- The returned
PacketInforef struct holds aReadOnlySpan<byte>instead of abyte[]payload, meaning the consumer can process the payload directly out of the socket receive buffer without copying.
Case Study 3: Custom string formatting (Building payloads)
Formatting output strings (like constructing HTTP headers, database queries, or telemetry payloads)
is a major source of hidden heap allocations. String interpolation ($"...") allocates multiple
intermediate strings and boxes primitive value types before returning the final string.
The old, allocation-heavy way
public sealed class NaiveFormatter { public static string FormatTelemetryRow(string name, int id, float score) { // Allocates intermediate strings, boxes 'id' (int) and 'score' (float) to objects return $"METRIC:{name},{id},{score:F2}\n"; } }
The zero-alloc span refactor
Modern C# introduced ISpanFormattable and Utf8.TryWrite APIs.
These interfaces allow primitive types to format themselves directly
into caller-owned spans (such as a stack-allocated buffer or a rented memory block)
as UTF-8 bytes or characters, completely bypassing string allocations and type boxing.
using System; using System.Globalization; public sealed class SpanFormatter { // Write formatted data directly into a caller-provided span (e.g. stackalloc span) public static bool TryFormatTelemetry( Span<char> destination, out int charsWritten, string name, int id, float score) { charsWritten = 0; ReadOnlySpan<char> prefix = "METRIC:"; ReadOnlySpan<char> comma = ","; ReadOnlySpan<char> suffix = "\n"; // 1. Copy prefix if (!prefix.TryCopyTo(destination)) return false; charsWritten += prefix.Length; // 2. Copy name if (!name.AsSpan().TryCopyTo(destination[charsWritten..])) return false; charsWritten += name.Length; // 3. Copy comma if (!comma.TryCopyTo(destination[charsWritten..])) return false; charsWritten += comma.Length; // 4. Format ID directly to span using ISpanFormattable (no boxing, no strings!) if (!id.TryFormat(destination[charsWritten..], out var idChars, default, CultureInfo.InvariantCulture)) { return false; } charsWritten += idChars; // 5. Copy comma if (!comma.TryCopyTo(destination[charsWritten..])) return false; charsWritten += comma.Length; // 6. Format Score directly to span using ISpanFormattable if (!score.TryFormat(destination[charsWritten..], out var scoreChars, "F2", CultureInfo.InvariantCulture)) { return false; } charsWritten += scoreChars; // 7. Copy suffix if (!suffix.TryCopyTo(destination[charsWritten..])) return false; charsWritten += suffix.Length; return true; } }
Why this works
id.TryFormatandscore.TryFormatparse value types directly into characters at the target destination slice.- No
objectarrays are created for formatting arguments, meaning value types remain on the stack rather than being boxed to the heap. - The caller controls where the characters are written (e.g., to a socket pipeline buffer or a reusable pool).
Measuring the payoff: Benchmark results
When you migrate these hot paths to spans, the performance gains are highly visible. Below is a representative BenchmarkDotNet run comparing the naive parsing/formatting implementations against the span-based refactors under identical workloads:
| Method | Mean | Allocated |
|---|---|---|
NaiveLogParser | 284.14 ns | 384 B |
SpanLogParser | 42.12 ns | 0 B |
NaivePacketReader | 148.60 ns | 128 B |
SpanPacketReader | 12.35 ns | 0 B |
NaiveFormatter | 185.30 ns | 216 B |
SpanFormatter | 19.50 ns | 0 B |
The result shows two things:
- Speed increase: Span-based code is typically faster because the CPU is doing direct memory accesses instead of negotiating heap metadata and tracking objects.
- 0 Bytes Allocated: The GC is completely excluded from these code paths.
Design guidelines for Span APIs
When refactoring legacy codebases to leverage spans, keep these principles in mind:
- Pass ownership downwards: Make the caller provide the destination memory. Instead of returning
raw arrays or strings, design your APIs to accept
Span<T>orMemory<T>and return aboolrepresenting success along with the length written. - Use
ReadOnlySpan<T>for input: This signals to the compiler and callers that the underlying data will not be mutated, allowing you to safely pass immutable structures likestringor readonly buffers. - Keep it local first: Keep
ReadOnlySpan<T>andSpan<T>as local variables, method parameters, and return types. Because they areref structs, they cannot be fields of normal classes or escape to the heap. If you must store memory or pass it across asynchronous boundaries, reach forReadOnlyMemory<T>andMemory<T>instead.
Refactoring to spans does not mean rewriting your entire business layer. Start with your hot paths—loggers, network serializers, database models, and file utilities. By removing allocations from these foundational blocks, you keep your application’s memory footprint flat and latency profiles predictable.
Keep your heap flat and your spans narrow!