Skip to content
Muhammet Şafak
tr

I measured PHP and Go on the same OAuth2 API: no gap at 10,000 writes, a real one at 50,000 reads

The same API verifies an OAuth2 token on every request and then writes to or reads from PostgreSQL. On four cores, how much CPU do PHP-FPM, FrankenPHP worker mode and Go need for 10,000 writes and 50,000 reads a second?

Finding

At 10,000 writes a second all three candidates hit the target in five runs out of five, and none had a p99 above 2.5 ms: at this load the language is not a capacity line item. At 50,000 reads a second only Go held the target on four cores (p99 6.45 ms); PHP-FPM stopped at 23,528 and FrankenPHP at 22,859. CPU per read request is 64 microseconds for Go, 117 for FrankenPHP and 168 for PHP-FPM. Sized by instance, 50,000 reads take 3.4 cores in Go and 8.2–8.8 cores for the two PHP candidates. FrankenPHP's CPU saving does not turn into capacity: at saturation it leaves about one of its four cores idle.

10,000 writes/s · all three held it
p99 ≤ 2.50 ms
50,000 reads/s · only Go held it
p99 6.45 ms
CPU per read · Go → PHP-FPM
64 → 168 µs
Token-check ceiling · Go / best PHP
83,526 / 32,953

Method

One contract, written three times; every request verifies an RS256 bearer token (signature, iss, aud, exp, nbf, scope). The candidates are Go 1.27.1 (net/http, pgx, golang-jwt), PHP 8.5.10 on nginx + php-fpm, and PHP 8.5.10 in FrankenPHP 1.12.7 worker mode. There is no framework, and both PHP candidates run the same class. Each candidate got four pinned cores, 1 GiB of memory and at most 32 database connections; php-fpm's nginx counts against that budget. PostgreSQL 17.11 ran on four other cores, and oha 1.15.0 on four more. Every block started from a byte-identical, prewarmed copy of a 1,000,000-row table and a fresh candidate container. The fixed-rate phase ran open loop (oha -q, latency correction, 256 connections, 60 s, 5 repetitions); the median is reported. The ceiling phase ran closed loop at 16/64/128/256 connections (15 s, 3 repetitions); the best repetition is reported. CPU and memory come from cgroup counters for every run. Before each block, an nginx running no application code was driven at 50,000/s on the candidate's cores; across all 24 blocks it never delivered less than 49,980/s. In total: 153 load measurements, 123 million responses, zero non-2xx.

Medium confidence Repeated runs, limited control of the environment. Differences of the same order are meaningful.
Measured on

measured today

Published

Environment

Go
1.27.1 · net/http · pgx 5.11.0 · golang-jwt 5.3.1
PHP
8.5.10 · opcache on · JIT off · firebase/php-jwt 7.1.1
PHP-FPM
nginx 1.26.3 + php-fpm in one container · pm=static · 32 workers
FrankenPHP
1.12.7 (Caddy 2.11.4) · worker mode · 32 workers · ZTS
Database
PostgreSQL 17.11 · 1,000,000 rows · at most 32 connections · synchronous_commit on
Load generator
oha 1.15.0 · open loop + latency correction · 256 connections
Hardware
Apple M4 Pro · 12 cores · 24 GB · macOS 27.0
Virtualisation
Docker Desktop 29.8.0 · 12 vCPU / 7.75 GB · aarch64
Core split
candidate 0-3 · PostgreSQL 4-7 · load 8-11
Repetitions
fixed rate 5 × 60 s (median) · ceiling 3 × 15 s (best)

Technologies

Go PHP FrankenPHP PHP-FPM PostgreSQL OAuth2 JWT Docker nginx

To reproduce

./bench/build.sh && ./bench/verify.sh && STAMP=$(date -u +%F) ./bench/run.sh --all

“How many servers do we save if we move to Go?” usually gets the answer “Go is faster”, and that answer does not fill a capacity sheet. For this entry I wrote the same OAuth2-protected API three times: PHP-FPM, FrankenPHP worker mode and Go. I measured all three on the same four cores against the same PostgreSQL, and boiled the difference down to one number you can turn into cores for your own target: CPU per request. Every raw run is in the php-go-bench repository.

What I measured

Three endpoints follow exactly the same contract in all three candidates. Each one first verifies the bearer token: the RS256 signature, iss, aud, exp, nbf, and a scope that depends on the endpoint. No candidate caches verification.

Scenario Request What happens after verification Target rate
Token check GET /auth nothing — no database 50,000/s
Read GET /events/{id} one row by primary key from a 1,000,000-row table 50,000/s
Write POST /events JSON body validation, one INSERT … RETURNING 10,000/s
The token check is not a target in its own right: it is there to isolate what OAuth2 costs without the database.

There is no framework. Both PHP candidates run the same Api.php class; only the entry points differ. Each candidate uses the fastest database idiom its process or request lifetime allows:

  • Go: pgx caches prepared statements per connection.
  • FrankenPHP worker: prepares each statement once and reuses it.
  • php-fpm: a single request does not live long enough to keep a statement, so each query goes out as one parameterised call, in a single round trip.

At the target rate: 10,000 writes are easy for everyone, 50,000 reads are not

Requests per second achieved at a fixed target rate

For writes, all four bars are the same height. The two PHP candidates reach 61–66% of the target on the token check and about half of it on reads; the requests they cannot serve wait in the load generator's queue.

  • Target
  • Go
  • FrankenPHP (worker)
  • PHP-FPM

Source: Median of 5 repetitions, open loop, 256 connections, 60 s — bench/report.mjs

Data table
Median of 5 repetitions, open loop, 256 connections, 60 s — bench/report.mjs
Series Token check onlyCheck + readCheck + write
Target 50,00050,00010,000
Go 49,99949,99910,000
FrankenPHP (worker) 33,08922,85910,000
PHP-FPM 30,60623,52810,000

The chart is drawn in the browser; the table below carries the same data.

At 10,000 writes a second all three candidates held the target in five runs out of five. Their p99s, side by side: FrankenPHP 1.81 ms, PHP-FPM 1.88 ms, Go 2.50 ms. The lowest p99 at this load came from a PHP candidate, and the gap to Go is under a millisecond. At this target the language is not even a line on your capacity sheet.

At 50,000 reads the picture changes. Go held 50,000 in five runs out of five, with a p99 of 6.45 ms. PHP-FPM stopped at 23,528 while using 3.9 of its four cores. FrankenPHP stopped at 22,859 using only 2.7 cores; I come back to that below. In an open-loop test a request that cannot be served waits in a queue. The latency of the two PHP candidates on the token check and on reads (p99 of 20–32 seconds) is therefore not service latency; it only says “this target cannot be met on this budget”.

What decides the gap: CPU per request

Application CPU per request

The candidate's cgroup CPU counter divided by the number of requests answered. Database CPU is not included.

  • Go
  • FrankenPHP (worker)
  • PHP-FPM

µs lower is better Source: Fixed-rate phase, median of 5 repetitions

Data table
Fixed-rate phase, median of 5 repetitions
Series Token check onlyCheck + readCheck + write
Go 47 µs64 µs96 µs
FrankenPHP (worker) 88 µs117 µs146 µs
PHP-FPM 130 µs168 µs207 µs

The chart is drawn in the browser; the table below carries the same data.

Multiply this number by the target rate and you get the cores you need. In every run where a candidate held its target, the calculation matches what the counter shows. For Go reads, 50,000 × 64 µs = 3.2 cores; the counter reads 3.20. For PHP-FPM writes, 10,000 × 207 µs = 2.07 cores; the counter reads 2.07 as well.

There are two ways to size 50,000 reads, and the two PHP candidates land in different places depending on which one you use:

50,000 reads/s CPU-time sizing vs Go Instance sizing vs Go
Go 3.2 cores (measured) 1.0× 3.4 cores 1.0×
FrankenPHP (worker) 5.8 cores 1.8× 8.8 cores 2.6×
PHP-FPM 8.4 cores 2.6× 8.2 cores 2.4×
CPU-time sizing: target × CPU per request. Instance sizing: target ÷ ceiling of a four-core instance × 4. Apart from Go's CPU-time cell, every cell is calculated from measurements rather than measured, and assumes horizontal scaling is linear. Database cores are not included.

For PHP-FPM both methods give the same answer, because PHP-FPM uses all four of its cores. For FrankenPHP they do not.

Where the CPU goes: three quarters to the token

The token-check scenario never touches the database, so it lets me separate how much of a read request’s CPU goes to OAuth2. The share is in the same band for all three: token verification is 73% of the application CPU of a read request in Go, 75% in FrankenPHP and 77% in PHP-FPM. The ratio is approximate, because the token-check response is smaller than the read response, but the capacity conclusion is clear. In this API, reading the row is not the expensive part; verifying the RS256 signature is. Most of the gap between the languages also comes from signature verification itself: 47 µs in Go, 130 µs in PHP-FPM.

The database side is a separate line. When I divided PostgreSQL’s CPU for the same query by the number of requests, the three candidates fell into two groups:

PostgreSQL CPU / request Read Write
Go 34 µs 48 µs
FrankenPHP (worker) 35 µs 51 µs
PHP-FPM 53 µs 81 µs
The database container's cgroup CPU counter divided by the number of requests answered; fixed-rate phase, median of 5 repetitions. The database ran on the same four cores with the same settings for every candidate.

The two candidates that reuse statements, Go and FrankenPHP, are very close to each other. PHP-FPM, which cannot, makes the database do 56% more work per read and 69% more per write than Go. The likely cause is that every query is parsed and planned from scratch each time; this entry did not measure that directly. For a capacity sheet this means that moving from PHP-FPM to Go saves database cores as well as application cores, and that moving to FrankenPHP already captures most of the database side of that saving.

FrankenPHP’s saving does not turn into capacity

In all three scenarios, worker mode cuts PHP’s CPU per request by roughly a third compared with PHP-FPM (168 to 117 µs for reads). The ceiling, however, barely moves:

Ceiling on four cores: requests served per second

Without the database, Go reaches 83,526; both PHP candidates stay below 33,000. The write group is bound by database I/O and is noisy; see the note below.

  • Go
  • FrankenPHP (worker)
  • PHP-FPM

Source: Best cell of the 16/64/128/256-connection sweep, best of 3 repetitions, 15 s

Data table
Best cell of the 16/64/128/256-connection sweep, best of 3 repetitions, 15 s
Series Token check onlyCheck + readCheck + write
Go 83,52659,00443,942
FrankenPHP (worker) 32,95322,61322,874
PHP-FPM 31,15724,43721,857

The chart is drawn in the browser; the table below carries the same data.

Even at saturation, FrankenPHP uses only 2.2 to 2.9 of its four cores; PHP-FPM uses 3.3 to 4.0 in the same runs. So the bottleneck is not CPU but somewhere else: the 32-worker pool, the hand-off between Caddy and the PHP threads, or the ZTS build. This entry did not measure where it is. The practical result: moving to FrankenPHP does the same work in less CPU time, but on this budget it does not get more requests out of the same instance.

Memory ranks the candidates differently:

Candidate Peak RSS Note
Go ~27 MiB one process, 32-connection pool
PHP-FPM ~63 MiB 32 workers + 2 nginx workers
FrankenPHP (worker) ~137 MiB 32 worker threads and Caddy, one process
Peak anonymous memory in the candidate's cgroup, fixed-rate phase. None came close to the 1 GiB limit.

How I measured

  • Budget. The Docker VM’s 12 vCPUs were split into three disjoint sets: candidate 0-3, PostgreSQL 4-7, load generator 8-11. php-fpm’s nginx sits inside the candidate’s four cores; Go and FrankenPHP serve HTTP themselves.
  • Every block starts from the same place. The table is copied with CREATE DATABASE … TEMPLATE and pulled into memory with pg_prewarm. Then a CHECKPOINT is taken, a fresh candidate container is started and each scenario is warmed up for 5 seconds. Write is the only scenario that changes the table, so it always runs last.
  • Reference probe. Before each block, an nginx running no application code was placed on the candidate’s cores and driven at 50,000 requests a second. Had the load generator failed to reach even that, no candidate could have. The lowest value was 49,980/s.
  • Two estimators. In the fixed-rate phase the target is fixed, so the median of the repetitions is reported. In the ceiling phase the machine could not be quiesced, and interference can only pull throughput down, so the best repetition is reported. The two phases corroborate each other: PHP-FPM gave 31,157 at the ceiling and a median of 30,606 at the fixed rate on the token check; FrankenPHP gave 32,953 and 33,089.

Limits and honesty notes

  1. One machine, a laptop. Candidate, database and load generator run on separate cores but in one VM and on one Linux kernel; the network is the Docker bridge, not a wire. macOS decides whether the 12 vCPUs land on performance cores or efficiency cores.
  2. The write ceiling is noisy. Go’s write ceiling ranged from 9,649 to 43,942 across repetitions. In those runs neither the candidate nor the database was CPU-saturated; the limit is most likely WAL writes on the VM disk, but this entry did not measure that directly. Read the write ceiling as context, not as a comparison. The fixed 10,000-write results are not affected by this noise: all 15 runs held the target.
  3. Disk guarantees not verified. I did not check whether fsync on the Docker Desktop VM disk gives the same guarantee as bare metal. Absolute write numbers may be optimistic; the comparison between the three candidates is not affected, because all three write to the same database.
  4. One token. Every request sent the same token. The candidates do not cache verification, but a real resource server sees many different tokens.
  5. One block was re-measured. The machine went to sleep during FrankenPHP’s third repetition of the ceiling phase. The block was measured again from the start. The interrupted block’s raw files could not be moved aside, and the re-run wrote over them under the same names. How that happened is written up in EXCLUDED.md.
  6. Out of scope. Frameworks, Swoole and RoadRunner, JIT, the authorization server that issues tokens, and the team cost of porting code to Go were not measured. What frameworks themselves cost is in the PHP framework load test entry.

To produce your own number, run the php-go-bench repository with your own token format and your own query, on your own hardware. The value that goes into your sheet is CPU per request; the rest is multiplication.

Related posts

Share:

Other records

All records
Service & load Measurement

Seven PHP frameworks under identical load: the gap narrows as soon as the request does real work

On the same hardware, the same PHP build and the same seven routes, how many requests a second do Laravel, Symfony, CodeIgniter, Yii2, Phalcon, Laminas and Slim serve, and at what latency?

Finding

On an empty route the fastest is 4.4× the slowest (Slim 25,975, Laravel 5,966 req/s). As soon as the request does real work the gap closes: 3.7× for a single row from the database, 3.5× for twenty rows. Phalcon is third on an empty route and fifth once a query is involved — being a C extension buys nothing while the process waits on MySQL. And the expensive decision is not the framework: Laravel's own default `web` middleware group takes the same response from 5,858 to 2,176 req/s, so one default costs more than most of the distance between the frameworks.

measured 27 days ago

Medium confidence

Laravel's preload curve: 123 files buy eight times what the last 1,912 do

How far can a curated preload take Laravel, and what does each slice cost in start-up time?

Finding

The curve is not proportional to volume. The first 1,592 files — Laravel's own framework — buy 30 ms and add 1.2 seconds to start-up. The next 1,094 Symfony files buy 9.5 ms for free. The **123 files** after that (psr, carbon) buy 15.7 ms, more than the 1,094 before them. And the last 1,912 buy 1.8 ms while adding another 1.2 seconds. So the blanket preload the earlier record measured as a ceiling is the worst point on the curve that is not the origin: stopping at 2,809 files gives 12.77 ms for 1,514 ms of start-up, while 4,721 files ask 2,691 ms to reach 10.96 ms.

measured 25 days ago

High confidence

opcache preload cuts the deploy bill by up to fourteen times — but five of seven frameworks do not hand it to you

With `opcache.preload` on, how long is the first request seven PHP frameworks serve after a deploy, what does the gain cost, and who can actually have it?

Finding

Preload shortens the cold first request by between 3.5× and 14.2×: Symfony drops from 35.58 ms to 2.50 ms, down to Phalcon's bare figure. But only two of the seven candidates — Symfony and CodeIgniter — publish a preload file of their own; for the other five the gain sits on the table waiting for the user to write one. Writing one is not as easy as it looks: a preload generated blindly from the classmap never brings Symfony up at all, and on CodeIgniter it does worse (5.29 ms) than the hand-picked official file (3.13 ms). And the cost does not vanish: Laravel's classmap preload takes the 62 ms it saves each visitor and writes it back as 2,340 ms of php-fpm start-up.

measured 25 days ago

High confidence

Search the site

Start typing to search posts, projects and pages.

Esc to close Powered by Pagefind