← All posts

C# Was Losing to Node Because of a Logging Call

Performance Go Java C# Rust Node.js Deno Benchmarking

A reasonable objection came in on my last version of this benchmark: there's no way Deno or Bun should be beating .NET or Java. That's a fair instinct, and it turned out to be right — just not for the reason I first assumed.

Two things happened as a result. First, I added Java (Spring Boot, Tomcat) and C# (ASP.NET Core, Kestrel) to the lineup. Second, and more importantly: every service in this benchmark had been capped at 1 CPU core. That's a defensible way to compare per-core efficiency, but it also silently erases the entire point of a thread-based runtime — Go, Rust, Java, and C# all have real multi-core parallelism available, and 1 core never let them use it. So I reran everything at 4 cores per service instead.

That single change flipped the story completely.

A correction first

Adding Java and C# initially seemed to confirm something else: I thought I'd found real GC misconfiguration bugs in both — Java's JVM sizing garbage-collection threads off the host's 12 visible processors instead of the container's actual 1-CPU quota, and .NET's Server GC doing the same. I made fixes for both and was ready to write about it.

Then I re-tested properly, and it turned out my original tests were wrong, not the runtimes. My first check ran the containers with no --cpus flag at all, so of course they saw 12 processors — I forgot to apply the actual constraint. Tested correctly, under a real 1-CPU cgroup limit: the JVM already scales ParallelGCThreads down to match the quota exactly, and .NET's Environment.ProcessorCount correctly reads the quota and automatically disables Server GC when only 1 CPU is available. Both runtimes were already doing the right thing. I reverted both fixes — they weren't fixing anything.

Worth stating plainly rather than quietly editing out: I was wrong, said so as soon as I checked properly, and the correction is more useful here than the original claim would have been.

A real fix: default request logging

The pushback continued after the 4-core rerun below first went out — still not buying that .NET was performing anywhere near what it should. That prompted a second look, and this time it was a genuine issue: I audited every service's default logging behavior for consistency, and ASP.NET Core's WebApplication.CreateBuilder wires up Information-level "Request starting / Request finished" logging on every single request out of the box — nobody else in this benchmark pays that cost. Express has no logging middleware. Fastify is explicitly configured with logger: false. Go and Rust never had a logging layer added. Java's root logger is set to WARN. Only .NET's default was still writing a structured log line per request, and Python's uvicorn had its own version of the same problem (access logging on by default, unlike everything else).

Fixed both to match the rest of the field's baseline — builder.Logging.SetMinimumLevel(LogLevel.Warning) plus disabling the Kestrel Server response header for C#, and --no-access-log for uvicorn — and reran the whole suite again. A quick isolated check before the full rerun: C# at a steady 300 VUs went from ~24,000 req/s to 60,000 req/s. That's not a rounding difference; it's confirmation that the earlier numbers were measuring ASP.NET Core's console logging overhead as much as they were measuring Kestrel.

Versions and test bench

Service Runtime Framework
Express Node 24.19.0 (LTS) Express 5.2.1
Fastify Node 24.19.0 (LTS) Fastify 5.12.1
Express (Node latest) Node 26.7.0 (current) Express 5.2.1, identical code to the LTS row
Bun Bun 1.4.0 native Bun.serve
Deno Deno 2.9.5 native Deno.serve
Go Go 1.26 stdlib net/http
Python Python 3.14.7 FastAPI 0.141.1 + uvicorn 0.52.3
Rust Rust 1.98.0 Axum 0.8.9 + Tokio 1
Java Java 25 (JDK 25) Spring Boot 4.1.0 (Tomcat, default blocking MVC)
C# .NET 10.0 ASP.NET Core 10.0 minimal APIs (Kestrel)

Bench: AMD Ryzen 5 3600 (6 cores / 12 threads), 16GB RAM, CachyOS Linux, Docker 29.7.2. Every service now gets 4 CPU cores and 512MB RAM (previously 1 core). Load generated by k6, one service at a time, CPU frequency and package temperature sampled every second throughout every run — no throttling occurred in any tier (max 72.4°C).

Round 1: trivial endpoint, 10,000 VUs

Trivial endpoint — req/s over time (4 CPU cores)

Hover to compare exact values · click a legend entry to toggle a service

Service Avg req/s p95 latency
Deno 53,019 169ms
Go 50,870 97ms
Rust 46,687 35ms
C# 46,662 119ms
Bun 46,356 192ms
Java 24,386 160ms
Fastify (Node 24) 19,471 108ms
Express (Node 24) 13,495 126ms
Node 26 + Express 12,779 132ms
Python 5,920 491ms

With logging fixed, C# nearly doubled from its first 4-core run (23,782 → 46,662) and now sits in a tight four-way cluster with Bun, Rust, and Go, just behind Deno — a completely different picture from "C# trails Node." Java also benefits enormously from more cores (still without needing a logging fix — Spring Boot's defaults were already fine): it moved from dead last at 1 core to solidly ahead of both Node variants and Fastify. Everything single-threaded (Express, Fastify, Bun, Deno, Node 26, Python) gained only modestly from 1 core to 4, because a single-threaded event loop's JS/Python execution can't spread across extra cores no matter how many you hand it.

Deno still leads outright on raw trivial-endpoint throughput, with Go just behind — but the 1-CPU, unfixed-logging version of this benchmark made it look like C# structurally couldn't compete with Bun or Deno, when in fact it had been fighting the extra cost of a logging call on every single request the whole time.

Round 2: CPU-bound endpoint, 1,000 VUs — the clean split

Same handler as before in every service: synchronously count primes below 100,000 by trial division, identical algorithm per language.

CPU-bound endpoint @ 1,000 VUs — req/s over time (4 CPU cores)

Hover to compare exact values · click a legend entry to toggle a service

Service Avg req/s Scaling vs 1-core (~73-80 baseline)
Rust 297.0 ~4.0x
Go 297.0 ~4.0x
C# 290.0 ~4.0x
Java 178.8 ~2.4x
Deno 76.0 ~1.0x
Fastify 73.8 ~1.0x
Express (Node 24) 73.2 ~1.0x
Node 26 + Express 72.3 ~1.0x
Bun 71.2 ~1.0x
Python 7.8 ~1.0x, still aborts (20.42% failed)

Notice C# barely moved from before the logging fix (292.5 → 290.0, within noise). That's expected and a good sanity check: once each request already costs tens of milliseconds of real CPU work, a microseconds-scale logging call stops being the dominant cost. The logging overhead mattered enormously for the trivial endpoint and barely at all here — exactly consistent with it being a fixed per-request cost rather than something that scales with load.

This is the cleanest result in the whole series. Rust, Go, and C# each scale almost exactly 4x — full, direct use of the extra cores for genuinely parallel request handling (Tokio's worker pool, goroutines across GOMAXPROCS, Kestrel's thread pool). Java scales too, but only about 2.3x — real parallelism, just with more overhead per additional thread than the other three, likely from Tomcat's thread-per-request synchronization and GC pause behavior under sustained load. And every single-threaded runtime — Express, Fastify, Bun, Deno, Node 26, Python — stays pinned almost exactly at its 1-core number. Four cores were sitting right there and could not be used, because the entire request-handling path lives on one thread (or one process, for Python's default uvicorn setup).

None of this was framework marketing or a synthetic microbenchmark — it's the direct, mechanical consequence of "one thread processes one request at a time" vs "many OS threads or a work-stealing scheduler process requests concurrently," measured with the same code and the same load generator against all ten.

Round 3: the breaking point, 6,000 VUs — who doesn't break

CPU-bound endpoint @ 6,000 VUs — req/s over time (4 CPU cores)

Hover to compare exact values · click a legend entry to toggle a service

Service Avg req/s Failed Tripped abort?
Go 299.6 0% No — full ramp, never approached the threshold
C# 290.0 0% No — full ramp, never approached the threshold
Rust 285.7 9.25% No — degraded but stayed under 20%
Java 127.3 20.71% Yes, ~100% into ramp
Node 26 + Express 97.9 20.36% Yes, ~91%
Fastify 95.2 20.92% Yes, ~91%
Deno 95.1 21.10% Yes, ~99%
Express (Node 24) 94.4 21.09% Yes, ~91%
Bun 90.7 21.75% Yes, ~96%
Python 8.8 21.50% Yes, ~69%

This is the answer to the objection that started this whole update. Pushed to a genuine breaking point — 6,000 VUs against a hard CPU-bound handler — Go and C# recorded 0% failures and completed the entire ramp, and Rust only degraded to 9.19%, never tripping the 20% abort threshold at all. Every single-threaded runtime failed, and so did Java, whose partial (2.3x, not 4x) scaling in round two turned out to matter here: Tomcat's thread-per-request model, even with real parallelism, still couldn't keep pace with true async schedulers (Tokio, Go's runtime, Kestrel) once concurrency got extreme enough.

So the original instinct was right, just aimed slightly wide: it isn't that Bun and Deno can't win anything — they clearly win round one's raw HTTP-layer throughput test outright, even at 4 cores. It's that "which runtime wins" depends entirely on what you're testing for. Raw single-request HTTP overhead: Bun/Deno/Rust/Go. Compute-bound throughput once real cores are available: Rust/Go/C# tied at the top. Surviving actual overload without falling over: Go and C# specifically, with Rust just behind them.

Takeaway

Two separate issues stacked on top of each other, and both had to be fixed before this comparison was fair: the 1-CPU cap silently favored single-threaded event-loop runtimes over anything built to actually use a multi-core machine, and ASP.NET Core's default logging was quietly taxing every single request in a way none of the other nine services were. Fix only one and the picture is still wrong — fix the core count and C# still looks slow because of the logging tax; fix the logging and it still can't show what multiple cores buy it. Fix both, and C# goes from "loses to Node" to "ties Bun and Rust on raw HTTP throughput, and is one of only three runtimes that didn't break under a genuine breaking-point test."

If the objection that started this was "no way Deno or Bun outperforms .NET or Java" — under a fair, multi-core, default-logging-off, pushed-to-breaking test: Deno and Bun still win round one's raw HTTP-layer throughput outright, C# ties them there once its own handicap is removed, and Go/Rust/C# are the only three that don't break under real overload. Neither side of the original argument was entirely right until the test itself was actually fair.