I was writing a small server-sent-events endpoint — nothing clever, just streaming records to a browser as they came off a queue. Each record goes out framed: data: in front, a blank line behind. I wrote the obvious thing, it worked, and I forgot about it. Then I was poking at an allocation profile for something unrelated and saw a wall of tiny byte[] allocations coming out of that writer. The payloads I understood. The framing I did not. Those two little strings never changed, so why was the loop minting fresh arrays for them a few million times a second?
Here's the loop that did it:
for (int i = 0; i < Records; i++) { buffer.Write(Encoding.UTF8.GetBytes("data: ")); buffer.Write(payload); buffer.Write(Encoding.UTF8.GetBytes("\n\n")); } Encoding.UTF8.GetBytes("data: ") does exactly what it says every single time: it allocates a brand-new six-byte array, fills it, hands it to Write, and then it's garbage. Same for the "\n\n". The string is a compile-time constant, the bytes can never differ between iterations, and I was still paying for a fresh array on every pass. Two constant tokens, two allocations per record, nothing to show for it afterward.
The literal that isn't a string
C# has a thing for this now, and it's been stable for a few language versions: the u8 suffix. Write "data: "u8 and you don't get a string, you get a ReadOnlySpan<byte> that points straight at a blob the compiler bakes into the assembly's data section. No encoding step at runtime. No array. Reading it is just handing out a view over bytes that were already there when the program loaded.
The one wrinkle is that a ReadOnlySpan<byte> is a ref struct, so you can't drop it in a field and keep it around. The idiom that gets you a name for it is a property:
static class Framing { public static ReadOnlySpan<byte> Prefix => "data: "u8; public static ReadOnlySpan<byte> Suffix => "\n\n"u8; } Every read of Framing.Prefix returns a span over the same static bytes. Nothing is copied, nothing is allocated. The loop turns into this:
for (int i = 0; i < Records; i++) { buffer.Write(Framing.Prefix); buffer.Write(payload); buffer.Write(Framing.Suffix); } Stream.Write already takes a ReadOnlySpan<byte>, so the span goes in with no detour through an array at all.
What it actually bought me
I measured it the way I measure most of this stuff: GC.GetAllocatedBytesForCurrentThread() around the work, best-of-five on a Stopwatch for the timing. I framed two million records each pass. The allocation number is the one I trust, because it's deterministic and doesn't wobble between runs the way milliseconds do.
GetBytes per write best 109 ms 183.1 MB/pass gen0=7 (checksum 54000000) u8 literal best 38 ms 61.0 MB/pass gen0=2 (checksum 54000000) The checksums match — same 54 million bytes come out either way, so this isn't a correctness trade. The framing arrays were 122 MB of garbage per pass, gone. Gen0 collections dropped from seven to two. And it ran about three times faster, though I'd lean on the allocation figure over the timing since the clock jumps around on a shared box.
Now the honest part, because 61 MB is not zero and I don't want to pretend it is. That leftover is the output buffer itself. I sized the MemoryStream to hold the whole batch, so there's one big array per pass by design, and that one's the real work. The u8 change didn't make the method allocation-free, it deleted the allocations that had no business existing: the four million throwaway arrays for eight constant bytes. The buffer you asked for stays. The garbage you didn't, leaves.
When I'd skip it
If you already hoist the encoded bytes into a static readonly byte[] once at startup, you've solved the per-call allocation too, and u8 only really buys you clarity and the guarantee that it's truly static data rather than a heap array someone could mutate. The place u8 earns its keep is exactly the trap I fell into: GetBytes on a string literal sitting inside a hot loop, where it looks harmless and quietly isn't.
And it only works for literals the compiler can see. The moment your bytes come from a runtime string (a header value, a user's name, anything built while the program runs), you're back to GetBytes, as you should be. u8 is for the constants: delimiters, protocol framing, fixed JSON keys you write to a Utf8JsonWriter, the "HTTP/1.1 " kind of thing. My honest opinion is that Encoding.UTF8.GetBytes("some literal") should read as a small code smell at this point — if the argument is a quoted constant, there's almost always a u8 that does it for free.
Since I hit a snag pushing this one to the repo, here's the whole measured sample inline — a net10.0 console app, dotnet run -c Release and it prints the table above:
using System.Diagnostics; using System.Text; const int Records = 2_000_000; byte[] payload = Encoding.UTF8.GetBytes("{\"id\":42,\"ok\":true}"); long WriteWithGetBytes() { using var buffer = new MemoryStream(Records * 32); for (int i = 0; i < Records; i++) { buffer.Write(Encoding.UTF8.GetBytes("data: ")); buffer.Write(payload); buffer.Write(Encoding.UTF8.GetBytes("\n\n")); } return buffer.Length; } long WriteWithU8() { using var buffer = new MemoryStream(Records * 32); for (int i = 0; i < Records; i++) { buffer.Write(Framing.Prefix); buffer.Write(payload); buffer.Write(Framing.Suffix); } return buffer.Length; } long Measure(string name, Func<long> run, int reps) { long checksum = run(); // warm up GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); long bestMs = long.MaxValue; long before = GC.GetAllocatedBytesForCurrentThread(); int g0 = GC.CollectionCount(0); for (int r = 0; r < reps; r++) { var sw = Stopwatch.StartNew(); checksum = run(); sw.Stop(); if (sw.ElapsedMilliseconds < bestMs) bestMs = sw.ElapsedMilliseconds; } double mb = (GC.GetAllocatedBytesForCurrentThread() - before) / (double)reps / (1024 * 1024); Console.WriteLine($"{name,-22} best {bestMs,4} ms {mb,7:F1} MB/pass gen0={GC.CollectionCount(0) - g0} (checksum {checksum})"); return checksum; } Console.WriteLine($"Framing {Records:N0} records, best of 5 passes each\n"); long a = Measure("GetBytes per write", WriteWithGetBytes, 5); long b = Measure("u8 literal", WriteWithU8, 5); Console.WriteLine("\n" + (a == b ? "checksums match" : "MISMATCH")); static class Framing { public static ReadOnlySpan<byte> Prefix => "data: "u8; public static ReadOnlySpan<byte> Suffix => "\n\n"u8; } Anyone else have a GetBytes call hiding in a loop they forgot about? Go grep your hot paths for a quoted string argument — I bet you find one.
— still chasing allocations nobody asked me to chase

