Last week’s client was Go: a 5.8 MB static binary that starts in under two milliseconds, which is a high bar for any language with HTTP in its standard library. This week it’s C#. .NET has had everything the spec needs in the box for a decade, and since .NET 8 it has had Native AOT, which compiles the whole program to a single native executable with no runtime to install. I wanted to know whether that gets a C# client anywhere near Go on size and cold start. It gets closer than I expected, though the runtime column on the leaderboard shows what the binary size leaves out.
The code is at github.com/AndyCross/claude-hot-path, and the spec every client passes is at /claude-hot-path/.
JsonNode, not source generators
The idiomatic way to do JSON in AOT .NET is System.Text.Json with a source-generated JsonSerializerContext, so the serialiser knows every type at compile time and nothing needs reflection. For a client this small I went the other way and used JsonNode, the DOM API, because the request body and the event payloads are built and read dynamically anyway and a context class would have doubled the file.
That choice runs into AOT’s trimming analysis in one place. JsonArray.Add<T>(T) is generic, and when T is JsonObject the compiler warns that creating a JsonValue from a non-primitive type needs runtime code generation. It is a false alarm here, because a JsonObject is a node and gets added as one, but the fix is to say so with a cast:
messages.Add((JsonNode)new JsonObject { ["role"] = "assistant", ["content"] = content.DeepClone() });
Four casts and the publish is warning-free. The rest of the project file is the usual AOT size settings:
<PublishAot>true</PublishAot>
<StripSymbols>true</StripSymbols>
<OptimizationPreference>Size</OptimizationPreference>
<InvariantGlobalization>true</InvariantGlobalization>
<UseSystemResourceKeys>true</UseSystemResourceKeys>
<EventSourceSupport>false</EventSourceSupport>
<StackTraceSupport>false</StackTraceSupport>
InvariantGlobalization is the big one. Without it the binary carries ICU for culture-aware string handling, and a client that compares a few ASCII event names does not need any of it.
Headers first, then the stream
The one line that makes streaming work in HttpClient is the completion option. By default SendAsync buffers the whole response body before returning, which for an SSE stream means waiting for the model to finish before printing anything, and the harness fails a client that does that:
using var resp = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
With headers-read completion the body is a live stream, and a StreamReader over it gives the same line loop as Go’s scanner:
using var reader = new StreamReader(await resp.Content.ReadAsStreamAsync());
while (await reader.ReadLineAsync() is { } line)
{
if (!line.StartsWith("data: ", StringComparison.Ordinal)) continue;
var ev = JsonNode.Parse(line.Substring(6))!;
var index = (int?)ev["index"] ?? 0;
switch ((string?)ev["type"])
{
case "content_block_start":
content.Add(ev["content_block"]!.DeepClone());
partial.Add(new StringBuilder());
break;
case "content_block_delta":
var delta = ev["delta"]!;
if ((string?)delta["type"] == "text_delta")
{
var text = (string)delta["text"]!;
stdout.Write(text); stdout.Flush();
content[index]!["text"] = (string?)content[index]!["text"] + text;
}
else if ((string?)delta["type"] == "input_json_delta")
partial[index].Append((string?)delta["partial_json"]);
break;
case "content_block_stop":
if ((string?)content[index]!["type"] == "tool_use" && partial[index].Length > 0)
content[index]!["input"] = JsonNode.Parse(partial[index].ToString());
break;
case "message_delta":
stop = (string?)ev["delta"]?["stop_reason"];
break;
}
}
The stdout.Flush() is there because Console.Out is buffered when it is not a terminal, and under the harness it never is. Go’s os.Stdout is unbuffered and the bash client’s printf goes straight out, so this is the first client where streaming could have silently become batching.
The second turn echoes the assistant content back with DeepClone(), because a JsonNode can only have one parent and the same array is about to be the content of a message inside messages and the source of the tool_result blocks. TimeZoneInfo.FindSystemTimeZoneById takes IANA names on Linux directly, so the tool is three lines and no conversion table.
What it measures as
| C# Native AOT | Go | bash and curl | |
|---|---|---|---|
| Artefact | 5.4 MB | 5.8 MB | 3.7 KB |
| Runtime | 2.4 MB | 0 | 23.7 MB |
| Ship | 7.8 MB | 5.8 MB | 23.7 MB |
| Cold start, median of 100 | 3.0 ms | 1.7 ms | 7.0 ms |
| Turnaround, median of 100 | 1.4 ms | 1.2 ms | 19.1 ms |
| Client total, median of 100 | 7.1 ms | 4.8 ms | 35.3 ms |
| Peak RSS | 7 MB | 6 MB | 11 MB |
| Lines of code | 102 | 156 | 75 |
What each row means:
- Artefact is the file the build produces: the native executable for C# and Go, the script for bash.
- Runtime is everything else that has to be in the image for it to run. For C# that’s glibc,
libgcc_sand the dynamic loader. Go needs nothing. Bash needs bash, curl and every library they link. - Ship is the two added together, which is what you actually deploy.
- Cold start is from the process spawning to the first request reaching the mock API.
- Turnaround is from the first reply finishing to the second request arriving: parsing the stream, running the tool and sending the next request.
- Client total is every millisecond of the run when the mock wasn’t streaming, including the tail after the last reply.
- Peak RSS is the most memory the process held at once.
- Lines of code counts the non-blank, non-comment lines of the one source file.
The timings are medians of 100 runs, taken from timestamps the mock recorded, with nothing subtracted.
.NET SDK 10.0.402, linux/arm64, Apple M4 Max under Docker. Published with -r linux-arm64 and the project settings above, run from the runtime-deps image.
The binary is smaller than Go’s, which surprised me and will surprise more people. It is also not the whole story, and this is the reason the leaderboard has a runtime column. The Go binary is static. The .NET one links glibc, libgcc_s and the dynamic loader, and ldd on it finds 2.4 MB of shared libraries that have to be in the image. Add them and .NET ships 7.8 MB to Go’s 5.8. Leave them out and you have an image that does not start. The runtime-deps base image exists for exactly this reason. Its chiseled variant is smaller still, and has no shell, which is why the harness measures from the exported image rather than by running commands inside it.
Cold start is three milliseconds against Go’s under-two. The extra millisecond is the loader resolving those libraries and the AOT runtime initialising its GC, and it is a long way short of what a JIT-compiled .NET console app spends before it reaches Main. The turnaround between the two requests is within a fraction of a millisecond of Go’s, and peak memory is about the same. For a language that spent twenty years needing a runtime installed on the box, that table is a remarkable place to have got to.
How to run it
git clone https://github.com/AndyCross/claude-hot-path
cd claude-hot-path
./bench/bench.sh csharp
Docker and Python 3, no key. The AOT publish needs clang and zlib1g-dev in the SDK image, which the Dockerfile installs, and takes about a minute. Next week is Rust, the first client in the series without HTTP in its standard library.
