A PHP service in worker mode grows memory with every request until workers die at memory_limit; how do you find the growth and contain it with worker recycling?
answer
- plateau versus straight line
- memory_get_usage() after each request
- memory_reset_peak_usage() since 8.2
- Allowed memory size exhausted fatal
- max requests per worker, then replace
basics
~20 sLog memory_get_usage() after each request: a plateau is warm caches, a steady climb is a leak, usually data appended to long-lived arrays. Fix the source, and recycle workers after N requests or above a memory threshold so growth never reaches memory_limit mid-request.
solid answer
~50 sFirst measure: record `memory_get_usage()` after every request (and per-request peaks with `memory_get_peak_usage()`, resetting them with `memory_reset_peak_usage()`, available since PHP 8.2). Memory that rises for a while and then flattens is warm caches; memory that climbs in a straight line with request count is a leak. Typical culprits are long-lived arrays that only grow: static caches without a bound, a query or event log kept in memory, an ORM identity map never cleared, listeners registered per request, or `register_shutdown_function()` called per request. If a worker reaches `memory_limit` (128M by default), the engine raises a fatal "Allowed memory size of … bytes exhausted" error in the middle of a request and the worker dies. Contain it with recycling: end each worker gracefully after a number of requests or above a memory threshold, between requests, and let the runtime start a fresh one. Recycling buys time; it does not fix the leak.
code
php · 20 lines<?php
declare(strict_types=1);
// Worker loop with measurement and memory-based recycling.
$limit = 96 * 1024 * 1024; // well below memory_limit (128M)
$handled = 0;
while (frankenphp_handle_request($handler)) {
$handled++;
gc_collect_cycles();
error_log(sprintf(
'req=%d mem=%d peak=%d',
$handled,
memory_get_usage(),
memory_get_peak_usage(),
));
memory_reset_peak_usage(); // PHP 8.2+: per-request peak
if (memory_get_usage() > $limit) {
break; // exit cleanly; the runtime starts a new worker
}
}go deeper
Recall that a long-running PHP worker keeps what it allocates, so memory can grow across requests until it hits memory_limit.
Explain how to read memory_get_usage() over request count, what a plateau versus a straight line means, and why memory_reset_peak_usage() matters.
Find the growing structure, fix it, and set recycling by request count or memory threshold so workers exit between requests, never mid-request.
Balance recycling limits against boot cost and host memory, stagger recycling across workers, and budget memory per worker in capacity plans.
## Why memory behaves differently in a worker Under PHP-FPM, whatever a request allocates is freed when it ends, so a small leak is invisible. In **worker mode** the same PHP process handles thousands of requests, and anything that is still referenced after a request stays in memory. A leak of a few kilobytes per request becomes hundreds of megabytes over a day. The `memory_limit` directive still applies, but the budget that used to cover one request now covers the worker's whole lifetime. (How PHP frees memory, reference counting and the cycle collector, is its own topic; here the question is how to detect and contain growth in a worker.) ## Step 1: measure per request The engine gives you three functions: - `memory_get_usage()` — memory currently used by PHP's allocator; pass `true` to see memory reserved from the system instead. - `memory_get_peak_usage()` — the highest usage so far. - `memory_reset_peak_usage()` — resets that peak, **new in PHP 8.2**, so you can read a per-request peak in a long-running worker. Log usage after each request together with the worker's request count, then plot it: | Shape of the curve | Likely meaning | |---|---| | rises, then flattens | caches warming up: routes, metadata, a bounded cache | | straight line upwards | a leak: something grows per request and is never released | | sudden jumps | a few requests load huge data sets, and the peak stays reserved | | saw-tooth that never drops to its base | growth that the cycle collector only partly reclaims | ## Step 2: find what grows The usual suspects in PHP worker code: 1. **Unbounded static or singleton arrays**: an in-memory cache keyed by something unbounded, such as user IDs or URLs. 2. **Diagnostics that accumulate**: a query log, profiler or debug collector that records every query of every request. 3. **ORM state**: an identity map or unit of work that is never cleared between requests. 4. **Per-request registration**: event listeners or callbacks added on each request to a long-lived dispatcher, or `register_shutdown_function()` called per request, which piles up callbacks that run only when the worker exits. 5. **Captured objects**: closures stored in long-lived registries that captured the request and everything it references. Comparing a snapshot of suspicious structures (for example the size of a service's cache array) after request 100 and request 1,000 narrows it quickly. Calling `gc_collect_cycles()` before measuring separates collectable cycles from real leaks. A quick way to localise growth in development is to replay one endpoint thousands of times against a single worker and compare the curves: if one endpoint grows memory and another does not, the leak lives in code only the first one runs. ## Step 3: contain it with recycling Even clean code tends to grow a little, so production workers are **recycled**: a worker exits between requests after a limit, and the runtime starts a fresh one. - **FrankenPHP** — the worker script's own loop decides; the documented pattern counts handled requests and leaves the `frankenphp_handle_request()` loop after a configured maximum. - **RoadRunner** — the pool configuration can cap the jobs per worker (`max_jobs`) and a supervisor can replace a worker above a memory limit. - **Swoole** — the server's `max_request` setting restarts a worker after that many requests. - **Any runtime** — the worker loop itself can check `memory_get_usage()` after each request and exit cleanly above a threshold set well below `memory_limit`. The point of recycling **between** requests is to avoid the alternative: hitting `memory_limit` (default `128M` in php-src) raises the fatal error "Allowed memory size of N bytes exhausted", which aborts the request being served and kills the worker. ## What recycling does not do - It does not remove the leak; it caps how far it can grow. - Aggressive limits (recycling every few requests) bring back the boot cost that worker mode was adopted to remove. - If all workers start together and share the same limit, they tend to recycle together; spreading the limits avoids a wave of cold workers. Fix the source, then keep a generous recycling limit as a safety net.
- Why not simply raise memory_limit for the workers?A higher limit only delays the fatal error; a real leak still reaches it, and each worker now holds more memory, so fewer workers fit on the host. Raise it only when a measured plateau is legitimately above the current limit, and still recycle below it.
- What does gc_collect_cycles() tell you when you call it before measuring?It forces the cycle collector to free unreachable objects that reference each other. If memory falls back after the call, the growth was collectable garbage waiting for the collector; if it keeps climbing, something reachable, such as a static array or a registered listener, is holding the data and must be found in code.
saying these in an interview costs you the question
- Worker memory always grows, so a steady climb is normal
- Recycling workers after N requests fixes the leak
- Hitting memory_limit only logs a warning and continues
- memory_get_peak_usage() resets itself after every request
- Recycling after every few requests is free