The Go client set the number to beat for a language with HTTP in its standard library: a 5.8 MB static binary that starts in under two milliseconds. .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 thing to a single native executable with no runtime to install. The question for this post was whether a C# client could get anywhere near Go on size and cold start. It can, with a caveat that the leaderboard makes visible.
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 |
.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.


