Last week I wrote the C# client for the hot path the way Microsoft's templates say to write it: HttpClient, System.Text.Json nodes, a StreamReader, Console.Out, Native AOT with the size-oriented settings. That was the point of it, since the series measures what each language gives you out of the box, and out of the box C# landed at 7.1 ms on the path against Go's 4.4. I put the extra down to the loader resolving glibc and the AOT runtime starting up, and left it there. It nagged at me all week.
I wanted to know how much of that gap is C# and how much is the defaults. So this is the same spec, the same mock, the same harness, with the "out of the box" rule removed and one of the leaderboard's own rules bent: HTTP is no longer coming from the platform. It comes from a hundred lines of hand-written HTTP/1.1 over a socket that libc opened. The result is 3.35 ms, a millisecond under Go, and the fastest row on the board as it stands. It has its own row on the leaderboard, next to the stock C# row rather than replacing it, because the two answer different questions.
The code is at github.com/AndyCross/claude-hot-path, 344 lines once blanks and comments are taken out, against 102 for the stock client.
Where the seven milliseconds went
strace -f -r on the stock client against the mock is the whole diagnosis. The run is 1,729 lines of syscalls. Before the first request leaves the process it has:
- loaded glibc and libm through the dynamic loader, the 2.5 MB the leaderboard's runtime column charges it for,
- started nine threads: a finalizer, a signal handler, a timer, a thread pool gate and three pool workers, and two
.NET Socketsthreads with an epoll instance each, becauseHttpClientis asynchronous all the way down even when nothing else in the program is, - opened seven probe sockets to learn which address families and options the kernel supports,
- resolved the name and connected.
After the last byte of the second reply it writes a newline, returns from Main, and the runtime shuts down: the HttpClient is disposed, the connection pool is torn down, the finalizer runs, the threads are joined. The harness's tail column, from the mock finishing to the process exiting, was 2.9 ms for C# and 1.8 for Go.
None of that is slow in absolute terms. It is a few hundred microseconds here and there, and a service that lives for a day would never see it. The leaderboard counts every one of them, so this client removes every one of them it can.
HTTP by hand, over libc's socket
The spec says HTTP and TLS come from the platform where the platform has them, and .NET has them. HttpClient is a connection pool, a request pipeline, HTTP/2 and HTTP/3 negotiation, proxy detection, cookie handling and an async engine, which is the right thing for a service and the wrong thing for a process that will make two requests on one connection and exit. So this client does not use it. It calls getaddrinfo, socket, connect, read and write directly, through LibraryImport, and the project file's DirectPInvoke item makes those links resolve at build time instead of through dlopen at run time:
static unsafe partial class Native { [LibraryImport("libc", StringMarshalling = StringMarshalling.Utf8)] private static partial int getaddrinfo(string node, string service, AddrInfo* hints, AddrInfo** res); [LibraryImport("libc")] private static partial int socket(int domain, int type, int protocol); [LibraryImport("libc")] private static partial int connect(int fd, byte* addr, uint len); [LibraryImport("libc")] public static partial nint read(int fd, byte* buf, nuint len); [LibraryImport("libc")] public static partial nint write(int fd, byte* buf, nuint len); [LibraryImport("libc", EntryPoint = "_exit")] public static partial void Exit(int code); public static int Connect(string host, int port) { var hints = new AddrInfo { Family = 2 /* AF_INET */, SockType = 1 /* SOCK_STREAM */ }; AddrInfo* res; if (getaddrinfo(host, port.ToString(), &hints, &res) != 0) throw new IOException("cannot resolve " + host); try { for (var ai = res; ai != null; ai = ai->Next) { int fd = socket(ai->Family, ai->SockType | 0x80000 /* SOCK_CLOEXEC */, ai->Protocol); if (fd < 0) continue; int one = 1; setsockopt(fd, 6 /* IPPROTO_TCP */, 1 /* TCP_NODELAY */, &one, 4); if (connect(fd, ai->Addr, ai->AddrLen) == 0) return fd; close(fd); } } finally { freeaddrinfo(res); } throw new IOException("cannot connect to " + host); } } The hints ask for IPv4 only, so the resolver sends one query instead of an A and an AAAA. On top of the descriptor sits a Stream subclass whose Read and Write are read(2) and write(2) and nothing else, so that SslStream has something to wrap when the base URL is https. Over plain http, which is what the mock speaks, that class is the entire transport.
The HTTP itself is the smallest thing that correctly reads what Go's net/http sends back when a handler flushes: a status line, headers, and a chunked body. The reader finds \r\n\r\n, reads three digits out of the status line, scans the headers for transfer-encoding: chunked or content-length, and then hands the body back in decoded pieces, one chunk at a time, straight out of its own buffer:
public ReadOnlySpan<byte> Next() { if (done) return default; if (chunked && remaining == 0) { int nl; while ((nl = buf.AsSpan(start, end - start).IndexOf("\r\n"u8)) < 0) if (Fill() == 0) { done = true; return default; } var sizeLine = buf.AsSpan(start, nl); int semi = sizeLine.IndexOf((byte)';'); remaining = long.Parse(Encoding.ASCII.GetString(semi < 0 ? sizeLine : sizeLine[..semi]), NumberStyles.HexNumber); start += nl + 2; if (remaining == 0) { done = true; return default; } } if (start == end && Fill() == 0) { done = true; return default; } int n = remaining < 0 ? end - start : (int)Math.Min(remaining, end - start); var piece = buf.AsSpan(start, n); start += n; if (remaining > 0) remaining -= n; if (remaining == 0) { if (chunked) { while (end - start < 2) if (Fill() == 0) break; start += 2; } else done = true; } return piece; } The request goes out as one write: the fixed header bytes, built once in the constructor, then the key, the content length and the body. The connection stays open for the second turn. There is no pool, no timeout, no redirect handling and no HTTP/2, and if the API ever sends something this reader does not understand it will throw an IOException rather than cope. That is the trade.
Reading events without building a tree
The stock client parses each data: line into a JsonNode and walks it with indexers. That allocates a tree per event and a string per property, and then unescapes the text into another string before Console.Out encodes it back to UTF-8. This client runs a Utf8JsonReader over the line's bytes, matches property names with ValueTextEquals against UTF-8 literals, and copies the one value it cares about into a scratch buffer with CopyString, which unescapes straight into bytes:
var r = new Utf8JsonReader(line[6..]); int kind = 0, index = 0, valLen = 0; string sub = "", id = "", name = ""; r.Read(); while (r.Read() && r.TokenType != JsonTokenType.EndObject) { if (r.ValueTextEquals("type"u8)) { r.Read(); kind = r.ValueTextEquals("content_block_start"u8) ? 1 : r.ValueTextEquals("content_block_delta"u8) ? 2 : r.ValueTextEquals("message_delta"u8) ? 3 : 0; } else if (r.ValueTextEquals("index"u8)) { r.Read(); index = r.GetInt32(); } else if (r.ValueTextEquals("content_block"u8) || r.ValueTextEquals("delta"u8)) { r.Read(); while (r.Read() && r.TokenType != JsonTokenType.EndObject) { if (r.ValueTextEquals("type"u8)) { r.Read(); sub = r.GetString()!; } else if (r.ValueTextEquals("id"u8)) { r.Read(); id = r.GetString()!; } else if (r.ValueTextEquals("name"u8)) { r.Read(); name = r.GetString()!; } else if (r.ValueTextEquals("text"u8) || r.ValueTextEquals("partial_json"u8)) { r.Read(); valLen = r.CopyString(scratch); } else if (r.ValueTextEquals("stop_reason"u8)) { r.Read(); if (r.TokenType == JsonTokenType.String) stop = r.GetString(); } else { r.Read(); r.Skip(); } } } else { r.Read(); r.Skip(); } } A text delta then goes from the scratch buffer to file descriptor 1 in one write(2), and is appended to the block's own byte buffer for the echo. Tool input fragments are appended to a second buffer as they arrive and never parsed until the tool runs, when a second Utf8JsonReader pulls out the one timezone field. The request body on the way out is a Utf8JsonWriter over an ArrayBufferWriter, with the tool declaration as a u8 literal written through WriteRawValue and the assembled tool input written the same way, so the fragments the model sent are the bytes the model gets back.
The streaming check in the harness is still satisfied. It never was in doubt for this design, since there is no buffer between the socket and stdout to forget to flush.
No Console, no Uri, no shutdown
Console.Out on Unix sets up an encoder, a synchronised writer and a StreamWriter with a buffer that has to be flushed after every delta. Console.In does the same in the other direction. This client wraps descriptors 0 and 1 in the same read/write stream the socket uses, and the prompt is read as bytes and written back into the request body as bytes, never becoming a string.
System.Uri is a lot of machinery to split one string in three places. The base URL is parsed with IndexOf.
And when the last reply is in and the newline is written, the client calls _exit(0) instead of returning from Main, which skips the runtime's orderly shutdown. I expected that to show in the tail column. It did not. The same static build with the _exit line commented out measured 3.43 ms to 3.35, which is inside the run-to-run noise, and its tail was 0.97 ms to 1.0. With no sockets engine, no pool and no thread pool, there is nothing left for the shutdown to do, and the kernel's teardown of the process is the whole tail either way. The line stays because it says plainly what the program wants, and it costs nothing.
The project file
The stock client's .csproj is tuned for size. This one is tuned for the opposite:
<OptimizationPreference>Speed</OptimizationPreference> <InvariantGlobalization>true</InvariantGlobalization> <UseSystemResourceKeys>true</UseSystemResourceKeys> <EventSourceSupport>false</EventSourceSupport> <StackTraceSupport>false</StackTraceSupport> <ConcurrentGarbageCollection>false</ConcurrentGarbageCollection> <IlcDisableReflection>true</IlcDisableReflection> <IlcDehydrate>false</IlcDehydrate> <StaticExecutable>true</StaticExecutable> <PositionIndependentExecutable>false</PositionIndependentExecutable> <StaticOpenSslLinking>true</StaticOpenSslLinking> ConcurrentGarbageCollection off means the runtime does not start a background GC thread for a heap that will never be collected. IlcDehydrate off stops the compiler packing the runtime's type data into a compact form that the process unpacks before Main, at the price of a larger binary. IlcDisableReflection drops the reflection stack, which this client never touches. I measured those three plus Speed on the untouched stock code before changing a line of it, and they bought nothing: 6.31 ms became 6.37. Whatever the stock client is spending its time on, it is not the GC thread or the type data. They stay in the project file because they are the right settings for this program, not because they moved the number.
The last three are the ones that change what ships. StaticExecutable on the linux-musl-arm64 runtime identifier links the whole thing against musl with no dynamic loader at all, so the image is FROM scratch and the runtime column on the leaderboard reads zero. StaticOpenSslLinking pulls OpenSSL into the binary too, because a static musl executable cannot dlopen the libssl the TLS stack would otherwise load, and that is where most of the 10.9 MB comes from. It rebuilds the crypto shim from source during publish, so the SDK image needs cmake, openssl-libs-static and zlib-static installed before dotnet publish will succeed. PositionIndependentExecutable off is the one I would not have found without running file on the output: the first static build was static-pie, which means the process relocates itself on every start, and for a binary this size that is a measurable number of page faults before the first instruction of Main. Turning it off gives a plain statically linked ELF that the kernel maps and runs.
The finished strace of a run is 260 lines, down from 1,729. One thread is created, the finalizer, because the runtime insists. After that it is one DNS query, one socket, one connect, one write per request, one write per text delta, and exit_group.
What each step bought
Every row is the same spec, the same mock and 100 runs, measured one after another on the same evening. The Go and stock C# rows on the leaderboard were measured on earlier evenings and read a little slower (4.42 and 7.09); the machine was in a better mood tonight, and the comparison that matters is between rows in this table.
| Cold start | Turnaround | Tail | On the path | Artefact | Runtime | Peak RSS | Lines | |
|---|---|---|---|---|---|---|---|---|
| Stock C#, as posted | 3.33 ms | 0.92 ms | 1.95 ms | 6.31 ms | 5.6 MB | 2.5 MB | 7.2 MB | 102 |
| Stock code, tuned project file | 3.19 ms | 0.94 ms | 2.02 ms | 6.37 ms | 7.5 MB | 2.5 MB | 8.1 MB | 102 |
| Rewritten, glibc, dynamic | 2.07 ms | 0.76 ms | 1.20 ms | 4.25 ms | 7.4 MB | 2.5 MB | 6.6 MB | 344 |
| Rewritten, static musl, orderly exit | 1.59 ms | 0.69 ms | 0.97 ms | 3.43 ms | 10.9 MB | 0 | 4.3 MB | 343 |
Rewritten, static musl, _exit | 1.67 ms | 0.73 ms | 1.00 ms | 3.35 ms | 10.9 MB | 0 | 4.3 MB | 344 |
| Go, for reference | 2.01 ms | 0.70 ms | 1.30 ms | 4.11 ms | 6.1 MB | 0 | 6.3 MB | 156 |
The rewrite is worth two milliseconds, and most of that is cold start: no sockets engine, no thread pool, no probe sockets, no Console, no Uri. On glibc, still dynamically linked, it is within a fraction of a millisecond of Go. Going static is worth most of another millisecond, takes the loader out of the cold start and the shared libraries out of the ship size, and halves the memory. The project file knobs and _exit are inside the noise.
3.35 ms on the path puts this client at the top of the board as it stands, with Go at 4.42 and the stock C# client it started from at 7.09. The languages still to come will have something to say about that, and the C# row that matters for the series is still the stock one.
What it is not
It is not the client I would ship. The stock one is: it is 102 lines, it uses the HTTP stack that handles proxies, HTTP/2, timeouts and certificate revocation, and nobody has to know what a chunk size line looks like to maintain it. This one has AddrInfo laid out by hand to match a C struct, a handful of constants from socket.h with comments saying which is which, and a reader that will throw on a trailer it has not seen. It is 10.9 MB because it carries its own OpenSSL, against 5.6 for the stock binary plus the 2.5 of libraries that binary borrows from the image.
What it shows is that the gap between the stock C# client and Go was not the language and not the runtime. The bare start of the static binary, execve to exit_group with nothing to do, is level with Go's. The gap was what the defaults build on top of that, and every piece of it can be taken away in C# with no unsafe code outside the P/Invoke class and the two stream methods that call it, because the pieces underneath (Utf8JsonReader, LibraryImport, Stream, Native AOT's static linking) are all in the box too. That is a different answer to the one the first C# post gave, and I think it is the more useful one.
How to run it
git clone https://github.com/AndyCross/claude-hot-path cd claude-hot-path ./bench/bench.sh csharp-tuned Docker and Python 3, no key. The build runs in the Alpine SDK image and takes about a minute, most of it compiling the OpenSSL shim. The TLS path was checked against the real API from the image with an invalid key, which returns the 401 the spec's error case wants, over HTTPS, through the same hand-written reader.

