DEV Community

Cover image for I'm a .NET Developer. I Spent Two Months Benchmarking Go vs C# and I Didn't Like the Answer.
Haik
Haik

Posted on

I'm a .NET Developer. I Spent Two Months Benchmarking Go vs C# and I Didn't Like the Answer.

I write C#. I have written C# for years. I like EF Core, I like LINQ, and every time somebody told me Go was faster, I opened the same leaderboard everyone opens and pointed at the row where ASP.NET Core sits on top.

Then I stopped arguing and measured it.

I built the same invoicing API twice, once in Go and once in C#. Same database, same cache, same six CPU cores, same latency promise. Two months of evenings and weekends went into building the two services, the spec they both implement and the harness that measures them; the measuring itself was four days, 618 windows and 358 million requests. Every CPU and memory figure is read straight out of the Linux kernel's cgroup accounting, so neither runtime got to grade its own homework.

The answer was not the one I wanted, and it was not close. I'll get to the numbers. First I have to show you why the leaderboard everybody quotes points the other way, because until that makes sense my numbers just look like one guy on the internet disagreeing with TechEmpower.

Everything is open source, so you can check the work as you go: github.com/HaikAsatryan/go-vs-dotnet-benchmark.


Part 1: why the leaderboard says the opposite

Someone tells you Go is faster. They always do. Ask how much, or faster at what, and the conversation gets vague fast. So let's start with the numbers everybody links.

TechEmpower was the reference for a decade. Its Fortunes test is the one worth quoting, because it is the only popular one that touches a database, an ORM, sorting, templating and HTML escaping instead of just echoing a string back. Round 23, February 2025:

framework language Fortunes req/s
ASP.NET Core C# 609,966
Fiber Go 338,096
Actix Rust 320,144
Spring Java 243,639
Express Node 78,136
Django Python 32,651

Round 23 Fortunes, popular-framework slice, as tabulated by Tuan Anh Pham. The full leaderboard has a long tail of C++ and Rust exotica nobody ships either.

C# is not just ahead of Go there. It is 1.8x ahead, on the most database-shaped test in the suite, and it has looked roughly like this for years.

So it should be settled, right? C# is faster, has a richer ecosystem, better tooling, a nicer type system and a bigger hiring pool. Everyone picking Go for backend services is being sentimental.

Then why is Docker written in Go?

And Kubernetes. And Terraform, Prometheus, etcd, containerd, Grafana, InfluxDB, Traefik, Caddy, CockroachDB, Vault, Consul, Cilium.

That is not one team's taste. That is essentially the entire infrastructure layer of the modern internet, built by people who measure things for a living, choosing the language the leaderboard calls slower.

"It's history" only gets you so far. Docker started in 2013, when .NET did not run on Linux. Fine. But .NET Core landed in 2016 and has been genuinely cross-platform for a decade, and in those ten years the infrastructure world kept picking Go anyway.

And then there is the one that should really bother a C# person.

In 2025, Microsoft ported the TypeScript compiler to native code, chasing roughly a 10x speedup. They wrote it in Go. The lead architect of TypeScript is Anders Hejlsberg, the man who created C#, and Turbo Pascal and Delphi before that. If anybody alive had both the ability and the motive to make C# the answer, it was him. He is also, by any measure, one of the best language designers who has ever lived, which is exactly why his choice is worth more than my benchmark.

His stated reasoning: Go was "the lowest-level language available that still has garbage collection," giving "optimized native code for all platforms" and "great control over data layout." On C#, despite everything AOT has added, he noted it is "bytecode-first," is not universally available across platforms, "doesn't have a decade of hardening" in that mode, and "was not engineered that way."

The three reasons people give, and why none of them is on your bill

Push on "Go is faster" and you usually get some mix of three things:

  • It starts instantly. Milliseconds. No JIT warm-up.
  • It ships as one small static binary. No runtime to install, tiny container image.
  • It sits on almost no RAM when idle. Ten megabytes and a nap.

All three are true. Go wins all three, and it isn't close.

Now look at what they have in common: none of them is on your bill. A backend service starts once and then runs for three weeks. Startup time is a number you watch during a deploy and then never think about again. Image size is a one-time pull. Idle RAM is what your service uses in the state where it is doing nothing, which is not the state you are paying for.

What you actually pay for is CPU-seconds and gigabyte-hours while it is serving traffic, and how much traffic one box takes before your latency promise breaks. And on those two, the leaderboard says C# wins.

The catch: which row are you quoting?

TechEmpower's results page has a Classification filter: Platform, Micro, Fullstack. A Fullstack entry uses the framework the way you would, with the real router and the real ORM. A Platform entry is allowed to bypass all of that and write low-level code straight against the runtime.

.NET's headline entry, aspcore, is Platform class: no MVC, no ORM, raw database connectivity. It is beautiful code, and it is not an application. It shows how fast the runtime goes once you take the framework out of it.

Compare the Fullstack rows instead and the order flips. Dustin Moris Gorski ran exactly that comparison on Round 21: ASP.NET Core MVC with real templating managed about 184k req/s on Fortunes, while a Go entry did about 381k. (Different round, different hardware, so don't line those up against the table above. The ordering is the point.) Same benchmark, same test, opposite answer, depending entirely on which row you quote.

TechEmpower shut down in March 2026. Its announcement gives no reasons, so I won't invent any, but the widely shared community read was that those low-level tricks had turned it into "more a socket benchmark than actual real-life implementations used by end-users."

It measured what it measured, extremely well: the ceiling of a runtime when an expert strips the framework away. .NET's ceiling really is spectacular. It just is not the thing you deploy.

So I measured the thing you deploy.


Part 2: the benchmark I ran

What I built

Ledgerline is a small invoicing API. Create an invoice (a real multi-statement database transaction), fetch an invoice (cache-aside through Redis), list a customer's invoices, price a quote, and a PDF-stub endpoint.

I wrote the spec once, froze it before measuring anything, and implemented it twice the way a normal team would ship it:

  • Go: net/http, sqlc over pgx, go-redis.
  • C#: Minimal API on .NET 10, EF Core, Npgsql, StackExchange.Redis.

No hand-rolled routers. No pre-built byte arrays. No leaderboard tricks. Boring code on both sides, the kind that shows up in a pull request without anyone commenting on it.

The test rig: one box, one workload, two implementations of the same frozen spec

Hardware Ryzen 9 7950X, Fedora 43, tuned: CPU governor pinned, boost off, SMT siblings excluded
CPU budget service under test 6 cores, PostgreSQL 3, load generator 3, Redis 1, each in its own cgroup and cpuset
Go side Go 1.26.5, net/http, sqlc over pgx v5.10.0, go-redis 9.22
C# side .NET 10, Minimal API, EF Core 10.0.11, Npgsql 10.0.3, StackExchange.Redis 3.1
Shared PostgreSQL 18.4, Redis 8, 24-connection pools on both sides, verified warm at 24/24 in every cell
Load vegeta 12.13, open loop, 120-second measured windows behind gated warm-ups
Working set 1,000,000 pre-generated requests drawn Zipf-hot from 5M seeded invoices. Measured Redis hit rate 0.807
The promise mixed-workload p99 at or under 20 ms
Receipts 618 measured windows, 358,673,386 requests, four days

Three things make those numbers mean something. If you're newer to this, these are the parts most blog benchmarks get wrong:

1. Every CPU and memory figure comes from cgroup v2. That's the kernel's own accounting for the container, not something the process said about itself. A runtime cannot flatter itself here.

2. The load is open loop. A closed-loop generator waits for a response before sending the next request, so when your server slows down, the generator politely slows down too and hides the failure. An open-loop generator keeps firing at the offered rate regardless. That's how real traffic behaves, and it's the only way to see a service actually fall over.

3. The database gets its own cores, and it never broke. Before any measured run I laddered PostgreSQL on its own 3 cores to 3,715 req/s of create-only traffic, the heaviest thing in the mix, and it was still keeping up when the ladder ran out. It never broke during the runs either. Fair warning on this one: my pre-registered headroom check compared that against a demand figure I estimated before the ladder ran, and the runs ended up going faster than I planned for, so read the database as unfalsified rather than proven innocent. Both services faced the identical database, interleaved in time, so the comparison holds either way.

p99 means: sort every response time in the window, take the value 99% of them come in under. It's the slow-tail number. Averages hide pain; p99 doesn't.


Part 3: the results

Result 1: Go did the same work for a third of the CPU

Same rate, same work, same box:

CPU-ms burned per request vs offered load: C# runs 3-5x higher than Go at every rate

Read the ratios left to right, because this is the most practically useful thing in the post. The ratio is not a number, it is a curve.

Go's per-request cost is a flat line: 7.4% coefficient of variation across 19 rungs spanning a 26x range of load. It costs what it costs.

C#'s cost falls by more than half as load rises, because tiered JIT, dynamic PGO and the runtime's fixed overheads amortise across more requests. That's why the gap is worst exactly where most services live. A .NET service cruising at 10% CPU is sitting at the expensive end of its own curve.

Across every rung of every configuration I ran, the range is 2.64x to 4.85x. If you want one number to plan with, use 3x, and expect worse than that if your traffic is modest.

Result 2: 5x less memory, with one honest caveat

Memory: 32 MB vs 171 MB while serving, but only 1.15x apart on container peak

The entire steady-state Go service, serving thousands of requests per second with a database and a cache client attached, fits in about 32 MB. That's a quip, not a claim, but it's a good one.

And now the caveat, because I would rather you hear it from me than from the comments:

If you size containers by the kernel's high-water mark over the container's whole life (which includes page cache and warm-up), the two land within 15% of each other. Go's advantage is in steady-state memory, which is what you feel when you pack services onto a node. It is not the number your orchestrator's memory limit has to be.

There's one more place Go looked bad, and you should know about it. In a configuration where I told both runtimes that memory was free (GOGC=off against .NET with dynamic heap adaptation disabled), Go ballooned to 6.6 GB while .NET held 212 MB. Go's small footprint is a default, not a law of nature. That configuration failed my warm-up gates by construction, so I file it as posture rather than measurement, but it is the one result in this whole run where Go lost badly.

Result 3: C# hits the wall first

Cost is the bill. Capacity is the cliff.

p99 latency vs offered load: C# crosses the 20 ms promise at 10,426 req/s, Go never crosses

The C# service held the 20 ms promise up to 10,426 req/s. Past that, the tail came apart the way tails do: not gracefully.

Go did not break. Not there, not in any of the five configurations I ran. At the highest rate the run ever offered, 13,312 req/s, Go's warm windows averaged a 16.3 ms p99 while C# was at 103 ms and long past its wall.

offered rate Go p99 C# p99 20 ms promise
11,093 req/s 15.2 ms 23.7 ms (worst window 39.3) Go meets, C# misses
13,312 req/s 16.3 ms (worst 20.4) 103.1 ms* Go meets, C# is past its wall

* C# at 13,312 is past its own wall, and all ten of its windows were thrown out by my pre-registered gates: six for an error rate up to 2.5% (the limit is 0.1%) and four for delivering as little as 81.5% of the offered load. Read it as posture, not a measurement. This is what the far side of a wall looks like.

So Go holds the promise to at least 1.28x the rate C# does. I can't tell you the real multiple, because I never found Go's wall. Four days of trying, and the honest answer is "further up than my load generator went."

"It's EF Core's fault." No, it isn't.

Before anyone says it: yes, I checked. Four data layers, each a full multi-hour run on the identical workload.

Knee by .NET data layer: EF Core, Dapper and raw ADO.NET all land near 10,300 req/s

The three at the top are the same number. EF Core, Dapper and hand-written ADO.NET land within 2.5% of each other, and EF costs about 20% more CPU per request than raw ADO at the same rate. You cannot rewrite your way out of the Go gap by dropping the ORM.

The outlier at the bottom is EF Core on Npgsql's defaults. The fix is one connection-string setting, Max Auto Prepare, which pgx has on and Npgsql does not: turn it on and EF Core goes from 1,468 to 10,426 req/s. That is 7x.

But look at what the other two rows are telling you. Dapper and raw ADO.NET ran with that same setting at its default of off, and they still reached 10,177 and 10,334. So this is not an Npgsql default that hurts every .NET data layer. It is specific to how EF Core issues its parameterised queries when they aren't prepared: hand-written SQL on the same driver, same default, doesn't care. Everything else in this post uses the fixed configuration, so the Go-vs-C# comparison is not quietly carrying that difference.

Hold on to that number. It comes back in a minute.


What I am not claiming

A benchmark that only tells you the good parts is marketing. Here's what this run does not support:

  • No Go knee. Go never crossed 20 ms in any ladder I ran, so its wall is unmeasured. Every Go capacity number here is a floor.
  • Not a single ratio. Cost per request is a curve on both sides. Every ratio here is stamped with the rate it was measured at.
  • These knees are not precision instruments. The crossing brackets carry as few as one or two measurement-grade repetitions against a pre-registered minimum of ten. The third digit of any knee is decoration. The ordering is not.
  • Warm-window thinness is this run's biggest data-quality cost. My warm-up gates (GC cadence steady, tail flat, pools warm) flagged 59.5% of all 618 windows, and they hit Go harder than C# (67% vs 52%) because Go's per-second p99 wanders more at high rates. Some Go roll-ups rest on one or two fully-warm windows. The counts ride along with every table in the repo.
  • No statistical significance on the paired latency deltas. Only the low-rate configuration produced enough warm pairs to run the test; n was 2 and 3, and the intervals span zero. Direction and effect size only.
  • TechEmpower is not wrong on its own terms. It measures what it measures, extremely well. I'm claiming it doesn't measure your service, and that people quote it as though it does.
  • Not captured: per-query PostgreSQL CPU attribution, GC pause histograms, and anything off this one box, one workload, one topology.
  • And both of these are genuinely fast. At 9,244 req/s, a rate most services will never see, Go's p99 was 2.4 ms and C#'s was 4.1 ms. The gap here is a bill, not a broken service.

Hygiene, since a benchmark should show its receipts: 618 measured windows, 358,673,386 requests, seven HTTP 500s in total, zero CPU-throttled windows, zero OOM kills, achieved rate equal to offered rate in every window I kept, and a cross-implementation equivalence suite passing 19 of 19 before the run started. There were also 906,235 connection-level failures with no HTTP response at all, 0.25% of the total, essentially all of them at rates past a service's wall. Those probes are excluded from every fit by a rule I wrote down before the run.


So what does this actually mean?

Let me say the uncomfortable part plainly, because I'm the one who has to live with it.

On the code people actually ship, Go beat C# on every performance axis I measured. Not narrowly. Not in a lab. Not with tricks:

  • ~3x less CPU per request at the same load, and up to 4.85x at low load.
  • 3x to 5x less steady-state memory while serving.
  • Lower p99 at every confirm-stage operating point in the run. (On the exploratory ladder, where some rungs rest on a single window, Go's tail wanders and a few rungs land above C#. That noise is visible in the latency chart and it is why the analysis fits a monotone curve instead of trusting one point.)
  • More traffic before the latency promise broke, with no measured ceiling.

That is the answer to "why is Docker written in Go?" It isn't that Go's runtime wins in a lab; .NET's ceiling is higher than Go's. It's that Go's floor (the cost of ordinary code doing ordinary work) is about three times cheaper, its cost is flat and predictable at every load, and it doesn't have to be busy before it stops being expensive. That's the number that lands on an invoice every month, and it is what the infrastructure world has been telling you for a decade by choosing Go.

And the next time someone links you TechEmpower: that benchmark measured a runtime with the framework surgically removed by an expert. Nobody ships that code. It never measured what you deploy, and as of March 2026 it isn't running at all. Ask them which row they're quoting: Platform, or Fullstack?

I'd rather have found the opposite. But the point of measuring is that you don't get to choose.


Now the part that actually matters

Everything above is about speed and cost. That's one axis. On most projects it is not the axis that decides anything, and I'd be doing you a disservice if I let a benchmark post end at "use Go."

So here's the guide I'd actually give a junior engineer asking "which language should we use?"

The one-sentence version: for a new product you can pick almost anything, including Python, which is around an order of magnitude slower than either language in this post and still runs Instagram. Ship first, measure later. The longer version is below.

Quadrant: pick a language per service by domain complexity and compute bill

1. The most important input is your team, not the language

The first question is not "which language is fast." It's "which language can my team write well, and can I hire more of those people where I am?"

A team fluent in C# will ship a better, safer, faster C# system than an unfamiliar Go one. Language performance is a constant factor. Team fluency is a multiplier on everything else: design quality, review quality, how fast bugs get caught, how long onboarding takes, whether you can replace someone who leaves.

If you cannot hire it, it doesn't matter how fast it benchmarks.

2. Architecture beats language by an order of magnitude. I measured that by accident.

Look at that data-layer chart again. One Npgsql connection-string setting moved C# from 1,468 to 10,426 req/s. That's 7x, from one line of configuration, and it's more than twice the entire Go-vs-C# gap.

Now think about the things that are far worse than a missing config flag:

  • an N+1 query in a hot path
  • a missing index
  • a chatty service boundary that turns one request into twelve
  • no cache where a cache obviously belongs
  • a synchronous call where a queue belonged

Each of those routinely costs 10x to 100x. Go with any of them loses to C# without them, and it isn't close.

You cannot out-language a bad design. You can very easily out-design a slow language.

3. You don't pick a language once. You pick one per service.

Go and C# in the same company is completely normal and not a smell. Different services have different shapes. Treating "our language" as a company-wide identity is how you end up writing a Kubernetes operator in C# and a claims-adjudication engine in Go, both for no reason.

4. Use each language for what it was built for

Go was designed at Google for infrastructure: fast builds, one static binary, cheap concurrency, and a deliberately small language that a large team reads consistently. That minimalism is a feature when the domain is thin and a tax when it's thick. Reach for Go when the surface area is small and the volume is high: proxies, gateways, agents, CLIs, schedulers, cloud-native tooling, high-throughput plumbing, anything that ships as one binary and gets deployed 500 times.

C# / .NET was built for applications with deep business rules. When the hard part isn't throughput but modelling 200 rules that change every quarter, a rich type system, LINQ, a real ORM and a mature ecosystem for the boring things (background jobs, validation, mapping, reporting, integrations) are worth far more than 3x CPU. Billing, ERP, insurance, healthcare, e-commerce with real pricing and promotions: that's where I'd still pick C# knowing everything in this post. Writing that domain in Go is possible, and it is a lot more typing.

"But Rust is faster than Go. Why isn't Docker in Rust?" Because speed is not the only axis, which is the entire point of this post. Rust beats Go on CPU and memory and has no GC pauses at all, and it charges you for that: an ownership model your whole team has to learn, slower builds, a smaller hiring pool, and real friction with the cyclic, graph-shaped data structures that compilers and orchestrators are made of. Go is the point on that curve where you get most of the systems-language win for a fraction of the cognitive cost. The TypeScript team wrote down exactly this trade: a from-scratch Rust rewrite would have been a multi-year job producing something incompatible, while Go let them port the existing compiler largely mechanically. Plenty of infrastructure is written in Rust today, and when you genuinely need the last increment of performance and can pay for it, that is the right call. Most teams do not, and cannot.

The honest caveat, because absolutes are how you get torn apart in the comments: all of this is a centre of gravity, not a law. Monzo runs a bank on roughly 1,600 Go microservices. That's an argument about how you slice a system, not about whether Go can hold money. Their complexity lives in the topology; the counter-case is one thick domain model in one service.

5. Do the arithmetic before you care about 3x

Paying 3x means a bill that could be \$100 is \$300 instead. So:

  • Compute bill of \$300/month on C#? Moving to Go saves you around \$200/month. Nobody should re-architect for that.
  • Compute bill of \$200,000/month on C#? Moving to Go saves you around \$130,000/month. That is somebody's full-time job, and then some.

Work out which one you are. Most people argue like the second and live like the first.

6. When the bill does hit, you don't rewrite everything

You profile, find the two or three services that dominate the invoice, and rewrite those. That's a sprint, not a year, and by then you'll have the revenue and the traffic data to do it properly.

"We'll rewrite the hot paths later" is not technical debt. It's the correct plan.


Quick answers to the comments I'm going to get

"But TechEmpower says C# is faster."
It measured a runtime with the framework stripped out by an expert. Nobody ships that. Ask which row is being quoted: Platform, or Fullstack?

"What about Native AOT? Doesn't that close the gap?"
I didn't test it, so treat this as reasoning and not data. AOT buys you the three things I argued aren't on your bill: instant startup, a small self-contained binary, no JIT warm-up. Those are real wins, and they are exactly the Go advantages that never show up on a long-running service's invoice. What AOT does not obviously improve is steady-state CPU per request, and it gives up the JIT's dynamic PGO, which is part of why the C# curve in my chart gets cheaper as load rises. It could go either way and I'd want to measure before claiming a direction.

The reason I spent none of those four days on it: AOT still doesn't cover a large part of the .NET ecosystem. Anything that leans on runtime reflection or dynamic code generation either needs rework or isn't supported, and that describes a lot of what makes .NET pleasant to write. Hejlsberg made the same point when explaining the TypeScript port: C# is still "bytecode-first," isn't available everywhere, and "doesn't have a decade of hardening" in AOT mode. So I genuinely don't know how much real-world adoption AOT has for services shaped like this one. If you've run this comparison, I want to see it.

"So we should migrate to Go?"
Only if your compute bill is genuinely large, your domain is genuinely thin, and your team can write good Go. Two out of three is a no.

"Isn't this just EF Core being slow?"
No. Dapper and hand-written ADO.NET land within 2.5% of EF Core's number.

"Does this mean C# is bad?"
No. Both services were fast. At 9,244 req/s, Go's p99 was 2.4 ms and C#'s was 4.1 ms. This is a bill, not a broken service. I'm still writing C# on Monday.

"Your Go/C# code is unidiomatic."
Maybe! The spec, both implementations, the runner and every raw artifact are in the repo, and there's an issue template for exactly this. Send a PR, I'll re-run it.


Go and C# are not rivals. They're tools with different centres of gravity, and plenty of good companies run both. This benchmark says Go's floor is about three times cheaper than C#'s. That's a true and useful fact, and it is nowhere near the most important input to your next architecture decision.

Just don't take it from a leaderboard. Measure your own service, at your own rate, and read the accounting from the kernel rather than from a scoreboard.


Run 20260814T051533-fedora. Every number here was recomputed from the per-probe raw artifacts (vegeta summaries, cgroup deltas, warm-up gate records) rather than quoted from the pipeline's own report, and the two agree. The spec, both service implementations, the analysis pipeline, the figure-generation script and the full artifact tree are in the repo. TechEmpower figures are Round 23 (February 2025). If you find a hole, open an issue and I'll fix the post.

Top comments (0)