Skip to content

String & Buffer Performance

Key Points

  • Strings are immutable — every concat allocates. Use StringBuilder, string.Create, or string.Concat(span1, span2) for builds.
  • Span<T> / ReadOnlySpan<T> for parsing without substring allocation.
  • SearchValues<T> (.NET 8+) — vectorized search for any-of-many chars/bytes. Faster than IndexOfAny.
  • Utf8 formatting (Utf8Formatter, Utf8JsonWriter) for network code — avoids string ↔ bytes conversion.
  • CompositeFormat — pre-parsed format string; reuses formatting plan.
  • Source-generated regex ([GeneratedRegex]) — compiled at build; no setup alloc.

Concepts (deep dive)

String building

// ❌ Quadratic alloc
var s = "";
foreach (var w in words) s += w;

// ✅ StringBuilder (1 final string allocation)
var sb = new StringBuilder();
foreach (var w in words) sb.Append(w);
return sb.ToString();

// ✅ string.Join (when collection)
return string.Join(",", words);

// ✅ string.Create (single optimal allocation)
int totalLen = words.Sum(w => w.Length) + (words.Length - 1);  // separators
return string.Create(totalLen, words, (span, ws) =>
{
    var pos = 0;
    for (int i = 0; i < ws.Length; i++)
    {
        if (i > 0) span[pos++] = ',';
        ws[i].AsSpan().CopyTo(span[pos..]);
        pos += ws[i].Length;
    }
});

string.Create is the gold standard — one allocation, written in place.

StringBuilder pool

public class C(ObjectPool<StringBuilder> pool)
{
    public string Build(IEnumerable<string> parts)
    {
        var sb = pool.Get();
        try { foreach (var p in parts) sb.Append(p); return sb.ToString(); }
        finally { pool.Return(sb); }
    }
}

Default pool clears on return. Pool size capped (~1024 chars by default).

Span-based parsing

ReadOnlySpan<char> input = "key=value;timeout=30;retries=3";

while (!input.IsEmpty)
{
    var sepIdx = input.IndexOf(';');
    var part = sepIdx < 0 ? input : input[..sepIdx];
    input = sepIdx < 0 ? ReadOnlySpan<char>.Empty : input[(sepIdx + 1)..];

    var eqIdx = part.IndexOf('=');
    var key = part[..eqIdx];
    var val = part[(eqIdx + 1)..];
    Process(key, val);
}

No Substring allocations. The whole parse runs on the original string's memory.

SearchValues (.NET 8+)

private static readonly SearchValues<char> _vowels = SearchValues.Create("aeiouAEIOU");

ReadOnlySpan<char> input = "Hello World";
int firstVowel = input.IndexOfAny(_vowels);   // SIMD-vectorized

5–20x faster than IndexOfAny(char[]) for large inputs. Especially critical for byte parsing (HTTP headers, CSV).

Utf8Formatter / Utf8Parser

Span<byte> dest = stackalloc byte[16];
Utf8Formatter.TryFormat(123.45, dest, out int written);
// dest now holds "123.45" as UTF-8

Utf8Parser.TryParse(source, out int value, out _);

Skips string allocation entirely — useful for network protocols.

Utf8JsonWriter

using var stream = new MemoryStream();
using var writer = new Utf8JsonWriter(stream);
writer.WriteStartObject();
writer.WriteString("name", "Alice");
writer.WriteNumber("age", 30);
writer.WriteEndObject();
writer.Flush();

Direct UTF-8 emission. Faster than JsonSerializer.Serialize when you control the shape.

CompositeFormat

private static readonly CompositeFormat _fmt = CompositeFormat.Parse("Hello, {0}! You have {1} messages.");

var s = string.Format(null, _fmt, name, count);

Format string parsed once; reused. Marginal but real win in hot logging/error paths.

Source-gen regex

public partial class C
{
    [GeneratedRegex(@"^\d{3}-\d{4}$")]
    private static partial Regex PhoneRegex();
}

Compile-time generation. No setup; no reflection; ~10x faster than new Regex(...).

Interpolation

$"Hello, {name}!"

In .NET 6+, interpolated string handlers are optimized — string.Create-like, single allocation. But still allocates the result. Don't use in hot paths if you can avoid the string entirely (e.g., write to span).

Char operations

char.IsDigit('5');           // fast; no string
char.IsAsciiLetterOrDigit('a');   // .NET 7+; even faster
char.ToUpperInvariant('a');

Use Invariant variants. ToUpper() uses thread culture — slower; locale dependent.

String.Empty vs ""

Same string instance (interned). Use either — preference, no perf difference.

String.Intern / String.IsInterned

Manual interning. Generally avoid; the GC won't collect interned strings, leading to leaks. Compiler interns string literals automatically.

StringPool (Microsoft.Toolkit.HighPerformance)

string s = StringPool.Shared.GetOrAdd(span);

Returns a deduplicated string for identical spans. Useful for parsing many copies of the same string (CSV column values).

Buffer copy

// ✅ Span/Memory CopyTo
src.AsSpan().CopyTo(dest);

// ✅ Buffer.MemoryCopy (low-level)
unsafe { Buffer.MemoryCopy(srcPtr, destPtr, bytes, bytes); }

// ✅ Array.Copy (legacy; works)
Array.Copy(src, dest, count);

Span's CopyTo is JIT-optimized; comparable to Buffer.MemoryCopy.

IndexOf vs SearchValues vs Vectorized custom

For single-char search, IndexOf is already vectorized in .NET 6+. For multi-char, SearchValues. For domain-specific (e.g., HTML escape characters), custom intrinsics with Vector256<byte>.

Encoding

// ✅ UTF-8 default in modern .NET
Encoding.UTF8.GetBytes("hello");

// ❌ Avoid Encoding.GetEncoding(0) (default codepage; unstable)

For high-performance UTF-8: use Span<byte> + Encoding.UTF8.GetBytes(span, dest) overload that avoids alloc.

string.Equals ordinal vs current culture

"abc".Equals("ABC", StringComparison.OrdinalIgnoreCase);   // ✅ fast; deterministic
"abc".Equals("ABC", StringComparison.CurrentCultureIgnoreCase);   // ❌ slower; locale-dependent

Always use Ordinal or OrdinalIgnoreCase unless you specifically need culture comparison.

String.Format vs $""

Compiler converts $"..." to string.Create calls (or interpolated handler). Roughly equivalent perf in modern .NET. string.Format for runtime templates; $"" for compile-time.


Code: correct vs wrong

❌ Wrong: substring allocates

foreach (var part in input.Split(','))
{
    Process(part.Substring(0, part.IndexOf('=')));   // 2 allocs per part
}

✅ Correct: span-based

var span = input.AsSpan();
while (!span.IsEmpty)
{
    var idx = span.IndexOf(',');
    var part = idx < 0 ? span : span[..idx];
    var eq = part.IndexOf('=');
    Process(part[..eq]);   // ReadOnlySpan<char>
    span = idx < 0 ? ReadOnlySpan<char>.Empty : span[(idx+1)..];
}

❌ Wrong: regex per call

public bool Validate(string s) => new Regex(@"^\d+$").IsMatch(s);

✅ Correct: source-gen

[GeneratedRegex(@"^\d+$")] private static partial Regex DigitsRegex();
public bool Validate(string s) => DigitsRegex().IsMatch(s);

❌ Wrong: byte<->string round-trip

var bytes = Encoding.UTF8.GetBytes(value.ToString());

✅ Correct: Utf8Formatter

Span<byte> buf = stackalloc byte[16];
Utf8Formatter.TryFormat(value, buf, out var w);

Design patterns for this topic

Pattern 1 — "Span for parsing"

  • Intent: zero-alloc string surgery.

Pattern 2 — "string.Create for builds"

  • Intent: single optimal allocation.
  • Intent: SIMD speedup.

Pattern 4 — "Source-gen regex"

  • Intent: compile-time; no setup.

Pattern 5 — "Utf8 formatting for network"

  • Intent: avoid string conversion.

Pros & cons / trade-offs

Approach Pros Cons
StringBuilder Familiar Final ToString allocates
string.Create One alloc Verbose
Span parsing Zero alloc API limited; no using
Regex generated Fast Build dep
Utf8Json Faster than string Less convenient

When to use / when to avoid

  • Use Span for hot parsing.
  • Use string.Create for known-size builds.
  • Use SearchValues for multi-pattern search.
  • Avoid Substring in hot loops.
  • Avoid culture-sensitive comparison unless needed.

Interview Q&A

Q1. Why is string concat in a loop quadratic? Each concat allocates a new string copying the previous. Total copying is O(n²).

Q2. StringBuilder vs string.Create? StringBuilder for unknown final size. string.Create when you know the size — single optimal alloc.

Q3. Span vs ReadOnlySpan? Span: read-write. ReadOnlySpan: read-only view. Use the most-restricted one your API allows.

Q4. SearchValues? Pre-built set for vectorized any-of-many search. Major win for parsing.

Q5. Why source-gen regex? Compile-time codegen; no setup; faster than new Regex(...) repeatedly.

Q6. Ordinal vs CurrentCulture comparison? Ordinal: byte-by-byte; fast; deterministic. CurrentCulture: locale-aware; slow. Default to Ordinal.

Q7. Utf8Formatter vs ToString + Encoding.UTF8? Skips string allocation; writes UTF-8 directly to span.

Q8. CompositeFormat? Pre-parsed format string. Reuses parsing plan. Marginal win in hot logging.

Q9. Why is $"..." competitive in .NET 6+? Compiler emits interpolated handler — optimized. Comparable to string.Create.

Q10. ArrayPool for strings? Strings are immutable; pooling not direct. Pool the byte[] / char[] buffers used to build them.


Gotchas / common mistakes

  • ⚠️ Substring in hot loops — alloc per call.
  • ⚠️ Culture-sensitive comparison without need — slow.
  • ⚠️ new Regex(...) per call — 10x slowdown.
  • ⚠️ Manual String.Intern — leaks.
  • ⚠️ String concat for build — quadratic.
  • ⚠️ Encoding.Default — unstable across machines.

Further reading