# 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 OAuth2 + PostgreSQL API on PHP-FPM, FrankenPHP and Go, four cores each: latency at a fixed target rate, ceiling and CPU per request.

- Kind: Measurement
- Question: 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.
- 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.
- Metrics: 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
- Measured on: 2026-09-16
- Confidence: Medium confidence
- Status: Current
- Programme: Service & load
- 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
- Source code: https://github.com/muhammetsafak/php-go-bench
- Raw data: https://github.com/muhammetsafak/php-go-bench/tree/main/results/2026-09-16
- Raw data licence: https://github.com/muhammetsafak/php-go-bench/blob/main/LICENSE
- Published: 2026-09-16
- Source: https://www.muhammetsafak.com.tr/en/research/php-vs-go-oauth2-postgres-load-test/
- Language: en-US
- Author: Muhammet Şafak

---
"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](https://github.com/muhammetsafak/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.

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

|  | Token check only | Check + read | Check + write |
| --- | --- | --- | --- |
| Target | 50000 | 50000 | 10000 |
| Go | 49999 | 49999 | 10000 |
| FrankenPHP (worker) | 33089 | 22859 | 10000 |
| PHP-FPM | 30606 | 23528 | 10000 |

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.

Source: Fixed-rate phase, median of 5 repetitions

|  | 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 |

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.

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

|  | Token check only | Check + read | Check + write |
| --- | --- | --- | --- |
| Go | 83526 | 59004 | 43942 |
| FrankenPHP (worker) | 32953 | 22613 | 22874 |
| PHP-FPM | 31157 | 24437 | 21857 |

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.

> **Result**
>
> If your target is 10,000 writes a second, moving from PHP to Go does not change
> your capacity sheet: all three manage it on one or two cores. At a target of
> 50,000 reads, both PHP runtimes I measured need more than twice the cores you
> would give Go. For your own target the sum is target rate × CPU per request;
> for FrankenPHP, add the idle core on top.

## 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](https://github.com/muhammetsafak/php-go-bench/blob/main/results/2026-09-16/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](/en/research/php-framework-load-test/) entry.

To produce your own number, run the
[php-go-bench](https://github.com/muhammetsafak/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.
