5 min left
1053 words
5 minutes

Why I Built ZaString

ZaString started with a frustration. I was working on a high-throughput system in C#, and every profiler run showed the same thing: strings everywhere. Allocations piling up. The garbage collector working overtime. Not because the code was wrong, but because strings in C# are immutable and every operation creates a new one.

The Problem With Strings#

Here’s what I mean. Take something innocent-looking:

var label = $"User {name} ({age}) logged in at {DateTime.Now:t}";

One line. Clean. Readable. And underneath, it allocates. The interpolated string creates a string on the heap. The DateTime.Now:t formatting creates another. The name substring reference adds overhead. Even something as simple as str1 + str2 doesn’t modify in place. It allocates a brand new string with the combined content.

In C#, strings are immutable. That’s not a flaw. It’s a deliberate design choice that makes them thread-safe and predictable. But immutability has a cost: every operation that “changes” a string actually creates a new one. Concatenation, formatting, trimming, splitting, they all allocate.

For most code, this is fine. The garbage collector handles it. You never notice. But in hot paths like game loops, UI rendering, or request handlers processing thousands per second, those allocations add up. I was working with ImGui, where every frame builds strings for labels, tooltips, and debug output. Frame after frame, sixty times a second. The profiler wasn’t subtle about it: strings dominated the allocation graph.

I tried the usual fixes. StringBuilder for concatenation. string.Create() for pre-sized allocations. ArrayPool<char> for reuse. They helped, but they all still allocated on the heap eventually. The fundamental issue was that the output was always a string, and strings live on the heap.

I didn’t want to optimize allocation. I wanted to eliminate it.

Enter Span#

.NET 2.1 introduced Span<T>, and it changed what was possible.

A Span<T> is a view over a contiguous region of memory. It can wrap a managed array, a stack-allocated buffer, or unmanaged memory. Crucially, it’s a ref struct. That means it lives on the stack. It can’t be boxed, can’t be stored in a field, can’t be used across async boundaries. The runtime enforces this at compile time.

What you get in exchange is zero-allocation memory access. Slicing a span doesn’t copy. It just creates a new view with an adjusted offset and length. Writing into a span modifies the underlying memory directly. No GC pressure. No hidden allocations.

Span<char> buffer = stackalloc char[64];
var written = "Hello".AsSpan().CopyTo(buffer);
var slice = buffer[..5]; // zero-copy view

This was the insight: instead of building strings and letting the GC clean up, write directly into a buffer you already own. The buffer can be on the stack for small strings, or pooled for larger ones. When you’re done, you have a ReadOnlySpan<char>, which is just a view over the result with no heap allocation at all.

The catch is that working with raw spans is verbose. There’s no fluent API, no formatting helpers, no interpolation. You’re manually tracking offsets and calling TryFormat on every value. It works, but it doesn’t feel like writing C#. It feels like assembly with extra steps.

I wanted the ergonomics of StringBuilder with the allocation profile of Span<T>. That’s where ZaString came from.

The Design Philosophy#

The core idea behind ZaString is simple: zero-allocation should feel normal.

Compare the two:

// StringBuilder — 146 ns, 480 B allocated
var sb = new StringBuilder();
sb.Append("Name: ").Append("John").Append(", Age: ").Append(25);
var result = sb.ToString();
// ZaSpanStringBuilder — 115 ns, 0 B allocated
Span<char> buffer = stackalloc char[50];
var builder = ZaSpanStringBuilder.Create(buffer);
builder.Append("Name: ").Append("John").Append(", Age: ").Append(25);
var result = builder.AsSpan();

Same shape. Same chaining. Same mental model. But the second version never touches the heap. The buffer is stack-allocated, the builder is a ref struct, and AsSpan() returns a view, not a copy.

The fluent API was non-negotiable. Every Append returns ref this, so you can chain calls without creating intermediate references. It supports all the types you’d expect: strings, chars, numbers, booleans, dates, with formatting and culture providers:

builder.Append("Pi: ").Append(Math.PI, "F2")
.Append(", Date: ").Append(DateTime.Now, "yyyy-MM-dd");

C# 10’s interpolated string handlers make it even cleaner:

builder.Append($"User: {name}, Age: {age}, Pi: {Math.PI:F2}");

The handler intercepts the interpolation at compile time and routes each placeholder directly into the span buffer. No intermediate string. No Format() call. Just writes.

I also wanted safety. Raw spans are dangerous. Write past the end and you corrupt memory. ZaString provides TryAppend variants that return false instead of throwing when the buffer is full. TryAppendLine is atomic: if there isn’t room for both the content and the newline, nothing gets written. No partial state. No corruption.

The library grew from there. Escape helpers for JSON, HTML, URLs, CSV. Path and query parameter builders. A pooled builder for cases where the stack isn’t enough. A UTF-8 writer for scenarios that need bytes instead of chars. Each piece follows the same principle: write into a buffer you control, return a view, allocate nothing.

What I Learned#

The biggest lesson was about tradeoffs. Zero-allocation is not free. It’s a constraint, and constraints shape design. ref struct limitations mean you can’t store a builder in a field, pass it to async methods, or return it from a function that escapes the stack. You have to think about buffer sizes upfront. You lose the convenience of string being everywhere in the .NET ecosystem.

But those constraints also force clarity. When you have to declare your buffer size, you think about how much data you actually handle. When you can’t allocate, you design around reuse. The code becomes more intentional.

I also learned that zero-allocation isn’t always the answer. For a one-off log message, string interpolation is fine. The GC won’t notice. ZaString is for the hot paths, the code that runs thousands of times per frame, per request, per second. The profiler tells you where those paths are. Trust the profiler.

On API design, I found that the best abstraction is the one you forget is there. If someone can read ZaString code and not realize it’s zero-allocation, that’s a win. The ergonomics should be invisible. The performance should be the default, not something you opt into with ceremony.

And honestly? There’s a specific kind of joy in running a benchmark and seeing the allocation column read 0 B. Not “reduced.” Not “acceptable.” Zero. The garbage collector has nothing to do. The memory doesn’t move. The result is just… there, in the buffer, where you put it.

Sometimes the best code is the code that doesn’t happen.


ZaString is available on GitHub and NuGet.

Why I Built ZaString
https://corentings.dev/blog/why-i-built-zastring/
Author
Corentin Giaufer Saubert
Published at
2026-04-30
License
CC BY-NC-SA 4.0
Share this post