Request Lifecycle: How Web Frameworks Handle a Request · Foundations: From Socket to Response

PHP-FPM and the Shared-Nothing Model

Why a PHP site returned 502 errors while CPU sat at 30%, how PHP-FPM workers serve requests, and what 'shared nothing' means for state between requests.

Scenario

Example app: a PHP shop (Laravel, Symfony or plain PHP) runs on PHP-FPM behind nginx with pm.max_children = 10. Product pages call a recommendations API that normally answers in 100 ms.

What you see in production

  • During a sale, the recommendations API slows to 2 seconds.
  • nginx returns 502/504 errors and logs failures connecting to the PHP-FPM socket.
  • CPU usage on the server is only 30%. Adding CPU doesn't help.
  • The PHP-FPM log shows server reached pm.max_children setting.

The naive code

<?php
// every product page waits for the recommendations API, no timeout
$recs = file_get_contents("https://recs.internal/api/for/" . $productId);

Reproduce it

PHP-FPM runs a fixed pool of worker processes. Each worker handles one request at a time, from start to finish. The simulation sends 40 requests per second to 10 workers while each request waits on the slow API.

A worker pool saturated by slow I/OTry it yourself
<?php
function simulate(int $workers, float $secondsPerRequest, int $arrivalsPerSecond, int $backlog): string {
    $window = 10;                                              // look at a 10-second window
    $arrivals = $arrivalsPerSecond * $window;
    $capacity = (int) floor($workers / $secondsPerRequest * $window);   // requests the pool can finish
    $served = min($arrivals, $capacity);
    $queued = min($arrivals - $served, $backlog);              // waiting in the listen queue
    $rejected = $arrivals - $served - $queued;                 // nginx gets 502/504
    return sprintf("request %.2fs -> in 10 s: %3d arrive, %3d served, %2d queued, %3d failed",
        $secondsPerRequest, $arrivals, $served, $queued, $rejected);
}

echo "normal day (API 0.1 s):            ", simulate(10, 0.15, 40, 20), "\n";
echo "sale (API 2 s, no timeout):        ", simulate(10, 2.05, 40, 20), "\n";
echo "sale (100 ms API timeout + fallback): ", simulate(10, 0.20, 40, 20), "\n";

// Shared nothing: each request starts with fresh memory
function handleRequest(): int {
    static $counter = 0;        // static survives only within this request's process lifetime...
    $counter++;
    return $counter;
}
echo "\nrequest 1 sees counter = ", handleRequest(), "\n";
echo "in PHP-FPM the next request starts a new script run, so it sees 1 again - not 2\n";

Output

normal day (API 0.1 s):            request 0.15s -> in 10 s: 400 arrive, 400 served,  0 queued,   0 failed
sale (API 2 s, no timeout):        request 2.05s -> in 10 s: 400 arrive,  48 served, 20 queued, 332 failed
sale (100 ms API timeout + fallback): request 0.20s -> in 10 s: 400 arrive, 400 served,  0 queued,   0 failed

request 1 sees counter = 1
in PHP-FPM the next request starts a new script run, so it sees 1 again - not 2

Root cause

PHP-FPM's capacity is workers ÷ time per request. Workers blocked on slow I/O do no CPU work, but they still occupy a slot, so the pool runs out of workers while the CPU is idle. Once the listen queue is full, nginx can't hand requests to PHP and returns 502 or 504.

How a PHP-FPM request works

  1. nginx passes the request to PHP-FPM over FastCGI (a Unix socket or TCP port).
  2. The FPM master assigns it to an idle worker process.
  3. The worker runs your entry script (public/index.php) from the top: autoloading, framework bootstrap, routing, controller, response.
  4. The response goes back to nginx. The script's variables, objects and static properties are discarded: shared nothing. Only things outside the request survive: OPcache's compiled code, preloaded classes (OPcache preloading), APCu, sessions, databases and caches.
  5. The worker takes the next request.

The fix

  • Put timeouts on every outbound call (HTTP, database) and fall back gracefully (show the page without recommendations).
  • Move slow, non-essential work to queues or load it asynchronously from the browser.
  • Size pm.max_children by memory (available RAM ÷ average worker memory), not by guesswork, and choose pm = dynamic or static deliberately. See the FPM configuration.
  • Set request_terminate_timeout so stuck workers are recycled, and pm.max_requests to recycle workers that leak memory.
  • Use separate pools for slow endpoints (admin exports) so they can't starve customer pages.

Trade-offs

  • More workers need more memory; past a point they cause swapping, which is worse than queuing.
  • Long-running PHP servers (Laravel Octane, RoadRunner, FrankenPHP workers, Swoole) avoid per-request bootstrap cost but give up shared-nothing safety (see the Octane lesson).

Detect it in production

  • Enable the FPM status page (pm.status_path) and track active and idle processes, listen-queue length and max children reached.
  • Alert on nginx upstream errors and on outbound-call latency.

Key points

  • PHP-FPM runs a pool of workers; each serves one request at a time.
  • Capacity = workers ÷ request time, so slow I/O exhausts workers even when CPU is idle.
  • Every request starts fresh (shared nothing); only OPcache, caches and storage persist.
  • Use outbound timeouts, queues, memory-based pool sizing and status monitoring.

Practice questions

What does 'shared nothing' mean in PHP-FPM?

Answer

Each request runs the script from the start in a worker process and all variables, objects and static properties are discarded at the end. Nothing in application memory carries over to the next request; persistent state lives in OPcache (compiled code), caches, sessions and databases.

Why can a PHP-FPM server return 502 errors while CPU usage is low?

Answer

Workers are busy waiting on slow I/O (APIs, databases). Each still occupies one of the pm.max_children slots, so new requests queue and then fail even though the CPU is idle.

How do you size `pm.max_children`?

Answer

Divide the memory available for PHP by the average memory per worker (measured), leaving headroom, then confirm with load tests and the FPM status page. Increasing it beyond memory limits causes swapping.

Open in the app to mark lessons complete, take notes and track your progress.