Skip to content
Artem Ukrainskiy
Go back

12 web stacks on vendor defaults, PostgreSQL and MySQL: what a request costs and what the drivers change

Most comparisons of web stacks answer “which one serves more requests per second”. I wanted a different answer: how much CPU and memory one ordinary API request costs, on each stack as its vendors ship it, under the same rules for everyone. I wrote the same small JSON API twelve times — PHP (four runtimes), Go, Node (two servers), Rust, Java, .NET, Python and Ruby — and measured all of them on one machine, against PostgreSQL and against MySQL. The result that surprised me most is not about languages at all: on PostgreSQL, whether the database driver keeps prepared statements by default moves the cost more than the runtime does. On MySQL, where most drivers send plain text queries, the database costs about the same for most stacks, and the stack’s own CPU matters more.

Everything below comes from the repository web-request-cost at commit afd7ea6: the code, the rules, 700 raw records and the script that turns them into the tables. The mysqlnd finding adds a follow-up series in the same repository; the Swoole question cites one run that is not in it.

Contents

What was measured

CPU per request at the same load

CPU per request is CPU time summed across all cores of the stack’s containers (or of the database server), divided by the requests served — a cost, not a latency. Both tables are at a fixed 2000 req/s over 48 connections, without waiting (section 1a), each sorted by stack + database. Every stack runs the same code against both databases; only the driver changes.

PostgreSQL

stackstack CPUdatabase CPUstack + database
Java Jetty 12, virtual threads0.30 ms0.52 ms0.82 ms
Go net/http0.40 ms0.52 ms0.92 ms
Python uvicorn0.40 ms0.58 ms0.98 ms
Ruby Puma0.36 ms1.23 ms1.59 ms
Node node:http0.37 ms1.26 ms1.64 ms
Node Fastify0.39 ms1.27 ms1.65 ms
Rust hyper0.27 ms1.44 ms1.71 ms
PHP Swoole0.36 ms1.42 ms1.77 ms
PHP FrankenPHP worker0.42 ms1.42 ms1.84 ms
PHP RoadRunner0.51 ms1.47 ms1.98 ms
PHP FPM + nginx0.51 ms1.50 ms2.01 ms
.NET 10 Kestrel0.83 ms1.31 ms2.13 ms

MySQL

stackstack CPUdatabase CPUstack + database
PHP Swoole0.19 ms1.16 ms1.35 ms
PHP FrankenPHP worker0.33 ms1.15 ms1.48 ms
Ruby Puma0.36 ms1.15 ms1.51 ms
Java Jetty 12, virtual threads0.40 ms1.12 ms1.52 ms
Node node:http0.35 ms1.19 ms1.53 ms
Node Fastify0.37 ms1.20 ms1.57 ms
Rust hyper0.27 ms1.32 ms1.59 ms
PHP RoadRunner0.38 ms1.21 ms1.59 ms
Python uvicorn0.43 ms1.18 ms1.61 ms
PHP FPM + nginx0.41 ms1.27 ms1.67 ms
Go net/http0.56 ms1.35 ms1.91 ms
.NET 10 Kestrel0.88 ms1.22 ms2.10 ms

Three things stand out. The stacks themselves are close: 0.27–0.51 ms of CPU per request on PostgreSQL and 0.19–0.56 ms on MySQL, with .NET as the exception on both (more on that below). On PostgreSQL the database column splits the table in two: three stacks cost the database about half a millisecond per request, all the others 1.2–1.5 ms. On MySQL there is no such split: the database spends 1.12–1.35 ms for every stack. Latency is unremarkable: at 4000 req/s, p99 is 3.6–10.4 ms for every stack on PostgreSQL except Swoole (78 ms; below), and 6.5–8.2 ms for every stack on MySQL.

PostgreSQL: the driver’s default decides

The three cheap rows are exactly the three whose drivers keep prepared statements on each connection by default. Go’s pgx caches them, Java’s PgJDBC makes a statement server-side prepared after five uses on a connection, Python’s asyncpg caches them too. The others send each statement in a form that PostgreSQL has to parse and plan every time — node-postgres and Ruby’s pg as unnamed statements, Rust’s tokio-postgres and PHP’s pdo_pgsql by preparing, executing and closing every statement, Npgsql as unnamed statements plus a session reset when the connection goes back to the pool. What each driver does is in the driver table of the rules and in the wire check: the executes are counted in the database logs, the prepare and close pattern follows from each driver’s code.

So with every stack on its defaults, the database work per request differs by a factor of two to three, and that difference — 0.65–0.99 ms — is larger than the whole CPU cost of most runtimes. Rust has the cheapest stack and a total in the expensive half of the table, because of the call it uses (below). That is why this compares drivers and the calls chosen more than languages.

For PHP-FPM on PostgreSQL I measured four statement configurations — three PDO modes and PgBouncer in front of native prepares (section 6, at C = 50, that is 48 connections, with the database saturated, so the absolute numbers are higher than at 2000 req/s):

PDO modestack + database per request
native prepares, direct (pdo_pgsql’s default)2.60 ms
PDO::ATTR_EMULATE_PREPARES2.13 ms
Pdo\Pgsql::ATTR_DISABLE_PREPARES2.21 ms
native prepares through PgBouncer (max_prepared_statements)1.91 ms

At this workload the default is the most expensive of the four; what each mode does is in the finding below. Even the cheapest leaves FPM above Go (1.09 ms) and Java (1.11 ms) at the same load: of FPM’s gap to Go, PgBouncer closes 46% and emulated prepares 32%.

MySQL: the stack’s own CPU matters more

On MySQL, ten of the twelve stacks send each statement as one text query, with the parameters escaped on the client: PDO in the four PHP stacks (emulated prepares are pdo_mysql’s default), mysql2 in both Node stacks, Connector/J, MySqlConnector, asyncmy and trilogy. There is nothing to cache, and the database spends 1.12–1.27 ms per request for all ten; why FPM is at the top of that range and Java at the bottom I have not checked. The two stacks whose drivers prepare on the server cost the database 0.05–0.23 ms more than that — Go 1.35 ms (below) and Rust 1.32 ms (below) — against differences of 0.65–0.99 ms on PostgreSQL.

With the database cost this close, the stack’s CPU explains more of the order than on PostgreSQL. Eight stacks are within 1.48–1.61 ms in total; Swoole is the lowest at 1.35 ms, with 0.19 ms in the stack, and Go and .NET are the highest. FrankenPHP’s 0.33 ms includes a lock that every thread of a ZTS PHP build takes in mysqlnd, about 0.03 ms of it at this rate (below). .NET is the most expensive stack on MySQL as well (0.88 ms), with the same pattern as on PostgreSQL: 1.02 / 0.88 / 0.76 ms at 1000 / 2000 / 4000 req/s.

.NET: the CPU of waiting for work

.NET has the highest CPU per request in both tables, and that looked wrong. Its CPU per request falls as the load grows: on PostgreSQL 0.97 ms at 1000 req/s, 0.83 at 2000, 0.70 at 4000. Running the rates in descending order on one start gives the same numbers (0.965 / 0.817 / 0.693 ms), so the order of the warm-up does not explain it. A runtime that spends less per request when it has more requests is paying for something while it is idle: the .NET thread pool spins for a while before a worker thread goes to sleep, so that new work does not have to wake it. With that spinning switched off, the stack is at the level of Java and Go (finding below). The published number keeps the default, because that is what stage 1 measures.

Waiting requests and memory

The third series makes every request wait on another service, 0.1 s or 1 s, as if it called a slow API. Here the execution model matters. For blocking models — PHP-FPM, FrankenPHP, RoadRunner, Puma’s threads — the harness provisions a PHP worker per waiting request (RoadRunner accepts at most 500) and about two Puma threads per waiting request, so the series shows what each model needs to hold C requests that wait, not how a fixed production pool behaves. This series ran on PostgreSQL only.

Memory growth per 1000 requests that each wait 1 s (section 3):

modelmemory per 1000 waiting requests
RoadRunner (a PHP worker per request)11.5 GiB
PHP-FPM (a child process per request)1.1 GiB
FrankenPHP (a PHP thread per request)0.96 GiB
Node (node:http, up to 1000 requests)0.90 GiB
Java, virtual threads0.44 GiB
Ruby Puma (threads)0.41 GiB
.NET0.23 GiB
PHP Swoole (coroutines)0.11 GiB
Go0.10 GiB
Rust0.08 GiB
Python (asyncio)0.02 GiB

These are slopes of one series, not the size of one request, and they do not carry over to other loads: Node’s 0.90 GiB is its heap growing up to 1000 concurrent requests, after which it adds only 0.14 GiB per 1000; Node at about 1000 req/s with a 0.1 s wait uses twice what the slope predicts; and Java’s slope varies between passes from 0.39 to 0.76 GiB. What is stable is the order at the ends: RoadRunner far above everything else in every pass (below), the coroutine, goroutine, async and Rust stacks at about a tenth of a GiB or less.

What it costs in money

The repository turns these numbers into dollars with an explicit model (section 8): 1000 req/s with a 0.1 s wait, PostgreSQL, vCPU at a 60% target utilisation, AWS on-demand prices per vCPU and per GiB. With the database’s CPU added, Go, Java and Python come to $49–55 a month and every other stack to $79–105; the stacks alone cost $15–46. It is an amortised resource price, not a bill, and it shows two things: the database is the larger part for most stacks, and at this scale the differences are tens of dollars a month.

What the measurement found

Each finding has its own heading so that it can be linked from issues and discussions, and each says what it was measured on. Unless a finding says otherwise, the setup is the one above: this API (four SQL statements per request), the stack on 4 cores / 8 threads of a Ryzen 9 5950X, the database on the same host over loopback, numbers from the series in the repository at afd7ea6. “Not measured” means the cause is shown but the fix was not run; that is the next stage.

findingobservedfixeffect of the fix
mysqlnd statistics in ZTS builds+9.4% stack CPU for FrankenPHP on MySQL at 2000 req/s, +32% at saturationphp.ini today; patch for php-src+0.8% and +2.8%
pdo_pgsql prepares and closes every statementthree exchanges per statementPDO attribute or PgBouncer−15% to −27% stack + database
.NET thread pool spinning0.82 ms stack CPU at 2000 req/s (one-pass check)runtime knob0.32 ms
Puma threads above the pool sizep99 1424 ms at 200 clientsthread countp99 44 ms
tokio-postgres query prepares every statement1.44 ms database CPU against 0.52 ms for Go and Javaanother call or the pool’s cachenot measured
Go database/sql with go-sql-driver prepares every statement on MySQL1.35 ms database CPU, the highest on MySQLDSN optionnot measured
mysql_async: the pool’s session reset drops prepared statementsevery statement prepared againpool optionnot measured

Two more observations have no confirmed cause yet: Swoole on PostgreSQL at 4000 req/s and RoadRunner memory per worker, in open questions.

mysqlnd statistics in ZTS builds

pdo_pgsql prepares and closes every statement

.NET thread pool spinning

Puma threads above the pool size

tokio-postgres query prepares every statement

Go database/sql with go-sql-driver prepares every statement on MySQL

mysql_async: the pool’s session reset drops prepared statements

Open questions

Swoole on PostgreSQL at 4000 req/s

RoadRunner memory per worker

What this means for PHP

What to check in your own service

The measurement is about defaults, but the lesson is practical: find out what your driver does with each statement — from its documentation and code first.

The drivers here offer two different remedies. Some keep statements prepared on the server: Npgsql’s Max Auto Prepare, deadpool-postgres’ prepare_cached, named statements in node-postgres, PgBouncer with max_prepared_statements in front of PDO. Others cut the exchanges per statement instead, with the database still parsing each one: PDO’s emulated prepares or ATTR_DISABLE_PREPARES, tokio-postgres’ query_typed, and go-sql-driver’s interpolateParams on MySQL. What each does to this API is what the next stage measures, on every stack together.

What this does not show

Next

Stage 1 is the baseline: vendor defaults under equal rules. The next stages change one thing at a time against it — driver settings, then tuning, then frameworks (Laravel, Symfony, Spring, ASP.NET, NestJS, FastAPI, Rails, …), then the cloud.

Some questions I would like people who run these stacks to answer, in the repository’s issues:

If the implementation or configuration of a stack looks wrong to you, please open an issue too: the results are re-published whenever a mistake is found.


Share this post:

Previous Post
One mutex for every row: mysqlnd statistics in thread-based PHP
Next Post
json_encode() in php-src, autumn 2026: where the time goes and what is on the table