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.
- Measured on
- Published
measured today
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
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 |
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
| Series | Token check only | Check + read | Check + write |
|---|---|---|---|
| Target | 50,000 | 50,000 | 10,000 |
| Go | 49,999 | 49,999 | 10,000 |
| FrankenPHP (worker) | 33,089 | 22,859 | 10,000 |
| PHP-FPM | 30,606 | 23,528 | 10,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
| Series | Token check only | Check + read | Check + write |
|---|---|---|---|
| Go | 47 µs | 64 µs | 96 µs |
| FrankenPHP (worker) | 88 µs | 117 µs | 146 µs |
| PHP-FPM | 130 µs | 168 µs | 207 µ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× |
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 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
| Series | Token check only | Check + read | Check + write |
|---|---|---|---|
| Go | 83,526 | 59,004 | 43,942 |
| FrankenPHP (worker) | 32,953 | 22,613 | 22,874 |
| PHP-FPM | 31,157 | 24,437 | 21,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 |
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 … TEMPLATEand pulled into memory withpg_prewarm. Then aCHECKPOINTis 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
- 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.
- 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.
- Disk guarantees not verified. I did not check whether
fsyncon 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. - One token. Every request sent the same token. The candidates do not cache verification, but a real resource server sees many different tokens.
- 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.
- 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
Writing HTTP services in Go: is the standard library enough?
I explore what you get — and what you don't — when building a small HTTP service with Go's net/http package, and where the framework threshold sits.
How Far Can You Go with Go's Standard Library
How much mileage Go's rich standard library actually gives you, and a principled way to decide when to reach for an external dependency.