Dev.to WebDev 🛠 Dev 👁 0 📖 5 min read

I Expected string.Create to Be Faster. It Just Stopped Allocating.

I had a hot loop stamping out order keys — little strings like ORD-0000042-US — a couple million of them per refresh. The code was the most boring thing in the file, so of course that's where the allocation profiler kept

I had a hot loop stamping out order keys — little strings like ORD-0000042-US — a couple million of them per refresh. The code was the most boring thing in the file, so of course that's where the allocation profiler kept parking. I figured I'd reach for string.Create, the usual "stop allocating intermediate strings" move, get my speedup, and go home. I got half of what I expected. The interesting half.

Here's the code I started with. It's what most of us write without thinking, and honestly it's fine:

static string ConcatKey(int id, string region)
    => "ORD-" + id.ToString("D7") + "-" + region;

Clean, readable, obviously correct. But look at what it allocates. There's the final string, sure, you can't avoid that one. And then there's id.ToString("D7") — a whole separate seven-character string that exists for one reason: to be copied into the concatenation and then immediately thrown away. Every single call makes one. Two million calls, two million little garbage strings.

So the plan was string.Create, which hands you a Span<char> over the final string's buffer and lets you write the characters in directly. No intermediate anything:

static string CreateKey(int id, string region)
{
    int length = 4 + 7 + 1 + region.Length; // "ORD-" + 7 digits + "-" + region
    return string.Create(length, (id, region), static (span, state) =>
    {
        "ORD-".CopyTo(span);
        state.id.TryFormat(span.Slice(4), out _, "D7");
        span[11] = '-';
        state.region.CopyTo(span.Slice(12));
    });
}

TryFormat writes the digits straight into the span. No ToString, no throwaway string. One allocation total, the one you actually wanted.

Before I trusted either of them I made them agree on a few thousand keys, because off-by-one errors in span offsets are exactly the kind of thing that passes a glance and fails in production:

Both agree. Sample key: ORD-0000042-US

Good. Now the part I was wrong about.

The clock didn't move

I ran both against two million keys, best of seven passes, JIT already warm. This is a two-core container, not a lab bench, so I only trust the ratios, not the absolute milliseconds.

2,000,000 keys built, best of 7
Concatenation  :   50 ms     192.0 MB allocated
string.Create  :   55 ms     112.0 MB allocated

Time ratio  : 0.91x
Alloc ratio : 1.71x

Read that twice, because I did. The "fast" version wasn't faster. If anything the concatenation edged it out by a few milliseconds, run after run. What actually changed was the allocations: 192 MB down to 112 MB, dead stable across every pass I ran.

That 80 MB gap is the throwaway D7 strings. Two million of them, about 40 bytes each, which lines up almost exactly. The concatenation path is spending real memory to produce garbage that lives for one line. string.Create just... doesn't.

So why isn't it faster on the clock? Because modern .NET is genuinely good at this. Allocating a small short-lived string and collecting it in gen 0 is cheap — cheap enough that the cost of my careful span juggling (the bounds checks, the TryFormat call, computing the length) roughly cancels it out on a single call. On wall-clock latency, it's a wash.

The win is real, it's just not the win I went looking for. Fewer allocations means less gen-0 pressure, which means the GC wakes up less often. In a tight microbenchmark that barely shows, because nothing else is competing for the GC. Under a real server with sustained traffic and other threads all churning gen 0, that 80 MB per two million keys is 80 MB the collector doesn't have to sweep, and GC pauses are the thing that actually hurts tail latency. You don't see it here. You'd see it at 2 a.m. in your p99.

When I'd leave the boring version alone

Most of the time? I'd keep the concatenation. It's three tokens long, anyone can read it, and it's not slower. If this key got built a few thousand times per request I would not touch it — the allocation savings would be noise and I'd have traded obvious code for a span.Slice(12) that breaks the day someone makes the region prefix two characters longer and forgets to move the offset.

string.Create earns its keep in one specific spot: a genuinely hot path that your allocation profiler is actually pointing at, where GC pressure is the bottleneck you measured — not the one you assumed. And even then, the length has to be knowable up front. The moment the output length depends on something you can't compute cheaply before writing, the whole thing gets ugly and you're better off with an interpolated handler or a pooled StringBuilder.

My honest opinion after this one: "faster" and "allocates less" are two different claims, and I'd been sloppily treating them as the same claim for years. The profiler said allocations. I heard speed. The benchmark set me straight.

Here's the whole thing if you want to run it yourself. New console project targeting net10.0, paste this into Program.cs, dotnet run -c Release:

using System.Diagnostics;

string[] regions = ["US", "EU", "APAC", "LATAM", "MEA"];

static string ConcatKey(int id, string region)
    => "ORD-" + id.ToString("D7") + "-" + region;

static string CreateKey(int id, string region)
{
    int length = 4 + 7 + 1 + region.Length;
    return string.Create(length, (id, region), static (span, state) =>
    {
        "ORD-".CopyTo(span);
        state.id.TryFormat(span.Slice(4), out _, "D7");
        span[11] = '-';
        state.region.CopyTo(span.Slice(12));
    });
}

const int Count = 2_000_000;
const int Passes = 7;

long Measure(Func<int, string, string> build, out long allocated)
{
    for (int i = 0; i < 10_000; i++) _ = build(i, regions[i % regions.Length]);

    long bestTicks = long.MaxValue, bytes = 0;
    var sw = new Stopwatch();
    for (int pass = 0; pass < Passes; pass++)
    {
        long before = GC.GetAllocatedBytesForCurrentThread();
        sw.Restart();
        int sink = 0;
        for (int i = 0; i < Count; i++)
            sink += build(i, regions[i % regions.Length]).Length;
        sw.Stop();
        bytes = GC.GetAllocatedBytesForCurrentThread() - before;
        if (sw.ElapsedTicks < bestTicks) bestTicks = sw.ElapsedTicks;
        GC.KeepAlive(sink);
    }
    allocated = bytes;
    return bestTicks * 1000 / Stopwatch.Frequency;
}

long concatMs = Measure(ConcatKey, out long concatBytes);
long createMs = Measure(CreateKey, out long createBytes);

Console.WriteLine($"Concatenation  : {concatMs,4} ms   {concatBytes / 1_000_000.0,7:F1} MB");
Console.WriteLine($"string.Create  : {createMs,4} ms   {createBytes / 1_000_000.0,7:F1} MB");

Have you got a string.Create in your codebase that's actually pulling its weight, or one you added on vibes? I'd love to hear which. Drop it in the comments.

— still benchmarking the boring loops nobody asked me to

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.