PHP-FPM Hits a Concurrency Ceiling Long Before the CPU Does
PHP-FPM serves one request per worker process. Memory and connections usually cap concurrency before the CPU does. Go changes that shape; it does not fix a slow query.
PHP-FPM’s unit of concurrency is a process. One worker, one request, until that request returns. That design is why PHP applications are pleasant to reason about: a leak dies with the request, a crash takes one connection, and you do not debug another user’s session in your heap. It is also why a fleet that still has idle CPU can refuse traffic. The ceiling is not “PHP is slow”. The ceiling is how many of those processes you can afford to keep alive, and what each of them holds.
This is a PHP → Go article, not an AI article. The question is when that process model is the constraint, what Go actually changes, and what it does not. If you have not measured the request yet, start with what to fix in PHP before a rewrite. Language migration is an expensive way to confirm that pm.max_children was set by folklore.
The unit of concurrency is a process
Classic PHP-FPM:
- nginx (or another proxy) holds the client connection.
- It forwards to FPM over FastCGI.
- An idle worker accepts one request.
- The worker bootstraps enough of the framework to run the controller (opcache makes this cheaper than people remember, not free).
- The worker talks to the database, Redis, HTTP APIs, disk.
- It prints a response and is ready again, or it is recycled because
pm.max_requestssaid so.
While step 5 is waiting on PostgreSQL, that worker is not slow in the CPU sense. It is busy in the FPM sense. It cannot take a second request. If all children are in that state, new work queues in the proxy. Load average can look civilised. Users wait.
This is the concurrency ceiling: in-flight requests cannot exceed the number of live workers, and live workers cannot exceed what you configured, which cannot exceed what memory and the database will tolerate.
Fibers, curl_multi, and a few extensions let one PHP process juggle I/O in special cases. They are not how Laravel and Symfony request handling works on ordinary FPM. Swoole, RoadRunner, and FrankenPHP change the runtime story; they are not “FPM with a bigger number”. If you are still on FPM, reason about FPM.
pm.max_children is a memory budget
pm.max_children is the hard cap on worker processes. pm.dynamic and pm.ondemand only change how you approach that cap. The cap is the ceiling.
The honest formula is closer to:
max_children ≈ (RAM for PHP) / (average worker RSS)
not:
max_children = number of CPU cores
Cores matter for CPU-bound requests. Most PHP business apps are not CPU-bound. They are connection-bound and memory-bound. A worker that has booted Laravel or Symfony and hydrated a few entities can sit at 80–200 MB resident without doing anything exotic. Forty children is then 3–8 GB before the query result that copies a large array. Sixty children is a different server, not a different pm.max_children line.
Things that inflate RSS per worker:
- framework container and compiled routes (once per process, not per request, but still per process);
- ORM identity maps on fat read models;
- “just load the whole export into an array”;
- debug toolbars left on;
- image/PDF libraries;
- persistent connections and buffers.
pm.max_requests recycles processes so leaks do not run forever. That is good hygiene. It does not raise the ceiling. It lowers average RSS a bit by killing the worst offenders, at the cost of extra bootstraps.
When you hit the cap, FPM’s listen.backlog and the proxy queue fill. Symptoms:
- rising time-to-first-byte with CPU still not pegged;
server reached pm.max_childrenin the FPM log;- nginx
upstream timed outor 502/504 depending on timeouts; - a thundering herd when workers free at once.
The wrong response is to double pm.max_children because a blog used 50 as a default. Measure RSS under production traffic, subtract OS, database-if-colocated, Redis, and a margin, then set the cap. If that number is too small for the arrival rate times the mean wait, you do not have a “tune FPM” problem yet. You have too much wait per request, or you need a different concurrency model.
pm.min_spare_servers / pm.max_spare_servers only affect how many idle processes you keep warm. They do not create RAM. Ondemand reduces idle memory and adds fork latency; it does not raise the max.
Connections: the second ceiling
Each FPM worker is a client. If you use persistent PDO, or a pool that is really “one connection per process”, then max_children is a lower bound on database sessions during a full storm. PostgreSQL max_connections is a real limit. So is the memory PostgreSQL spends per backend. So is PgBouncer, if you have it, and the pool mode you chose.
A fleet of 80 PHP workers, each holding a connection, plus cron, plus queue workers, plus admin sessions, is how you discover too many connections on a perfectly healthy primary. The web workers were all waiting on a lock or a sequential scan. CPU on the PHP box was bored. The database was not.
The same arithmetic applies to:
- Redis (fewer, but not infinite);
- HTTP/2 connections to a billing API with a concurrency cap;
- SMTP;
- file descriptors.
PHP does not multiplex those waits across requests in one process. Go can, which is the interesting part, but only after you know you are not just going to open 10,000 connections from 10,000 goroutines. Backpressure is not optional in either language. FPM gives you a crude version for free: the worker count is the semaphore.
Queue workers are the same process tax on the consumer side. If the ceiling you care about is “how many jobs we can wait on at once”, read moving PHP queue workers to Go. The HTTP FPM case and the worker case share a memory model. They do not share a latency contract.
What Go actually changes
Go’s unit of concurrency is a goroutine. One OS process (or a handful), many requests in flight, a scheduler that parks work that is waiting on I/O. The PHP vs Go backend comparison is the wider map. Here is the subset that matters for the FPM ceiling.
Memory per in-flight request. You are no longer cloning the interpreter and the framework graph N times. You pay for one process heap plus per-request stacks and whatever you allocate. That is why the same machine can hold far more concurrent I/O-bound requests.
Connection pooling that is actually a pool. database/sql with SetMaxOpenConns lets 500 in-flight handlers share 40 database connections. FPM cannot do that without an external pooler, because the client is the worker. PgBouncer in front of PHP is a valid way to steal some of this benefit without a rewrite. Use it. Then see whether you still have a language problem.
Tail latency under wait. When 200 clients are waiting on the same HTTP dependency, FPM needs 200 workers to keep them all in flight, which you probably do not have. Go can hold 200 goroutines and 20 outbound connections with a semaphore. Users still wait on the dependency. They do not wait extra because you ran out of PHP children.
Long-lived connections. WebSockets, SSE, and anything that occupies a worker for minutes are hostile to FPM. Each open socket is a child you cannot use for the rest of the site. Go is a better default there. That is not a summarisation of every PHP app; it is a specific workload.
Deployment shape. A static binary and a small connection pool are easier to reason about at high fan-out than a herd of processes whose RSS you sample and pray. This is operational, not aesthetic.
What you must add in Go, because FPM was hiding it: bounded worker pools, timeouts on every outbound call, pprof, and the discipline not to go handle() unbounded. An unbounded goroutine storm will exhaust the database faster than FPM ever could. You traded a low, rigid ceiling for a high, optional one. Set the option.
What Go does not change
A rewrite does not move the wait.
If the request is 40 ms of PHP and 400 ms of SQL, Go might shave the 40 ms and still spend 400 ms in pg_stat_activity. If the planner is wrong, fix the planner. If N+1 queries fire per worker, they will fire per goroutine. If Redis is single-threaded and hot, you will heat it from one process instead of fifty; the Redis CPU is the same work.
Go does not:
- add indexes;
- split a monolith’s transactional boundary;
- remove a remote payment API’s 800 ms;
- make a 20 MB JSON payload cheap to ship to a browser;
- replace the need for a cache on a stampeding endpoint;
- lower PostgreSQL lock contention on the same rows.
It also does not remove PHP from the parts of the system that are CRUD and admin, where Laravel or Symfony are still the faster way to express rules. Extracting the hot, wait-heavy, high-fan-out edge is the usual win. Boiling the ocean so that a settings screen is in Go is how migrations fail.
Resident PHP runtimes (RoadRunner, FrankenPHP, Swoole) can raise the FPM ceiling without changing language: one or few processes, keep-alive, sometimes goroutine-like fibers or worker threads depending on the product. Try them if the team is not going to maintain Go and the bottleneck is process count rather than CPU in your own code. They bring long-lived process bugs that FPM used to recycle away. That trade is real, same as in Go.
How to tell you have hit the FPM ceiling
Do not guess from a flame graph that shows pdo_pgsql and conclude “we need Go”. Collect:
- FPM:
active processesvsmax_children,listen queue, slow log; - host: RSS of the php-fpm pool, CPU, context switches;
- proxy: upstream wait, 502/504, queue time;
- PostgreSQL: connection count,
wait_event,pg_stat_statementsby total time; - APM: time in PHP vs SQL vs HTTP.
You are in FPM-ceiling territory when active children pin at max, CPU on PHP is not saturated, and time is in wait. You are in CPU-in-PHP territory when workers are busy and profilers show your code or a tight loop. You are in database territory when PHP is idle-in-wait and the primary is not. Those three diagnoses lead to three different projects.
Load-test with a realistic mix, including the slow endpoints, not only GET /health. A health check never holds a worker long enough to teach you anything.
If max_children can still grow because you have RAM and the database pooler has headroom, grow it, fix the slow queries, and add caching. That is cheaper than Go. If max_children cannot grow, the arrival rate times service time exceeds what processes can hold, and the waits are I/O you must keep in flight, Go (or a resident PHP server with a real pool) is in the conversation.
When to stay on PHP-FPM anyway
Stay, or stay for this service, when:
- traffic fits in a measured child count with RAM to spare;
- the painful endpoints are database-bound and still unindexed;
- the team would create a single unowned Go service;
- the work is an admin UI with modest concurrency;
- you have not tried PgBouncer, opcache preloading, and killing the fat endpoints.
Move a slice to Go when you have evidence that process-shaped concurrency is the limiter, the interface of that slice is small, and you can roll back. HTTP fan-out, streaming, and high-wait APIs are the usual HTTP targets; queues are often even better first targets.
If you want that call written down against your numbers, the PHP to Go migration work is scoped for it: a Migration Readiness Audit at €1,200, implementation from €402/day, and a legitimate outcome that says tune FPM, add a pooler, and leave the language alone.
PHP-FPM hits a concurrency ceiling long before the CPU does because a waiting worker is still a worker. pm.max_children, RAM per process, and database sessions set the cap. Go changes the unit of concurrency and the shape of pooling. It does not forgive a sequential scan. Measure which ceiling you actually hit, then pick the lever that moves that number.