<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Haik</title>
    <description>The latest articles on DEV Community by Haik (@haikasatryan).</description>
    <link>https://dev.to/haikasatryan</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3874790%2F62759872-e001-47ff-bdac-2de73888fb40.png</url>
      <title>DEV Community: Haik</title>
      <link>https://dev.to/haikasatryan</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/haikasatryan"/>
    <language>en</language>
    <item>
      <title>Return or throw? Result pattern vs exceptions in ASP.NET Core</title>
      <dc:creator>Haik</dc:creator>
      <pubDate>Wed, 11 Nov 2026 11:16:29 +0000</pubDate>
      <link>https://dev.to/haikasatryan/return-or-throw-result-pattern-vs-exceptions-in-aspnet-core-1nl5</link>
      <guid>https://dev.to/haikasatryan/return-or-throw-result-pattern-vs-exceptions-in-aspnet-core-1nl5</guid>
      <description>&lt;p&gt;The order does not exist. Now what?&lt;/p&gt;

&lt;p&gt;Every .NET API answers this question many times a day, and there are two camps. One throws a &lt;code&gt;NotFoundException&lt;/code&gt; and lets something upstream turn it into a 404. The other returns the error as a value. Both work. Both have real costs.&lt;/p&gt;

&lt;p&gt;This post lists the pros and cons of each, shows numbers, and ends with the rule I use.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Return&lt;/strong&gt; the errors a caller can expect: not found, conflict, validation failed. They are 4xx.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Throw&lt;/strong&gt; what should never happen: a bug, a dead database. It is a 500.&lt;/li&gt;
&lt;li&gt;You need both, and both should produce the same error shape.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The exception way
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nc"&gt;Task&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;OrderDto&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;GetAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;FindAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;NotFoundException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;$&lt;span class="s2"&gt;"Order {id} does not exist."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ToDto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Somewhere else, an &lt;code&gt;IExceptionHandler&lt;/code&gt; or a middleware catches &lt;code&gt;NotFoundException&lt;/code&gt; and writes the 404.&lt;/p&gt;
&lt;h3&gt;
  
  
  What is good
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It is built in.&lt;/strong&gt; Every .NET developer knows it and every library already uses it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The happy path stays clean.&lt;/strong&gt; There are no checks after each call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It cannot be ignored by accident.&lt;/strong&gt; If nobody catches it, the request fails loudly.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;You get a stack trace for free.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It crosses ten layers with no plumbing.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  What is bad
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The signature hides the failure.&lt;/strong&gt; &lt;code&gt;Task&amp;lt;OrderDto&amp;gt;&lt;/code&gt; says nothing about "not found". C# has no checked exceptions, so the caller learns the failure cases from the docs, or from production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is slow next to a plain return.&lt;/strong&gt; .NET 9 made exceptions &lt;a href="https://learn.microsoft.com/dotnet/core/whats-new/dotnet-9/runtime" rel="noopener noreferrer"&gt;2 to 4 times faster in micro-benchmarks&lt;/a&gt;, and they are still far from free. Numbers are below.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expected events look like crashes.&lt;/strong&gt; A user asks for a missing order, and many monitoring tools count one more exception.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The mapping lives far from the code.&lt;/strong&gt; One handler has to know every exception type and its status code. Forget one and the client gets a 500.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  The Result way
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nc"&gt;Task&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;OrderDto&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;GetAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;FindAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NotFound&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;$&lt;span class="s2"&gt;"Order {id} does not exist."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ToDto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The method returns the value or an error. The caller checks which one it got.&lt;/p&gt;
&lt;h3&gt;
  
  
  What is good
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The signature tells the truth.&lt;/strong&gt; &lt;code&gt;Result&amp;lt;OrderDto&amp;gt;&lt;/code&gt; says "this can fail", and the caller has to look before it reads the value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is cheap.&lt;/strong&gt; An error is a normal return value, with no stack unwinding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expected errors stay out of your exception tracking.&lt;/strong&gt; A 404 is not an incident.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mapping to HTTP is mechanical.&lt;/strong&gt; The error already knows its type, and the type knows its status code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tests are simple.&lt;/strong&gt; Call the method, assert on what came back.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  What is bad
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Plumbing.&lt;/strong&gt; Every caller has to check and pass the error up. The code is below.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It spreads.&lt;/strong&gt; Once a service returns a &lt;code&gt;Result&lt;/code&gt;, its callers do too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There is no stack trace.&lt;/strong&gt; You know what failed, not where.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It can be dropped.&lt;/strong&gt; C# does not force you to use a return value. Call a method that returns a &lt;code&gt;Result&lt;/code&gt;, forget to check it, and the error is gone without a warning. An exception never lets you do that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There is no standard type.&lt;/strong&gt; .NET has no built-in &lt;code&gt;Result&lt;/code&gt;, so you pick a library or write your own. C# 15 unions give you the "value or error" shape, but you still write the HTTP mapping yourself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You still need exceptions.&lt;/strong&gt; EF Core throws, &lt;code&gt;HttpClient&lt;/code&gt; throws, your own bugs throw. A Result does not replace the exception handler. You run two systems.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the plumbing, repeated at every level:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;GetAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;IsSuccess&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;billing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ChargeAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;I measured a 404 from a service three async calls deep, on .NET 11 with BenchmarkDotNet. "Whole request" is a full &lt;code&gt;GET /orders/{id}&lt;/code&gt; through an in-memory TestServer.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;A 404 as&lt;/th&gt;
&lt;th&gt;Alone&lt;/th&gt;
&lt;th&gt;Whole request&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Thrown exception, caught by &lt;code&gt;IExceptionHandler&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;4,834 ns, 2,808 B&lt;/td&gt;
&lt;td&gt;16.6 µs, 19.6 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Returned &lt;code&gt;Result&amp;lt;T&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;68 ns, 64 B&lt;/td&gt;
&lt;td&gt;5.2 µs, 12.4 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Alone, the throw costs about 70 times more. Inside a whole request, about 3 times more. It is one laptop, so read the ratio and not the absolute numbers. The method and the raw output are &lt;a href="https://github.com/HaikAsatryan/ResultCrafter/blob/main/docs/design.md" rel="noopener noreferrer"&gt;in the repository&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Be honest about what this means. If your endpoint calls a database, 11 microseconds is noise. Speed alone is a weak reason to switch. It starts to matter when errors are common: a public API scanned by bots, a lookup endpoint that mostly answers 404. Bad requests are the ones anybody can send for free, so it helps when they are also the cheapest ones to answer.&lt;/p&gt;

&lt;p&gt;The stronger reasons are the other ones: an honest signature, and quiet monitoring.&lt;/p&gt;
&lt;h2&gt;
  
  
  So which one?
&lt;/h2&gt;

&lt;p&gt;Both. They answer different questions.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmpcgyezs8aroqxa2rs4v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmpcgyezs8aroqxa2rs4v.png" alt="Expected errors are returned and become 4xx. Unexpected ones are thrown and become 500. Both leave as one ProblemDetails shape." width="800" height="348"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Return&lt;/strong&gt; what the caller can expect and act on: not found, conflict, validation failed, forbidden. These are part of the contract of your API. They are 4xx responses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Throw&lt;/strong&gt; what should never happen: a bug, a null where none can be, a database that is down. Nobody upstream can fix it. It is a 500, and somebody should get an alert.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A quick test: should this wake someone up at night? If yes, it is an exception. If the client just did something clients do, it is a result.&lt;/p&gt;

&lt;p&gt;This has one practical consequence. You need both pipelines, and both must produce the same error shape. The client should not be able to tell which one wrote the response. In ASP.NET Core that shape is ProblemDetails (RFC 9457).&lt;/p&gt;
&lt;h2&gt;
  
  
  Where ResultCrafter fits
&lt;/h2&gt;

&lt;p&gt;Wiring those two pipelines into one shape is boring work, and I got tired of doing it in every project. So I wrote ResultCrafter, a small open-source library for .NET 10 and later.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/HaikAsatryan" rel="noopener noreferrer"&gt;
        HaikAsatryan
      &lt;/a&gt; / &lt;a href="https://github.com/HaikAsatryan/ResultCrafter" rel="noopener noreferrer"&gt;
        ResultCrafter
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Result pattern for ASP.NET Core: errors become the right status and RFC 9457 ProblemDetails in one call. NativeAOT-safe.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;
  
    
    &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FHaikAsatryan%2FResultCrafter%2FHEAD%2Fassets%2Fresultcrafter-lockup-light.svg" alt="ResultCrafter" width="334" height="64"&gt;
  
&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Return errors. Don't throw them.&lt;/strong&gt; ResultCrafter gives your ASP.NET Core services a &lt;code&gt;Result&amp;lt;T&amp;gt;&lt;/code&gt; and turns it into the
right HTTP response in one call. Errors leave as clean RFC 9457 ProblemDetails, the same shape every time.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://www.nuget.org/packages/ResultCrafter.AspNetCore" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/2917b744623121b09b9dbcf83be69d08dd4fff50cbf916056ce69641da2b0e93/68747470733a2f2f696d672e736869656c64732e696f2f6e756765742f762f526573756c74437261667465722e4173704e6574436f72653f6c6162656c3d6e75676574" alt="NuGet"&gt;&lt;/a&gt;
&lt;a href="https://www.nuget.org/packages/ResultCrafter.AspNetCore" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/2d173b505be2cdcb64fd2faf351d99770c3c0fc363c96b58047efe5d67f0fc60/68747470733a2f2f696d672e736869656c64732e696f2f6e756765742f64742f526573756c74437261667465722e4173704e6574436f7265" alt="Downloads"&gt;&lt;/a&gt;
&lt;a href="https://github.com/HaikAsatryan/ResultCrafter/actions/workflows/main.yml" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/7f074bef18eb8da484ad4c5d0f80492378eeabb2823f1492c04441e400e3ed74/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f4861696b417361747279616e2f526573756c74437261667465722f6d61696e2e796d6c3f6272616e63683d6d61696e266c6162656c3d6275696c64" alt="Build"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;The idea&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Exceptions are for surprises. A missing order is not a surprise. It is Tuesday. So the service returns it:&lt;/p&gt;
&lt;div class="highlight highlight-source-cs notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;public&lt;/span&gt; &lt;span class="pl-k"&gt;async&lt;/span&gt; &lt;span class="pl-smi"&gt;Task&lt;/span&gt;&lt;span class="pl-c1"&gt;&amp;lt;&lt;/span&gt;&lt;span class="pl-smi"&gt;Result&lt;/span&gt;&lt;span class="pl-c1"&gt;&amp;lt;&lt;/span&gt;&lt;span class="pl-smi"&gt;OrderDto&lt;/span&gt;&lt;span class="pl-c1"&gt;&amp;gt;&lt;/span&gt;&lt;span class="pl-c1"&gt;&amp;gt;&lt;/span&gt; &lt;span class="pl-en"&gt;GetAsync&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-smi"&gt;int&lt;/span&gt; &lt;span class="pl-s1"&gt;id&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-smi"&gt;CancellationToken&lt;/span&gt; &lt;span class="pl-s1"&gt;ct&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;
&lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-k"&gt;var&lt;/span&gt; &lt;span class="pl-s1"&gt;order&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-k"&gt;await&lt;/span&gt; &lt;span class="pl-s1"&gt;repo&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;FindAsync&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;id&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s1"&gt;ct&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;order&lt;/span&gt; &lt;span class="pl-k"&gt;is&lt;/span&gt; &lt;span class="pl-c1"&gt;null&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;
        &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-s1"&gt;Error&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;NotFound&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;&lt;span class="pl-s"&gt;$&lt;/span&gt;"Order &lt;span class="pl-kos"&gt;{&lt;/span&gt;&lt;span class="pl-s1"&gt;id&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt; does not exist."&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;

    &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-s1"&gt;order&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;ToDto&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;The endpoint maps it in one call:&lt;/p&gt;
&lt;div class="highlight highlight-source-cs notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;&lt;span class="pl-s1"&gt;app&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;MapGet&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"/orders/{id:int}"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-k"&gt;async&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-smi"&gt;int&lt;/span&gt; &lt;span class="pl-s1"&gt;id&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-smi"&gt;OrderService&lt;/span&gt; &lt;span class="pl-s1"&gt;orders&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-smi"&gt;CancellationToken&lt;/span&gt; &lt;span class="pl-s1"&gt;ct&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-c1"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-k"&gt;await&lt;/span&gt; &lt;span class="pl-s1"&gt;orders&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;GetAsync&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;id&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;&lt;/pre&gt;…
&lt;/div&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/HaikAsatryan/ResultCrafter" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;br&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package ResultCrafter.AspNetCore
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AddResultCrafter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;UseResultCrafter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MapGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"/orders/{id:int}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;OrderService&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;GetAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ToOkResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client gets a &lt;code&gt;200&lt;/code&gt; with the order, or this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"https://tools.ietf.org/html/rfc9110#section-15.5.5"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"not_found"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"detail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"Order 42 does not exist."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"instance"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"/orders/42"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"traceId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"requestId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"0HN8K2MJ7F4QP:00000001"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What it does for you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Returned errors get the right status: 400, 401, 403, 404, 409, 410, 422 and 429.&lt;/li&gt;
&lt;li&gt;Unhandled exceptions become a clean 500 in the same shape, with full detail in Development and a plain message in Production.&lt;/li&gt;
&lt;li&gt;It covers the "dropped result" weakness at the edge. Return a &lt;code&gt;Result&lt;/code&gt; from an endpoint without mapping it and it fails loudly on the first run, instead of sending your error with a 200.&lt;/li&gt;
&lt;li&gt;It logs 4xx at Warning and 5xx at Error.&lt;/li&gt;
&lt;li&gt;It works with Minimal APIs and controllers, and it is NativeAOT-safe.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is small on purpose: &lt;code&gt;Map&lt;/code&gt; and &lt;code&gt;Match&lt;/code&gt;, no &lt;code&gt;Bind&lt;/code&gt;, no railway chains. If you want a full functional toolkit, or you are on an older .NET, ErrorOr, FluentResults and Ardalis.Result are good libraries. I keep &lt;a href="https://github.com/HaikAsatryan/ResultCrafter/blob/main/docs/comparison.md" rel="noopener noreferrer"&gt;an honest comparison table&lt;/a&gt; in the repository.&lt;/p&gt;

&lt;p&gt;The package is &lt;a href="https://www.nuget.org/packages/ResultCrafter.AspNetCore" rel="noopener noreferrer"&gt;ResultCrafter.AspNetCore on NuGet&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your turn
&lt;/h2&gt;

&lt;p&gt;Pick the rule first and the library second. Return what you expect. Throw what you do not.&lt;/p&gt;

&lt;p&gt;Where do you draw the line in your APIs? Do you throw your 404s or return them? Tell me in the comments, especially if you disagree.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>aspnetcore</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I'm a .NET Developer. I Benchmarked Java vs C# and I Didn't Like the Answer. Again.</title>
      <dc:creator>Haik</dc:creator>
      <pubDate>Tue, 20 Oct 2026 05:37:58 +0000</pubDate>
      <link>https://dev.to/haikasatryan/im-a-net-developer-i-benchmarked-java-vs-c-and-i-didnt-like-the-answer-again-164n</link>
      <guid>https://dev.to/haikasatryan/im-a-net-developer-i-benchmarked-java-vs-c-and-i-didnt-like-the-answer-again-164n</guid>
      <description>&lt;p&gt;In August I &lt;a href="https://dev.to/haikasatryan/im-a-net-developer-i-spent-two-months-benchmarking-go-vs-c-and-i-didnt-like-the-answer-31fm"&gt;benchmarked Go against C#&lt;/a&gt; and did not enjoy the result. But I had a consolation prize ready:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Fine, Go is lean. But at least we beat Java.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I have written C# for years, and "modern .NET is faster than Java" is one of those facts you pick up without ever checking. Every talk and every "C# vs Java" video I have seen lands on the same side. I had honestly never heard anyone argue the opposite.&lt;/p&gt;

&lt;p&gt;So I checked. I built the same invoicing API twice: once in Java 25 with Spring Boot and Hibernate, once in .NET 10 with Minimal API and EF Core. Same PostgreSQL, same Redis, same six CPU cores each. Then one 16-hour run and 44 million measured requests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The short version:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Speed: a tie.&lt;/strong&gt; Both stayed fast up to about 4,800 requests per second.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CPU: Java used half.&lt;/strong&gt; .NET burned about twice the CPU time for every request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory: .NET used a quarter.&lt;/strong&gt; 176 MB against Java's 656 MB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Money: Java was cheaper to run.&lt;/strong&gt; A CPU core rents for 8 to 10 times the price of a gigabyte of RAM, even in the year RAM prices went crazy. Unless your services are small and sleepy. Then .NET wins.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I looked for the config mistake that explains it. I did not find one.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why everybody "knows" .NET is faster
&lt;/h2&gt;

&lt;p&gt;Mostly because of one table.&lt;/p&gt;

&lt;p&gt;For over a decade, TechEmpower's Framework Benchmarks were the scoreboard everyone screenshotted. In the final round (Round 23, February 2025), the Fortunes test shows ASP.NET Core at about 610,000 requests per second and Spring at about 244,000 (&lt;a href="https://dev.to/tuananhpham/popular-backend-frameworks-performance-benchmark-1bkh"&gt;the numbers people share&lt;/a&gt;). Two and a half times faster. Put that on a slide and the meeting is over.&lt;/p&gt;

&lt;p&gt;The project &lt;a href="https://github.com/TechEmpower/FrameworkBenchmarks/issues/10932" rel="noopener noreferrer"&gt;shut down in March 2026&lt;/a&gt;, but screenshots are forever.&lt;/p&gt;

&lt;p&gt;Here is the catch, which I went through in the &lt;a href="https://dev.to/haikasatryan/im-a-net-developer-i-spent-two-months-benchmarking-go-vs-c-and-i-didnt-like-the-answer-31fm"&gt;last post&lt;/a&gt;: the rows at the top are hand-tuned entries with most of the framework ripped out. Nobody ships that code. It is a drag racer. It tells you nothing about what your family car burns on the way to work.&lt;/p&gt;

&lt;p&gt;Last time, real code did not behave the way the scoreboard said it would. So I stopped trusting it for Java too, and measured.&lt;/p&gt;




&lt;h2&gt;
  
  
  The test
&lt;/h2&gt;

&lt;p&gt;The app is called Ledgerline. It is a boring invoicing backend, on purpose. Look up an invoice. List a customer's invoices. Price a quote. Create an invoice with line items. PostgreSQL holds 1 million customers and 5 million invoices, and Redis caches the reads.&lt;/p&gt;

&lt;p&gt;Both versions are written the way a normal team would write them.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Java&lt;/th&gt;
&lt;th&gt;C#&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Runtime&lt;/td&gt;
&lt;td&gt;Java 25 (LTS)&lt;/td&gt;
&lt;td&gt;.NET 10 (LTS)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web&lt;/td&gt;
&lt;td&gt;Spring Boot 4, virtual threads&lt;/td&gt;
&lt;td&gt;ASP.NET Core Minimal API, async/await&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database access&lt;/td&gt;
&lt;td&gt;Hibernate 7&lt;/td&gt;
&lt;td&gt;EF Core 10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Garbage collector&lt;/td&gt;
&lt;td&gt;the default (G1)&lt;/td&gt;
&lt;td&gt;the default (Server GC)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Given to each&lt;/td&gt;
&lt;td&gt;6 CPU cores, 12 GB of RAM&lt;/td&gt;
&lt;td&gt;6 CPU cores, 12 GB of RAM&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rule for both sides: if the project template gives it to you, it is in. If you would only add it to win a benchmark, it is out. I even removed four benchmark-flavoured tricks from my own .NET code before the run (a precompiled EF model, source-generated JSON, the slim app builder and a trimmed-down container image). Yes, I nerfed my own team.&lt;/p&gt;

&lt;p&gt;Two .NET settings are not defaults, and both help .NET: DbContext pooling, which is common practice anyway, and automatic prepared statements in Npgsql, because Java's Postgres driver does that out of the box. Every other difference, on either side, is listed in the repo's &lt;a href="https://github.com/HaikAsatryan/java-vs-dotnet-benchmark/blob/main/docs/FAIRNESS.md" rel="noopener noreferrer"&gt;FAIRNESS.md&lt;/a&gt;, including which side each difference hurts.&lt;/p&gt;

&lt;p&gt;How the measuring works, in plain words:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A load generator sends requests at a fixed pace. It does not slow down when the service does, so a struggling service cannot hide.&lt;/li&gt;
&lt;li&gt;I raise the pace step by step until the slowest 1% of requests take longer than 20 ms. (That "slowest 1%" number is called p99.) The traffic level where that happens is that stack's &lt;strong&gt;limit&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Then I run both at their limits, back to back, ten times. CPU and memory are read from the Linux kernel's own counters, not from what the runtimes say about themselves.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Only one service runs at a time, and each gets a few minutes of warm-up traffic before anything is recorded.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft15tp3yum1b85ufxl5nb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft15tp3yum1b85ufxl5nb.png" alt="The setup: a load generator, the two services, PostgreSQL and Redis, each on its own CPU cores" width="800" height="399"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The results
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Speed: a tie
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8gyfeud2cnlnl72okbs0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8gyfeud2cnlnl72okbs0.png" alt="Slowest 1% of requests as traffic goes up; both cross 20 ms near 4,800 requests per second" width="800" height="562"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Java's limit was 4,799 requests per second. .NET's was 4,833. That is a 0.7% difference. A draw.&lt;/p&gt;

&lt;p&gt;And neither was out of breath. At that point Java was using about 1.1 of its 6 cores and .NET about 2.3. So the limit comes from something in the shared plumbing, like the database connection pool, and not from the language. I did not chase down exactly what.&lt;/p&gt;

&lt;p&gt;Which means the real question is not "who is faster". It is "what does each one spend to be that fast".&lt;/p&gt;

&lt;h3&gt;
  
  
  CPU: Java did it with half
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsgha93uvig0lott9q5fm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsgha93uvig0lott9q5fm.png" alt="CPU time per request as traffic goes up; Java sits at about half of .NET" width="799" height="591"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At its limit Java spent 0.233 milliseconds of CPU per request. .NET spent 0.481. That is 2.1 times more. In the back-to-back runs Java used 51.6% less CPU, and the result barely wobbled between runs (95% interval: 51.3% to 51.8%).&lt;/p&gt;

&lt;p&gt;This is not a number picked at a flattering spot. From 1,500 requests per second all the way up, the gap stays between 2.0x and 2.2x. At light traffic it is wider: 3x at 500 requests per second.&lt;/p&gt;

&lt;h3&gt;
  
  
  Memory: .NET did it with a quarter
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fohdz89lvpwqr9emhklqq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fohdz89lvpwqr9emhklqq.png" alt="Memory in use as traffic goes up; .NET sits far below Java" width="800" height="588"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now the other side. At its limit .NET held 176 MB of memory. Java held 656 MB, which is 3.7 times more. At light traffic the gap is even bigger: 59 MB against 461 MB, almost 8x.&lt;/p&gt;

&lt;p&gt;Java was not squeezed, by the way. It was allowed about 3 GB of heap and its heap never passed 328 MB. This is simply how much elbow room a JVM likes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two things I was sure about, and was wrong about
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;"Hibernate must be hammering the database."&lt;/strong&gt; I had a reason to think so. Hibernate cannot batch inserts on this schema, so creating one invoice costs Java about 11.5 trips to the database where EF Core needs 6. I measured PostgreSQL's CPU separately to catch exactly this. Result: 0.101 ms per request when serving Java, 0.115 ms when serving .NET. Postgres did not care.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"The JVM takes forever to warm up."&lt;/strong&gt; Before every measurement, each service gets traffic until it settles: steady response times, steady garbage collection. The fastest that can possibly happen is 183 seconds.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe0t60j9q8fxct741sbqp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe0t60j9q8fxct741sbqp.png" alt="Seconds until each service settled, one dot per test run" width="799" height="441"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Java settled in 183 seconds, 53 times out of 53. .NET usually needed 213 seconds, and 11 times out of 63 it ran into the 300-second time limit. The JVM, famous for showing up late, was early to every single meeting.&lt;/p&gt;

&lt;p&gt;To be clear, this is not start-up time. I did not measure how fast each app boots, so I am claiming nothing about that.&lt;/p&gt;

&lt;h3&gt;
  
  
  So why does .NET need twice the CPU?
&lt;/h3&gt;

&lt;p&gt;Honest answer: I do not know. I did not profile it. I can only tell you what it is not.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Not the database.&lt;/strong&gt; See above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not garbage collection pauses.&lt;/strong&gt; .NET does collect a lot, about 26 times per second at its limit, because it keeps its heap tiny. That is how it stays at 176 MB. But those pauses add up to under 2% of the time. Even if all six cores were busy during every pause, that is about 5% of .NET's CPU. Not half.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not allocations.&lt;/strong&gt; .NET actually allocated less per request: 68 KB against Java's 88 KB.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What is left is the request path itself: the web server, JSON, async, the database driver. Someone with a profiler and a free afternoon could probably name the top three. If that is you, the &lt;a href="https://github.com/HaikAsatryan/java-vs-dotnet-benchmark" rel="noopener noreferrer"&gt;repo&lt;/a&gt; is open and I would love to know.&lt;/p&gt;




&lt;h2&gt;
  
  
  Same speed, different bill
&lt;/h2&gt;

&lt;p&gt;So Java pays in RAM and .NET pays in CPU. Which one is cheaper depends on what RAM and CPU cost. And 2026 is a funny year to ask, because RAM prices went through the roof.&lt;/p&gt;

&lt;p&gt;TrendForce, the people who track memory chip prices, forecast contract prices for ordinary DRAM up &lt;a href="https://www.trendforce.com/presscenter/news/20260105-12860.html" rel="noopener noreferrer"&gt;55 to 60% in the first quarter&lt;/a&gt;, &lt;a href="https://www.trendforce.com/presscenter/news/20260331-12995.html" rel="noopener noreferrer"&gt;another 58 to 63% in the second&lt;/a&gt;, and &lt;a href="https://www.trendforce.com/presscenter/news/20260930-13258.html" rel="noopener noreferrer"&gt;10 to 15% more this quarter&lt;/a&gt;. I fully expected that to flip my conclusion.&lt;/p&gt;

&lt;p&gt;Then I opened the cloud price lists. These are the prices on October 20, 2026, US regions, pay-as-you-go:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cloud&lt;/th&gt;
&lt;th&gt;1 vCPU for an hour&lt;/th&gt;
&lt;th&gt;1 GB of RAM for an hour&lt;/th&gt;
&lt;th&gt;CPU costs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://aws.amazon.com/fargate/pricing/" rel="noopener noreferrer"&gt;AWS Fargate&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;$0.0405&lt;/td&gt;
&lt;td&gt;$0.0044&lt;/td&gt;
&lt;td&gt;9x more&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://azure.microsoft.com/en-us/pricing/details/container-apps/" rel="noopener noreferrer"&gt;Azure Container Apps&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;$0.0864&lt;/td&gt;
&lt;td&gt;$0.0108&lt;/td&gt;
&lt;td&gt;8x more&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://cloud.google.com/run/pricing" rel="noopener noreferrer"&gt;Google Cloud Run&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;$0.0864&lt;/td&gt;
&lt;td&gt;$0.0090&lt;/td&gt;
&lt;td&gt;10x more&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;(AWS bills per GB, the other two per GiB, which is 7% bigger. It does not change the picture.)&lt;/p&gt;

&lt;p&gt;Nothing moved. Azure's own price feed dates its rate to June 2022. So far the memory crunch has landed on GPUs (&lt;a href="https://thenextweb.com/news/aws-gpu-prices-increase-memory-shortage" rel="noopener noreferrer"&gt;AWS raised its reserved GPU prices twice this year&lt;/a&gt;), not on the plain CPU and RAM a web API runs on. That can change, so check the date on this post.&lt;/p&gt;

&lt;p&gt;Now price what each service actually used at its limit, with the AWS numbers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Java&lt;/th&gt;
&lt;th&gt;.NET&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CPU in use&lt;/td&gt;
&lt;td&gt;1.12 cores&lt;/td&gt;
&lt;td&gt;2.32 cores&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory in use&lt;/td&gt;
&lt;td&gt;0.66 GB&lt;/td&gt;
&lt;td&gt;0.18 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU, per hour&lt;/td&gt;
&lt;td&gt;$0.045&lt;/td&gt;
&lt;td&gt;$0.094&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RAM, per hour&lt;/td&gt;
&lt;td&gt;$0.003&lt;/td&gt;
&lt;td&gt;$0.001&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total, per hour&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.048&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.095&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F493fzjg5ohfbrg28xxi4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F493fzjg5ohfbrg28xxi4.png" alt="Dollars per hour at the limit: Java $0.048, .NET $0.095" width="800" height="374"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Run that for a month and it is roughly $35 against $69, for one small service.&lt;/p&gt;

&lt;p&gt;Java's extra 480 MB of RAM costs about a fifth of a cent per hour. .NET's extra 1.2 cores cost about five cents. So RAM would have to get about 23 times more expensive before the two bills meet. It has been a wild year for RAM. Not that wild.&lt;/p&gt;

&lt;p&gt;Two honest footnotes on the money.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Clouds sell RAM in a bundle with CPU.&lt;/strong&gt; You do not pay for what you use, you pay for the size you pick. Fargate &lt;a href="https://docs.aws.amazon.com/AmazonECS/latest/developerguide/fargate-tasks-services.html" rel="noopener noreferrer"&gt;will not sell you&lt;/a&gt; 2 vCPU with less than 4 GB of RAM, or 4 vCPU with less than 8 GB. Both services fit inside that with lots of room to spare, so on a platform like that Java's extra memory costs nothing extra at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Small, sleepy services are a different story.&lt;/strong&gt; When traffic is low, the CPU bill shrinks toward zero and the memory bill stays. At 500 requests per second .NET fit in 59 MB where Java needed 461 MB. By my math the two bills cross at around 100 requests per second per instance. That is below anything I measured, so treat it as a rough guess. But if you run a hundred little services that mostly sit idle on a shared cluster, you can pack almost eight .NET services into the memory of one Java service. That is real money too.&lt;/p&gt;

&lt;p&gt;The memory gap is partly a choice each runtime makes for you. Java's garbage collector takes the room it is given. .NET's keeps the heap as small as it can and collects 26 times a second to do it. Neither is wrong. They just bet on different things being expensive, and on today's price list the expensive thing is CPU.&lt;/p&gt;




&lt;h2&gt;
  
  
  The fine print
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One machine, one full run.&lt;/strong&gt; A shorter rehearsal earlier the same day gave the same picture: about half the CPU for Java, 3.8x the memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One kind of app.&lt;/strong&gt; A database-and-cache business backend. Heavy math, streaming or gRPC could land somewhere else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Defaults only.&lt;/strong&gt; I measured what the templates ship, not what a tuning expert could squeeze out of either side.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The 4,800 limit is a bit optimistic.&lt;/strong&gt; When I re-ran both at exactly that rate, the slowest 1% averaged 27 to 29 ms, not 20, for both. So the true limit is a little lower for both. The cost comparison does not care: the CPU gap is 2x or more at every rate I measured.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nine pairs, not ten.&lt;/strong&gt; I planned ten back-to-back pairs. One .NET run never settled, so that pair was thrown out. My own rules said ten, so the report labels the verdict "directional".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Versions.&lt;/strong&gt; Java 25.0.4 and .NET 10.0.11, measured on August 27 and 28, 2026. Both are still the current LTS lines. .NET 11 lands in November and Java 27 came out in September. I have measured neither.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No start-up times.&lt;/strong&gt; Only how long each took to settle under traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One thing I found after the run.&lt;/strong&gt; This machine has a Linux setting that packs work onto the CPU's "best" cores, and it was almost certainly on. In a later test on the same machine it caused stalls. This run shows no sign of them, but I cannot fully rule it out. That, and how I will find what really caps both at 4,800, is in &lt;a href="https://github.com/HaikAsatryan/java-vs-dotnet-benchmark/blob/main/docs/NEXT-ROUND.md" rel="noopener noreferrer"&gt;NEXT-ROUND.md&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of it, plus the raw data and checksums, is in the run's own &lt;a href="https://github.com/HaikAsatryan/java-vs-dotnet-benchmark/blob/main/results/full-20260827T174611-fedora/report/report.md" rel="noopener noreferrer"&gt;report&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  So, Java or C#?
&lt;/h2&gt;

&lt;p&gt;Here is the thing about this rivalry: it is a family argument. C# was born in 2000 as Microsoft's answer to Java, and Java's creator James Gosling &lt;a href="https://en.wikipedia.org/wiki/C_Sharp_%28programming_language%29" rel="noopener noreferrer"&gt;called it&lt;/a&gt; "sort of Java with reliability, productivity and security deleted". A quarter of a century later the two have copied each other's homework so many times that they ended up with nearly the same feature list: records, pattern matching, cheap concurrency, a long-term release every two years, a library for everything, and jobs on every continent.&lt;/p&gt;

&lt;p&gt;So pick like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pick the one your team knows and can hire for.&lt;/strong&gt; This beats everything below. A happy Java team will out-ship a grumpy C# team, and the other way around, by a lot more than 2x.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Then look at your bill.&lt;/strong&gt; Big busy services where CPU is the cost: this test says Java, at about half the compute. Lots of small quiet services where memory is the cost: this test says .NET, at a quarter of the memory or less.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;And fix your queries first.&lt;/strong&gt; A missing index will cost you more than anything in this post.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Before this run I would have told you modern .NET is faster than Java and moved on. Now I would say they are the same speed, one of them is quietly cheaper to run, and it is not the one my career is built on.&lt;/p&gt;

&lt;p&gt;That is twice in a row. I am still writing C# on Monday.&lt;/p&gt;

&lt;p&gt;Do not take it from a scoreboard, and do not take it from me either. Measure your own service.&lt;/p&gt;




&lt;h2&gt;
  
  
  Comments I expect
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;"It's EF Core."&lt;/strong&gt; The database did the same amount of work for both. And I tested EF Core against Dapper and raw ADO.NET in the &lt;a href="https://dev.to/haikasatryan/im-a-net-developer-i-spent-two-months-benchmarking-go-vs-c-and-i-didnt-like-the-answer-31fm"&gt;last round&lt;/a&gt;: the ORM was not where the big cost was.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Use Native AOT."&lt;/strong&gt; Native AOT does not cover the reflection EF Core relies on, so it would be a different app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Give .NET a bigger heap."&lt;/strong&gt; I did not test it. It would cost memory and probably save some CPU. But GC pauses were under 5% of .NET's CPU, so I would not expect it to close a 2x gap. (The small heap is .NET's default behaviour here. It is called DATAS.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Spring Boot is the slow Java. Try Quarkus."&lt;/strong&gt; Mainstream against mainstream was the point. Leaner stacks exist on both sides.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Can I run it myself?"&lt;/strong&gt; Yes. Everything is in the &lt;a href="https://github.com/HaikAsatryan/java-vs-dotnet-benchmark" rel="noopener noreferrer"&gt;repo&lt;/a&gt;: both services, the test runner and the raw results. A full run needs Linux and at least 15 CPU cores. A 20-minute smoke test runs on a laptop.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Round 1, Go vs C#: &lt;a href="https://dev.to/haikasatryan/im-a-net-developer-i-spent-two-months-benchmarking-go-vs-c-and-i-didnt-like-the-answer-31fm"&gt;post&lt;/a&gt;. Round 2 repo with both services, the method, the fairness list and the raw data: &lt;a href="https://github.com/HaikAsatryan/java-vs-dotnet-benchmark" rel="noopener noreferrer"&gt;github.com/HaikAsatryan/java-vs-dotnet-benchmark&lt;/a&gt;. Every measured number in this post comes from run &lt;code&gt;full-20260827T174611-fedora&lt;/code&gt;, except the rehearsal line in the fine print. Cloud prices were checked on October 20, 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>java</category>
      <category>csharp</category>
      <category>dotnet</category>
      <category>performance</category>
    </item>
    <item>
      <title>I'm a .NET Developer. I Spent Two Months Benchmarking Go vs C# and I Didn't Like the Answer.</title>
      <dc:creator>Haik</dc:creator>
      <pubDate>Thu, 20 Aug 2026 11:22:10 +0000</pubDate>
      <link>https://dev.to/haikasatryan/im-a-net-developer-i-spent-two-months-benchmarking-go-vs-c-and-i-didnt-like-the-answer-31fm</link>
      <guid>https://dev.to/haikasatryan/im-a-net-developer-i-spent-two-months-benchmarking-go-vs-c-and-i-didnt-like-the-answer-31fm</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Then I stopped arguing and measured it.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Everything is open source, so you can check the work as you go: &lt;strong&gt;&lt;a href="https://github.com/HaikAsatryan/go-vs-dotnet-benchmark" rel="noopener noreferrer"&gt;github.com/HaikAsatryan/go-vs-dotnet-benchmark&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 1: why the leaderboard says the opposite
&lt;/h2&gt;

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

&lt;p&gt;&lt;a href="https://www.techempower.com/benchmarks/" rel="noopener noreferrer"&gt;TechEmpower&lt;/a&gt; was the reference for a decade. Its &lt;strong&gt;Fortunes&lt;/strong&gt; 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:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;framework&lt;/th&gt;
&lt;th&gt;language&lt;/th&gt;
&lt;th&gt;Fortunes req/s&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ASP.NET Core&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;C#&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;609,966&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fiber&lt;/td&gt;
&lt;td&gt;Go&lt;/td&gt;
&lt;td&gt;338,096&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Actix&lt;/td&gt;
&lt;td&gt;Rust&lt;/td&gt;
&lt;td&gt;320,144&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spring&lt;/td&gt;
&lt;td&gt;Java&lt;/td&gt;
&lt;td&gt;243,639&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Express&lt;/td&gt;
&lt;td&gt;Node&lt;/td&gt;
&lt;td&gt;78,136&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Django&lt;/td&gt;
&lt;td&gt;Python&lt;/td&gt;
&lt;td&gt;32,651&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Round 23 Fortunes, popular-framework slice, &lt;a href="https://dev.to/tuananhpham/popular-backend-frameworks-performance-benchmark-1bkh"&gt;as tabulated by Tuan Anh Pham&lt;/a&gt;. The full leaderboard has a long tail of C++ and Rust exotica nobody ships either.&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Then why is Docker written in Go?
&lt;/h3&gt;

&lt;p&gt;And Kubernetes. And Terraform, Prometheus, etcd, containerd, Grafana, InfluxDB, Traefik, Caddy, CockroachDB, Vault, Consul, Cilium.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;"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.&lt;/p&gt;

&lt;p&gt;And then there is the one that should really bother a C# person.&lt;/p&gt;

&lt;p&gt;In 2025, Microsoft ported the TypeScript compiler to native code, chasing roughly a 10x speedup. They wrote it in &lt;strong&gt;Go&lt;/strong&gt;. The lead architect of TypeScript is &lt;strong&gt;Anders Hejlsberg&lt;/strong&gt;, 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.&lt;/p&gt;

&lt;p&gt;His &lt;a href="https://devclass.com/2025/03/12/typescript-compiler-ported-to-native-code-c-faithful-ask-why-go-was-used/" rel="noopener noreferrer"&gt;stated reasoning&lt;/a&gt;: 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."&lt;/p&gt;

&lt;h3&gt;
  
  
  The three reasons people give, and why none of them is on your bill
&lt;/h3&gt;

&lt;p&gt;Push on "Go is faster" and you usually get some mix of three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It starts instantly.&lt;/strong&gt; Milliseconds. No JIT warm-up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It ships as one small static binary.&lt;/strong&gt; No runtime to install, tiny container image.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It sits on almost no RAM when idle.&lt;/strong&gt; Ten megabytes and a nap.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All three are true. Go wins all three, and it isn't close.&lt;/p&gt;

&lt;p&gt;Now look at what they have in common: &lt;strong&gt;none of them is on your bill.&lt;/strong&gt; 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.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  The catch: which row are you quoting?
&lt;/h3&gt;

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

&lt;p&gt;.NET's headline entry, &lt;code&gt;aspcore&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;Compare the &lt;em&gt;Fullstack&lt;/em&gt; rows instead and the order flips. Dustin Moris Gorski &lt;a href="https://dusted.codes/how-fast-is-really-aspnet-core" rel="noopener noreferrer"&gt;ran exactly that comparison on Round 21&lt;/a&gt;: ASP.NET Core MVC with real templating managed about &lt;strong&gt;184k req/s&lt;/strong&gt; on Fortunes, while a Go entry did about &lt;strong&gt;381k&lt;/strong&gt;. (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.&lt;/p&gt;

&lt;p&gt;TechEmpower &lt;a href="https://github.com/TechEmpower/FrameworkBenchmarks/issues/10932" rel="noopener noreferrer"&gt;shut down in March 2026&lt;/a&gt;. Its announcement gives no reasons, so I won't invent any, but the &lt;a href="https://dev.to/kaliumhexacyanoferrat/techempower-framework-benchmarks-are-now-archived-whats-next-3l0a"&gt;widely shared community read&lt;/a&gt; was that those low-level tricks had turned it into "more a socket benchmark than actual real-life implementations used by end-users."&lt;/p&gt;

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

&lt;p&gt;So I measured the thing you deploy.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 2: the benchmark I ran
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What I built
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Ledgerline&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;I wrote the spec once, &lt;strong&gt;froze it before measuring anything&lt;/strong&gt;, and implemented it twice the way a normal team would ship it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Go:&lt;/strong&gt; &lt;code&gt;net/http&lt;/code&gt;, &lt;code&gt;sqlc&lt;/code&gt; over &lt;code&gt;pgx&lt;/code&gt;, &lt;code&gt;go-redis&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;C#:&lt;/strong&gt; Minimal API on .NET 10, EF Core, Npgsql, StackExchange.Redis.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnuplvgg59m8xkw9w9dba.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnuplvgg59m8xkw9w9dba.png" alt="The test rig: one box, one workload, two implementations of the same frozen spec" width="800" height="492"&gt;&lt;/a&gt;&lt;/p&gt;

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

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

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

&lt;p&gt;&lt;strong&gt;2. The load is open loop.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The database gets its own cores, and it never broke.&lt;/strong&gt; Before any measured run I laddered PostgreSQL on its own 3 cores to &lt;strong&gt;3,715 req/s&lt;/strong&gt; 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 &lt;em&gt;before&lt;/em&gt; 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.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;p99 means:&lt;/strong&gt; 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.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Part 3: the results
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Result 1: Go did the same work for a third of the CPU
&lt;/h3&gt;

&lt;p&gt;Same rate, same work, same box:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpi7omgxtlz9skrm7fnwn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpi7omgxtlz9skrm7fnwn.png" alt="CPU-ms burned per request vs offered load: C# runs 3-5x higher than Go at every rate" width="800" height="465"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Read the ratios left to right, because this is the most practically useful thing in the post. &lt;strong&gt;The ratio is not a number, it is a curve.&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;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. &lt;strong&gt;A .NET service cruising at 10% CPU is sitting at the expensive end of its own curve.&lt;/strong&gt;&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Result 2: 5x less memory, with one honest caveat
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2wgaigr89o0wst6aqa7h.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2wgaigr89o0wst6aqa7h.png" alt="Memory: 32 MB vs 171 MB while serving, but only 1.15x apart on container peak" width="799" height="621"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;And now the caveat, because I would rather you hear it from me than from the comments:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you size containers by the kernel's high-water mark over the container's whole life&lt;/strong&gt; (which includes page cache and warm-up), the two land within 15% of each other. Go's advantage is in &lt;em&gt;steady-state&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;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 (&lt;code&gt;GOGC=off&lt;/code&gt; against .NET with dynamic heap adaptation disabled), &lt;strong&gt;Go ballooned to 6.6 GB while .NET held 212 MB.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Result 3: C# hits the wall first
&lt;/h3&gt;

&lt;p&gt;Cost is the bill. Capacity is the cliff.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcpjp4drr181dw4sylzae.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcpjp4drr181dw4sylzae.png" alt="p99 latency vs offered load: C# crosses the 20 ms promise at 10,426 req/s, Go never crosses" width="800" height="586"&gt;&lt;/a&gt;&lt;/p&gt;

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

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;offered rate&lt;/th&gt;
&lt;th&gt;Go p99&lt;/th&gt;
&lt;th&gt;C# p99&lt;/th&gt;
&lt;th&gt;20 ms promise&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;11,093 req/s&lt;/td&gt;
&lt;td&gt;15.2 ms&lt;/td&gt;
&lt;td&gt;23.7 ms (worst window 39.3)&lt;/td&gt;
&lt;td&gt;Go meets, C# misses&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;13,312 req/s&lt;/td&gt;
&lt;td&gt;16.3 ms (worst 20.4)&lt;/td&gt;
&lt;td&gt;103.1 ms*&lt;/td&gt;
&lt;td&gt;Go meets, C# is past its wall&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;* 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.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  "It's EF Core's fault." No, it isn't.
&lt;/h3&gt;

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

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3smpir75ee4d5qw35qhy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3smpir75ee4d5qw35qhy.png" alt="Knee by .NET data layer: EF Core, Dapper and raw ADO.NET all land near 10,300 req/s" width="799" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The three at the top are the same number. &lt;strong&gt;EF Core, Dapper and hand-written ADO.NET land within 2.5% of each other&lt;/strong&gt;, 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.&lt;/p&gt;

&lt;p&gt;The outlier at the bottom is EF Core on Npgsql's defaults. The fix is one connection-string setting, &lt;strong&gt;&lt;code&gt;Max Auto Prepare&lt;/code&gt;&lt;/strong&gt;, 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 &lt;strong&gt;7x&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;off&lt;/em&gt;, 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.&lt;/p&gt;

&lt;p&gt;Hold on to that number. It comes back in a minute.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I am &lt;em&gt;not&lt;/em&gt; claiming
&lt;/h2&gt;

&lt;p&gt;A benchmark that only tells you the good parts is marketing. Here's what this run does not support:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No Go knee.&lt;/strong&gt; Go never crossed 20 ms in any ladder I ran, so its wall is unmeasured. Every Go capacity number here is a floor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not a single ratio.&lt;/strong&gt; Cost per request is a curve on both sides. Every ratio here is stamped with the rate it was measured at.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;These knees are not precision instruments.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Warm-window thinness is this run's biggest data-quality cost.&lt;/strong&gt; My warm-up gates (GC cadence steady, tail flat, pools warm) flagged &lt;strong&gt;59.5% of all 618 windows&lt;/strong&gt;, 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No statistical significance on the paired latency deltas.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TechEmpower is not wrong on its own terms.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not captured:&lt;/strong&gt; per-query PostgreSQL CPU attribution, GC pause histograms, and anything off this one box, one workload, one topology.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;And both of these are genuinely fast.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Hygiene, since a benchmark should show its receipts: 618 measured windows, 358,673,386 requests, &lt;strong&gt;seven HTTP 500s in total&lt;/strong&gt;, 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  So what does this actually mean?
&lt;/h2&gt;

&lt;p&gt;Let me say the uncomfortable part plainly, because I'm the one who has to live with it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On the code people actually ship, Go beat C# on every performance axis I measured.&lt;/strong&gt; Not narrowly. Not in a lab. Not with tricks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;~3x less CPU&lt;/strong&gt; per request at the same load, and up to &lt;strong&gt;4.85x&lt;/strong&gt; at low load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;3x to 5x less&lt;/strong&gt; steady-state memory while serving.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lower p99&lt;/strong&gt; 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.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;More traffic&lt;/strong&gt; before the latency promise broke, with no measured ceiling.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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 &lt;strong&gt;Go's floor&lt;/strong&gt; (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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And the next time someone links you TechEmpower:&lt;/strong&gt; 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?&lt;/p&gt;

&lt;p&gt;I'd rather have found the opposite. But the point of measuring is that you don't get to choose.&lt;/p&gt;




&lt;h2&gt;
  
  
  Now the part that actually matters
&lt;/h2&gt;

&lt;p&gt;Everything above is about &lt;strong&gt;speed and cost&lt;/strong&gt;. 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."&lt;/p&gt;

&lt;p&gt;So here's the guide I'd actually give a junior engineer asking "which language should we use?"&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo3l6oasidhu6ebo4abgv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo3l6oasidhu6ebo4abgv.png" alt="Quadrant: pick a language per service by domain complexity and compute bill" width="799" height="635"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The most important input is your team, not the language
&lt;/h3&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;If you cannot hire it, it doesn't matter how fast it benchmarks.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Architecture beats language by an order of magnitude. I measured that by accident.
&lt;/h3&gt;

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

&lt;p&gt;Now think about the things that are far worse than a missing config flag:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an N+1 query in a hot path&lt;/li&gt;
&lt;li&gt;a missing index&lt;/li&gt;
&lt;li&gt;a chatty service boundary that turns one request into twelve&lt;/li&gt;
&lt;li&gt;no cache where a cache obviously belongs&lt;/li&gt;
&lt;li&gt;a synchronous call where a queue belonged&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of those routinely costs 10x to 100x. &lt;strong&gt;Go with any of them loses to C# without them, and it isn't close.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You cannot out-language a bad design. You can very easily out-design a slow language.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. You don't pick a language once. You pick one per service.
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Use each language for what it was built for
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Go&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;C# / .NET&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"But Rust is faster than Go. Why isn't Docker in Rust?"&lt;/strong&gt; 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: &lt;a href="https://github.com/microsoft/typescript-go/discussions/411" rel="noopener noreferrer"&gt;a from-scratch Rust rewrite&lt;/a&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The honest caveat&lt;/strong&gt;, because absolutes are how you get torn apart in the comments: all of this is a centre of gravity, not a law. &lt;a href="https://www.theregister.com/2020/03/09/monzo_microservices/" rel="noopener noreferrer"&gt;Monzo runs a bank on roughly 1,600 Go microservices&lt;/a&gt;. That's an argument about how you &lt;em&gt;slice&lt;/em&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Do the arithmetic before you care about 3x
&lt;/h3&gt;

&lt;p&gt;Paying 3x means a bill that could be \$100 is \$300 instead. So:&lt;/p&gt;

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

&lt;p&gt;Work out which one you are. Most people argue like the second and live like the first.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. When the bill does hit, you don't rewrite everything
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;"We'll rewrite the hot paths later" is not technical debt. It's the correct plan.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick answers to the comments I'm going to get
&lt;/h2&gt;

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

&lt;p&gt;&lt;strong&gt;"What about Native AOT? Doesn't that close the gap?"&lt;/strong&gt;&lt;br&gt;
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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"So we should migrate to Go?"&lt;/strong&gt;&lt;br&gt;
Only if your compute bill is genuinely large, your domain is genuinely thin, &lt;em&gt;and&lt;/em&gt; your team can write good Go. Two out of three is a no.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Isn't this just EF Core being slow?"&lt;/strong&gt;&lt;br&gt;
No. Dapper and hand-written ADO.NET land within 2.5% of EF Core's number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Does this mean C# is bad?"&lt;/strong&gt;&lt;br&gt;
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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Your Go/C# code is unidiomatic."&lt;/strong&gt;&lt;br&gt;
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.&lt;/p&gt;




&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Run &lt;code&gt;20260814T051533-fedora&lt;/code&gt;. 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, &lt;a href="https://github.com/HaikAsatryan/go-vs-dotnet-benchmark/issues" rel="noopener noreferrer"&gt;open an issue&lt;/a&gt; and I'll fix the post.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>csharp</category>
      <category>dotnet</category>
      <category>performance</category>
    </item>
    <item>
      <title>PostgreSQL Write Performance: What the Benchmarks Won't Tell You</title>
      <dc:creator>Haik</dc:creator>
      <pubDate>Sun, 12 Apr 2026 18:24:28 +0000</pubDate>
      <link>https://dev.to/haikasatryan/postgresql-write-performance-what-the-benchmarks-wont-tell-you-mm7</link>
      <guid>https://dev.to/haikasatryan/postgresql-write-performance-what-the-benchmarks-wont-tell-you-mm7</guid>
      <description>&lt;p&gt;I've spent years building financial systems on PostgreSQL. Payment processing, account management, transaction engines. The kind of systems where every write matters and you can't cut corners.&lt;/p&gt;

&lt;p&gt;A while ago, I kept running into the same story online: "Boost your PostgreSQL to 100,000 inserts per second." "Python + Postgres: 1 million requests per second." The numbers were everywhere. Conferences, blog posts, YouTube videos. So when one of our products, a well-architected .NET application with a properly configured PostgreSQL instance, barely hit 1,000 writes per second on hot paths, I thought we did something wrong.&lt;/p&gt;

&lt;p&gt;We didn't.&lt;/p&gt;

&lt;p&gt;I spent weeks digging into this. Reviewing our code, our PostgreSQL configuration, our infrastructure. Everything was solid. The architecture was clean. The queries were optimized. The connection pooling was in place. And then I started actually reading those benchmarks carefully. That's when I realized: most of them are, to put it politely, not representative of real-world workloads.&lt;/p&gt;

&lt;p&gt;This blog is what I found.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; On a 16-core Ryzen 9 with a top-tier NVMe and 62 GB RAM, a single PostgreSQL 18 node tops out at &lt;strong&gt;~1,875 realistic transactional writes per second&lt;/strong&gt; with full durability. In production with standard SSDs and more complex schemas, expect less. That's a long way from the 100,000 inserts/sec that synthetic benchmarks advertise. Full benchmark results and methodology below.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/tt_TULMl92M"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Only Writes?
&lt;/h2&gt;

&lt;p&gt;Let me be upfront about the scope. This post is only about write performance. Not reads. Not bulk inserts. Single-row transactional writes: the kind that happen when a real user hits a real API endpoint and expects a real result.&lt;/p&gt;

&lt;p&gt;Why ignore reads? Because read performance on PostgreSQL is, relatively speaking, a solved problem. Not trivial, but well-documented and dramatically easier to scale. The toolkit is rich and the path is clear:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Indexing strategies&lt;/strong&gt; are mature. B-tree, GIN, GiST, partial indexes, covering indexes. There are dozens of excellent guides on this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read replicas&lt;/strong&gt; scale horizontally with minimal effort. You can set up a CQRS pattern with N replicas, and PostgreSQL handles replication natively.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partitioning&lt;/strong&gt; works well and keeps large tables manageable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL 18 introduced asynchronous I/O&lt;/strong&gt; for read operations, delivering significant performance improvements (up to 2-3x on cloud storage with higher latency, and measurable gains even on local SSDs). Worth noting: this async I/O applies only to read operations. Writes, including WAL flushing, remain synchronous. (&lt;a href="https://www.postgresql.org/docs/current/release-18.html" rel="noopener noreferrer"&gt;PostgreSQL 18 Release Notes&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PgBouncer&lt;/strong&gt; and connection pooling eliminate connection overhead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared buffers and OS page cache&lt;/strong&gt; can serve most read workloads entirely from RAM.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To put this in perspective: OpenAI runs a single-primary PostgreSQL deployment with nearly 50 read replicas, serving millions of queries per second for 800 million ChatGPT users. Their read scaling works. Their bottleneck? Writes. They directed new write-heavy workloads to Azure Cosmos DB because PostgreSQL's single-node write architecture has real limits. (&lt;a href="https://openai.com/index/scaling-postgresql/" rel="noopener noreferrer"&gt;OpenAI: Scaling PostgreSQL&lt;/a&gt;. Side note: this post is well worth reading in full. It's one of the clearest and most insightful pieces on real-world database scaling I've come across.)&lt;/p&gt;

&lt;p&gt;The point is: if you need to scale reads, you have options. Lots of them. If you need to scale writes, you're entering a fundamentally different territory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Not Bulk Writes?
&lt;/h2&gt;

&lt;p&gt;Bulk inserts are a different game. PostgreSQL's &lt;code&gt;COPY&lt;/code&gt; command, combined with tools like Kafka for batching, can push hundreds of thousands or even millions of rows per second. Companies do this all the time for analytics pipelines, event ingestion, and log processing. There are plenty of good resources on bulk write optimization.&lt;/p&gt;

&lt;p&gt;But that's not what this post is about.&lt;/p&gt;

&lt;p&gt;Think about what happens when a customer taps "Pay" on their phone. That's a single API call. A single database transaction. It reads the account balance, checks constraints, inserts a transaction record, updates the balance, and commits. There's locking involved. There are indexes to maintain. There's a WAL (Write-Ahead Log) entry that must be flushed to disk before the commit is acknowledged.&lt;/p&gt;

&lt;p&gt;You can't batch this. You can't tell the customer "hold on, we'll process your payment in 200 milliseconds when we have 500 other payments ready to go."&lt;/p&gt;

&lt;p&gt;This pattern applies to every system where transactional integrity matters on a per-request basis: payment processors, banking platforms, healthcare record systems, e-commerce order processing, booking systems. Any application where a single user expects a single, atomic result from a single action.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Most PostgreSQL Write Benchmarks Are Misleading
&lt;/h2&gt;

&lt;p&gt;If you've searched for "PostgreSQL write performance" or "PostgreSQL inserts per second," you've seen impressive numbers. 50,000 inserts per second. 100,000. Even higher.&lt;/p&gt;

&lt;p&gt;Here's what those benchmarks typically do that your production system does not:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No indexes.&lt;/strong&gt; The test table has a primary key and nothing else. In production, your orders table has indexes on customer_id, created_at, status, maybe a composite index or two, possibly a GIN index on a JSONB column. Every index you add is a separate B-tree that PostgreSQL must update on every single write. A table with 8 indexes requires nine separate write operations per insert (one for the heap row and one for each index entry), along with corresponding WAL records for each. The overhead scales linearly with index count and adds up fast.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;fsync turned off.&lt;/strong&gt; PostgreSQL's &lt;code&gt;fsync&lt;/code&gt; setting controls whether the WAL is actually flushed to stable storage. With &lt;code&gt;fsync=off&lt;/code&gt;, PostgreSQL never asks the OS to flush writes to disk. It's dramatically faster, and catastrophically dangerous. If the server crashes, you don't just lose recent data. The database can end up in an &lt;strong&gt;unrecoverable, corrupted state&lt;/strong&gt; because partially-written pages go undetected. Every serious deployment runs with &lt;code&gt;fsync=on&lt;/code&gt;. No exceptions. (&lt;a href="https://www.postgresql.org/docs/current/wal-configuration.html" rel="noopener noreferrer"&gt;PostgreSQL WAL Configuration&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;synchronous_commit turned off.&lt;/strong&gt; This one is more nuanced, and often conflated with &lt;code&gt;fsync=off&lt;/code&gt;, but they're fundamentally different settings with fundamentally different risk profiles. With &lt;code&gt;synchronous_commit=off&lt;/code&gt;, PostgreSQL still writes WAL records, but doesn't wait for the disk flush before acknowledging the commit. If the server crashes, you might lose the last few hundred milliseconds of committed transactions, but the database recovers to a &lt;strong&gt;consistent state&lt;/strong&gt; via WAL replay. PostgreSQL's own documentation explicitly states this "does not create any risk of database inconsistency." Using &lt;code&gt;synchronous_commit=off&lt;/code&gt; per-transaction for non-critical writes (logging, metrics, notifications) is a legitimate, documented production pattern. But when benchmarks use it globally without disclosure and then claim "50,000 writes per second", that's a number your production system with &lt;code&gt;synchronous_commit=on&lt;/code&gt; will never match, and it's misleading to present it without context. Our benchmark tests both settings to show the actual gap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unlogged tables.&lt;/strong&gt; PostgreSQL supports tables that skip WAL entirely. Faster? Absolutely. Production-ready for financial data? Absolutely not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No real business logic.&lt;/strong&gt; Production writes aren't just INSERT statements. They involve &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; locks, constraint checks, foreign key validations, and often multiple statements within a single transaction. The benchmark inserts a row into an empty table and calls it done.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No concurrency contention.&lt;/strong&gt; Real systems have hundreds of connections competing for the same rows, the same indexes, and the same WAL buffer. Benchmarks typically measure throughput under ideal conditions with no lock contention.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No retries, no error handling.&lt;/strong&gt; In production, serialization failures happen. Deadlocks happen. Your application retries, which means the effective throughput is lower than the raw insert rate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Just use a message queue."&lt;/strong&gt; This is the most common response when someone brings up write performance concerns. RabbitMQ, Kafka, SQS, pick your flavor. The idea is simple: accept the request, drop it onto a queue, return 202 Accepted, and let a background worker handle the heavy write later.&lt;/p&gt;

&lt;p&gt;And to be clear: this is a great pattern. We use it. You can make the initial synchronous write much cheaper (a lightweight "pending" record with fewer indexes, or an append to an event log) and defer the expensive multi-table transactional work to a background consumer. For periodic traffic spikes, it's brilliant: even if the consumer falls behind, it catches up during quieter periods, and retries handle transient failures gracefully.&lt;/p&gt;

&lt;p&gt;But it does not raise the database's sustained write throughput.&lt;/p&gt;

&lt;p&gt;Here's why. Even with the cheapest possible initial write, the background worker still writes back to the same PostgreSQL instance: status updates, ledger entries, audit logs. You haven't removed writes from the database; you've spread them across time. Instead of one transaction doing everything, you now have the initial write, the queue acknowledgment (if durable), and one or more background completion writes. The total write count often goes up, not down.&lt;/p&gt;

&lt;p&gt;For spikes, that's fine. The queue absorbs the burst and the database catches up. For sustained load at or near the ceiling, there's no catching up. The writes per second flowing into PostgreSQL are the writes per second flowing into PostgreSQL, whether they arrive from an API handler or a queue consumer. Async processing is an excellent architectural pattern for latency and resilience, but the write ceiling is still the write ceiling. The bottleneck just moves to a less visible place.&lt;/p&gt;

&lt;p&gt;This isn't a minor difference. The gap between a synthetic benchmark and real-world write performance can easily be 10x or more.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting "Writes Per Second" in Context
&lt;/h2&gt;

&lt;p&gt;Before we look at actual numbers, let's calibrate what "writes per second" means in real business terms.&lt;/p&gt;

&lt;p&gt;1,000 sustained writes per second sounds small if you're used to thinking in terms of HTTP requests per second. But consider what it means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;1,000 writes/sec = 86.4 million writes per day&lt;/li&gt;
&lt;li&gt;500 writes/sec = 43.2 million writes per day&lt;/li&gt;
&lt;li&gt;100 writes/sec = 8.6 million writes per day&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stripe, one of the largest payment processors in the world, processes billions of API requests, but their actual payment transaction rate is a fraction of that. Most mid-size fintech companies never sustain more than a few hundred transactional writes per second.&lt;/p&gt;

&lt;p&gt;Let me bring this closer to earth. I work in Armenia, a country of about 3 million people. The largest banks here sustain around 3 transactional inserts per second on their core tables on average. During peak hours, they might spike to around 100 inserts per second for brief periods. The entire national banking system doesn't come close to 500 sustained writes per second.&lt;/p&gt;

&lt;p&gt;I once encountered a fintech here that was reporting roughly 1,000 inserts per second on a hot path. Impressive, until I looked at their architecture. They were running fast inserts in a loop within their application layer, not genuine one-user-one-request transactions. The system design was wrong. Once you filter out that kind of architectural mistake and look only at genuine single-request transactional writes, the numbers are dramatically lower than what people imagine.&lt;/p&gt;

&lt;p&gt;When someone tells you their PostgreSQL setup handles "only" 1,000 genuine transactional writes per second, they're describing a system that could serve the transactional needs of a small country. The companies that genuinely need 10,000+ transactional writes per second on a single PostgreSQL instance are rare. And if you're at that scale, you're almost certainly a large enough operation that sharding is already on your roadmap, and you have the engineering team to handle it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Benchmark
&lt;/h2&gt;

&lt;p&gt;Here's where I stop talking and show data.&lt;/p&gt;

&lt;p&gt;I built a public benchmark designed to simulate real-world write patterns, not synthetic ones. You can find it here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repository:&lt;/strong&gt; &lt;a href="https://github.com/HaikAsatryan/pg-bench-real" rel="noopener noreferrer"&gt;github.com/HaikAsatryan/pg-bench-real&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Hardware
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Specification&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CPU&lt;/td&gt;
&lt;td&gt;AMD Ryzen 9 7950X, 16 cores / 32 threads @ 4.5 GHz&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RAM&lt;/td&gt;
&lt;td&gt;62 GB DDR5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage&lt;/td&gt;
&lt;td&gt;WD_BLACK SN850X 2 TB NVMe (PCIe 4.0, ~7,300 MB/s seq read, ~6,600 MB/s seq write)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OS&lt;/td&gt;
&lt;td&gt;Fedora 43 (kernel 6.19)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;td&gt;18.3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is a strong machine, better than most production database servers. Keep that in mind: these numbers are likely &lt;strong&gt;optimistic&lt;/strong&gt; compared to your production environment, which probably runs on cloud VMs with standard SSDs, shared resources, and network-attached storage.&lt;/p&gt;

&lt;h3&gt;
  
  
  PostgreSQL Configuration
&lt;/h3&gt;

&lt;p&gt;Auto-tuned from hardware, production-safe settings:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fsync&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;on&lt;/code&gt; (always, non-negotiable)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;synchronous_commit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;on&lt;/code&gt; (server default; benchmark also tests &lt;code&gt;off&lt;/code&gt; per-transaction)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;shared_buffers&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;15 GB&lt;/code&gt; (25% of RAM)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;effective_cache_size&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;46 GB&lt;/code&gt; (75% of RAM)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;work_mem&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;64 MB&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wal_buffers&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;64 MB&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;max_wal_size&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;4 GB&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;max_connections&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;300&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;effective_io_concurrency&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;200&lt;/code&gt; (NVMe)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;random_page_cost&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1.1&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;huge_pages&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;try&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;checkpoint_completion_target&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0.9&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  What We Tested
&lt;/h3&gt;

&lt;p&gt;Two transaction shapes that model real enterprise write patterns:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Standard OLTP Write&lt;/strong&gt; ("Record a new transaction"):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;BEGIN&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;wallets&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;    &lt;span class="c1"&gt;-- account check&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;transactions&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;wallet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;  &lt;span class="c1"&gt;-- rate/fraud check&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;transactions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt; &lt;span class="n"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;incl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(...)&lt;/span&gt;  &lt;span class="c1"&gt;-- the write&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;audit_log&lt;/span&gt; &lt;span class="p"&gt;(...)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(...)&lt;/span&gt;                    &lt;span class="c1"&gt;-- compliance trail&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;4 statements per transaction. 2 reads + 2 writes across 2 tables. This is what a typical API endpoint does when recording a payment, order, or event. Not every API write includes a fraud-check scan, but most production transactions involve at least one read before writing. We included it because skipping all reads is exactly the shortcut that makes synthetic benchmarks unrealistic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Banking Transfer&lt;/strong&gt; ("Move money between two accounts"):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;BEGIN&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;wallets&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;  &lt;span class="c1"&gt;-- lock source&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;wallets&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;  &lt;span class="c1"&gt;-- lock destination&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;wallets&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="c1"&gt;-- debit&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;wallets&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="c1"&gt;-- credit&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;transactions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt; &lt;span class="n"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;incl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(...)&lt;/span&gt;   &lt;span class="c1"&gt;-- record&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;audit_log&lt;/span&gt; &lt;span class="p"&gt;(...)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(...)&lt;/span&gt;                     &lt;span class="c1"&gt;-- compliance&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;6 statements per transaction. 2 row locks held simultaneously across 5 statements. Canonical deadlock-avoidance ordering (always lock lower ID first). This is what creates real contention under load.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schema:&lt;/strong&gt; 12-column &lt;code&gt;transactions&lt;/code&gt; table (including a jsonb metadata column) with timestamptz, numeric(18,2), varchar, and text fields. 5 B-tree indexes. Monthly range partitioning by &lt;code&gt;created_at&lt;/code&gt;. 10,000-row &lt;code&gt;wallets&lt;/code&gt; table. &lt;code&gt;audit_log&lt;/code&gt; table with 2 indexes. 50 million rows pre-filled to push indexes beyond shared_buffers into the I/O-bound regime.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Access pattern:&lt;/strong&gt; Zipfian distribution (alpha=1.0), where the top 1-2% of wallets absorb ~20% of traffic. This is what creates real contention. Uniform random access hides the single-node bottleneck we're trying to measure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Timeouts:&lt;/strong&gt; &lt;code&gt;statement_timeout=5s&lt;/code&gt;, &lt;code&gt;lock_timeout=1s&lt;/code&gt;, &lt;code&gt;idle_in_transaction_session_timeout=10s&lt;/code&gt;. Production-realistic limits. A transaction that can't finish under these is counted as a rejection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Connection pool:&lt;/strong&gt; 50 connections (matching typical AddDbContextPool / HikariCP / psycopg-pool sizes).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Durability:&lt;/strong&gt; Tested with both &lt;code&gt;synchronous_commit=on&lt;/code&gt; (full durability, production default) and &lt;code&gt;synchronous_commit=off&lt;/code&gt; (no fsync wait) to show the exact performance gap that benchmarks hide when they don't disclose this setting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Driver:&lt;/strong&gt; Open-loop with Poisson arrivals. The driver produces requests at a fixed target rate regardless of how fast the DB drains them. When the DB can't keep up, requests queue and eventually time out. This is exactly what happens in production when your API gets more traffic than your database can handle. An adaptive binary search finds the rate where failures start.&lt;/p&gt;

&lt;h3&gt;
  
  
  Results
&lt;/h3&gt;

&lt;p&gt;Each probe ran for 7 minutes (spanning a full PostgreSQL checkpoint cycle) with 30 seconds of warmup discarded. The adaptive binary search converged to +/-6-9% brackets in 7 probes per sweep.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fahlp19bufkxlxat5l8zd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fahlp19bufkxlxat5l8zd.png" alt="PostgreSQL write ceiling comparison across four configurations" width="800" height="476"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  Standard OLTP Write (synchronous_commit = on)
&lt;/h4&gt;

&lt;p&gt;&lt;strong&gt;Ceiling: ~1,875 rps&lt;/strong&gt; (knee bracket: 1,875-2,046 rps)&lt;/p&gt;

&lt;p&gt;At the ceiling, the system sustained 1,875 committed transactions per second with zero errors and a P99 latency of 58 ms. Push it to 2,046 rps and errors jumped to 9%, all from pool wait timeouts. The database didn't crash or deadlock; it simply couldn't drain its connection pool fast enough.&lt;/p&gt;

&lt;p&gt;At the healthy ceiling: avg latency 9.2 ms, P50 3.1 ms, P99 58 ms. Over the 7-minute measurement window, 787,531 transactions committed successfully.&lt;/p&gt;

&lt;h4&gt;
  
  
  Banking Transfer (synchronous_commit = on)
&lt;/h4&gt;

&lt;p&gt;&lt;strong&gt;Ceiling: ~2,390 rps&lt;/strong&gt; (knee bracket: 2,390-2,562 rps)&lt;/p&gt;

&lt;p&gt;The banking transfer, despite holding two row locks per transaction, achieved a &lt;em&gt;higher&lt;/em&gt; ceiling than the standard OLTP write. Why? The standard OLTP write includes a &lt;code&gt;SELECT count(*)&lt;/code&gt; fraud-check query on the 50M-row transactions table, an index scan that becomes expensive at scale. The banking transfer's reads hit only the 10,000-row wallets table, which fits entirely in shared buffers.&lt;/p&gt;

&lt;p&gt;At the healthy ceiling: avg latency 8.8 ms, P50 2.6 ms, P99 144 ms. Zero deadlocks, zero lock timeouts. The canonical lock ordering (always lock lower account ID first) worked exactly as designed.&lt;/p&gt;

&lt;h4&gt;
  
  
  The synchronous_commit Gap
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp9rzrrhaf4i63i6kr6na.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp9rzrrhaf4i63i6kr6na.png" alt="P99 latency vs target rate for both workloads" width="800" height="355"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We tested both &lt;code&gt;synchronous_commit=on&lt;/code&gt; and &lt;code&gt;off&lt;/code&gt; to measure the gap that benchmarks quietly exploit:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Workload&lt;/th&gt;
&lt;th&gt;sync_commit=on&lt;/th&gt;
&lt;th&gt;sync_commit=off&lt;/th&gt;
&lt;th&gt;Improvement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Standard OLTP Write&lt;/td&gt;
&lt;td&gt;~1,875 rps&lt;/td&gt;
&lt;td&gt;~1,875 rps&lt;/td&gt;
&lt;td&gt;~0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Banking Transfer&lt;/td&gt;
&lt;td&gt;~2,390 rps&lt;/td&gt;
&lt;td&gt;~2,734 rps&lt;/td&gt;
&lt;td&gt;~15%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The result surprised me. For the standard OLTP write, skipping the durability flush made &lt;strong&gt;no measurable difference&lt;/strong&gt; to the ceiling. The bottleneck wasn't WAL flush latency; it was the read queries and index maintenance within the transaction. When your transaction includes a range scan on a 50M-row table, shaving 0.5 ms off the commit doesn't move the needle.&lt;/p&gt;

&lt;p&gt;For the banking transfer, &lt;code&gt;synchronous_commit=off&lt;/code&gt; gave a 15% boost. Meaningful, but far from the 5-10x numbers that synthetic benchmarks imply. And remember: you're trading data durability for that 15%.&lt;/p&gt;

&lt;p&gt;The latency at sub-ceiling rates tells a clearer story. At 500 rps (well below saturation), banking transfers dropped from 1.7 ms average (sync=on) to 0.8 ms (sync=off), a 2x latency improvement. But the &lt;em&gt;ceiling&lt;/em&gt; barely moved. Faster commits let you do each transaction quicker, but the database still hits the same I/O wall.&lt;/p&gt;

&lt;h4&gt;
  
  
  How It Breaks
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2dgrgtlqzj7jbtqpf8ur.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2dgrgtlqzj7jbtqpf8ur.png" alt="Achieved vs target write rate, showing the saturation cliff" width="800" height="394"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This chart tells the whole story. The line follows the diagonal (achieved = target) until the ceiling, then flatlines. Push harder and the database doesn't crash, doesn't deadlock, doesn't corrupt anything. It simply can't drain its connection pool fast enough. Your requests time out waiting for a connection, and your API starts returning 503s.&lt;/p&gt;

&lt;p&gt;Every single failure at the knee was a &lt;strong&gt;pool wait timeout&lt;/strong&gt;. Not a single deadlock. Not a single statement timeout. PostgreSQL is well-behaved at its limits: it just has a finite throughput.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fz61uny0odzlku4serr8d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fz61uny0odzlku4serr8d.png" alt="Detailed metrics at the highest healthy rate per configuration" width="800" height="327"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Key Findings
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The realistic write ceiling on strong hardware is ~2,000 rps.&lt;/strong&gt; On a Ryzen 9 7950X with a top-tier NVMe drive and 62 GB of RAM (hardware better than most production database servers), the ceiling for a standard multi-statement OLTP write with 5 indexes, production timeouts, and full durability is approximately 1,875 writes per second. That's a long way from the 100,000 inserts/sec that synthetic benchmarks advertise.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Row contention doesn't kill you; large table scans do.&lt;/strong&gt; The banking transfer (with two row locks held simultaneously) was actually &lt;em&gt;faster&lt;/em&gt; than the standard OLTP write. The bottleneck was the fraud-check query scanning a 50M-row table, not the lock contention on 10,000 wallets.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;synchronous_commit=off gives at most 15%, not 10x.&lt;/strong&gt; Benchmarks that quietly use &lt;code&gt;synchronous_commit=off&lt;/code&gt; suggest massive performance gains. In reality, with a realistic workload, the improvement is modest. The WAL flush is only one component of the total transaction cost.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The failure mode is clean.&lt;/strong&gt; PostgreSQL doesn't crash, corrupt data, or spiral into deadlocks at the ceiling. It cleanly saturates the connection pool. Requests that can't get a connection within 1 second are rejected. This is a well-behaved system hitting a well-defined limit.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;1,875 writes/sec = 162 million writes per day.&lt;/strong&gt; This is enough to serve the transactional needs of a mid-size country's banking system. Most applications will never come close to this limit.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The WAL Bottleneck: Why Writes Are Fundamentally Hard
&lt;/h2&gt;

&lt;p&gt;If you're wondering why write performance has a hard ceiling that read performance doesn't, the answer comes down to one thing: the Write-Ahead Log.&lt;/p&gt;

&lt;p&gt;Every write in PostgreSQL (every INSERT, UPDATE, and DELETE) must first be recorded in the WAL before it can be acknowledged. With &lt;code&gt;synchronous_commit=on&lt;/code&gt;, every transaction commit requires its WAL records to be physically flushed to disk.&lt;/p&gt;

&lt;p&gt;WAL &lt;em&gt;insertion&lt;/em&gt; itself has been concurrent since PostgreSQL 9.4: multiple backends can reserve LSNs and copy WAL records into shared buffers in parallel. But WAL &lt;em&gt;flushing&lt;/em&gt;, the actual &lt;code&gt;fsync()&lt;/code&gt; to disk, is serialized. That disk flush is the hard ceiling.&lt;/p&gt;

&lt;p&gt;Read replicas don't help here. Partitioning doesn't help. More RAM doesn't help. The WAL flush is a serialized disk I/O bottleneck by design. It's what guarantees your data is safe, and it's what limits how fast you can write.&lt;/p&gt;

&lt;p&gt;This is why horizontal write scaling (sharding) is fundamentally different from horizontal read scaling (replicas). Replicas are cheap: stream the WAL, apply it, serve reads. Sharding is expensive: split your data, split your application logic, handle distributed transactions, deal with cross-shard queries. Your code gets bigger, your architecture gets more complex, and your failure modes multiply.&lt;/p&gt;

&lt;p&gt;Tools like &lt;a href="https://www.citusdata.com/" rel="noopener noreferrer"&gt;Citus&lt;/a&gt; (now part of Azure) can help significantly. It extends PostgreSQL with distributed tables and handles a lot of the routing and query rewriting for you. But "significantly helps" is not the same as "makes it trivial." You still need to choose a distribution key, redesign queries that cross shard boundaries, handle distributed transactions carefully, and test thoroughly. I haven't personally needed to shard yet, so I can't speak from firsthand experience, but the engineers I've talked to who have describe it as a serious undertaking even with good tooling.&lt;/p&gt;

&lt;p&gt;For a deeper dive into WAL mechanics: &lt;a href="https://www.postgresql.org/docs/current/wal-intro.html" rel="noopener noreferrer"&gt;PostgreSQL Documentation: Write-Ahead Logging&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When Should You Start Worrying?
&lt;/h2&gt;

&lt;p&gt;The benchmark ceiling of ~1,875 rps was measured on strong hardware: a 16-core desktop CPU, a top-tier NVMe, and 62 GB of RAM. Your production environment is almost certainly worse.&lt;/p&gt;

&lt;p&gt;In my own experience, a well-architected .NET application running on production hardware with standard SSDs (not NVMe), a more complex schema (more columns, more indexes), and real network latency topped out at around &lt;strong&gt;~1,100 writes per second&lt;/strong&gt;. That's 40% lower than the benchmark, and our setup was well-optimized.&lt;/p&gt;

&lt;p&gt;Here's the framework I'd recommend:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;At 500+ sustained transactional writes per second&lt;/strong&gt;, start planning. Not panicking, planning. You have time, but the wall is real, and architectural changes (sharding, CQRS, write offloading) take months to design and years to fully implement. If your user base is growing, the time to start that conversation is &lt;em&gt;before&lt;/em&gt; you hit 1,000 rps, not after.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;At 1,000+ sustained writes per second&lt;/strong&gt;, you should already have a scaling strategy documented and partially validated. At this rate, a single bad query, a new index, or a traffic spike can push you into degradation territory. The margin for error shrinks fast.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;At 1,500+ sustained writes per second&lt;/strong&gt; on production hardware, you're at the wall. Any growth from here requires horizontal scaling, and that's a fundamentally different kind of engineering effort.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The key word is &lt;em&gt;sustained&lt;/em&gt;. Brief spikes to 2,000 rps during a flash sale are different from a steady baseline of 1,500 rps growing 20% year-over-year. It's the growth curve that should drive your planning timeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;On a 16-core Ryzen 9 with a top-tier NVMe and 62 GB of RAM (hardware that would cost you $300-400/month in the cloud), a single PostgreSQL 18 node tops out at approximately &lt;strong&gt;1,875 realistic transactional writes per second&lt;/strong&gt;. With full durability, 5 indexes, multi-statement transactions, an audit trail, and production timeouts. On real production servers with standard SSDs, expect that number to be closer to 1,000-1,200.&lt;/p&gt;

&lt;p&gt;That's still 86-162 million writes per day. More than enough for the vast majority of applications on the planet.&lt;/p&gt;

&lt;p&gt;PostgreSQL is an exceptional database. This post isn't an argument against it. I've built my career on it, and I'd choose it again.&lt;/p&gt;

&lt;p&gt;But the internet is full of inflated write performance numbers that set unrealistic expectations. When your application hits 1,000 transactional writes per second and struggles, you're not doing something wrong. You're hitting a real, physical limitation that most benchmarks conveniently avoid. The time to prepare for that ceiling is when you're at 500, not when you're already drowning at 1,200.&lt;/p&gt;

&lt;p&gt;Know your real limits. Plan your architecture around honest numbers, not synthetic ones. And when someone tells you PostgreSQL can handle 100,000 writes per second, ask them: with how many indexes? With fsync on or off? With synchronous_commit on or off? With real transactions or bare inserts?&lt;/p&gt;

&lt;p&gt;The answers will tell you everything.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The benchmark code is open source. Run it on your own hardware and see what your PostgreSQL can actually do. The numbers might surprise you.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>performance</category>
      <category>backend</category>
    </item>
  </channel>
</rss>
