OPcache’s wasted_memory counter grows on every deploy and never goes down on its own. When you ship new PHP files without clearing the cache, the old compiled bytecode stays in the shared memory segment alongside the new bytecode, occupying space that cannot be reclaimed until a full reset. Over a day of frequent deploys, this fragmentation starves the cache of room for hot scripts.
The opcache.max_wasted_percentage directive sounds like an automatic garbage collector, but the restart it gates only fires when a second condition is met, and that condition is almost never reached in practice. Hit rate drifts down, CPU drifts up, and the only durable fix is a reset that itself carries a thundering-herd cost.
What wasted memory actually is
OPcache stores compiled PHP bytecode in a single shared memory segment sized by opcache.memory_consumption (default 128 MB). Every worker in the pool reads from this segment. Memory inside it falls into three buckets reported by opcache_get_status(false):
used_memory: bytes holding currently valid cached scripts.free_memory: bytes available for new compilations.wasted_memory: bytes holding bytecode that has been invalidated (source file changed, or entry evicted) but cannot be freed back into the free pool.
wasted_memory is the problem bucket. OPcache does not compact its shared memory segment. When a script is invalidated, its slot is marked stale, but the pages are not returned to free_memory. They stay allocated until the entire segment is reset. The allocator trades the ability to reclaim stale entries for the simplicity of a fixed-layout shared segment.
current_wasted_percentage is wasted_memory expressed as a fraction of the total segment. Operators typically treat anything above 30% as severe fragmentation worth a reset, and anything above 5% as the point where fragmentation is measurably present.
How deploys cause the spike
Every deploy that changes PHP source files triggers OPcache to recompile those files. How OPcache notices the change depends on opcache.validate_timestamps:
- If enabled (the default), OPcache re-stat()s files every
opcache.revalidate_freqseconds (default 2) and recompiles any whose mtime changed. - If disabled (common in production for performance), OPcache never detects changes on its own. Stale bytecode persists until something explicitly invalidates it.
In both cases the outcome for wasted memory is the same: the old compiled bytecode is invalidated, the new bytecode is compiled into free memory, and the old bytecode’s pages become waste. Until you reset, the segment holds both versions.
Symlink-swap deploys make this worse. With a release-directory layout (/releases/20260720, /releases/20260721 linked through /current), OPcache keys cached scripts by filesystem path. After the swap, the same logical file lives at a new absolute path. OPcache caches the new path as a brand-new entry rather than invalidating the old one, so even unchanged files consume double space until the old release directory’s entries age out or the cache resets.
The cumulative effect is deterministic: each deploy adds a layer of waste. Five deploys a day, each touching 10% of the codebase, can push wasted_memory past 30% within a shift if nothing clears it. The exact trajectory depends on your segment size relative to codebase size.
Why the automatic restart rarely fires
The opcache.max_wasted_percentage directive (default 5, range 0 to 50, INI_SYSTEM) does not trigger a restart when wasted memory crosses the threshold. The PHP manual, clarified after bug #80341, states the restart is only scheduled when two conditions are both true:
- Wasted memory exceeds
max_wasted_percentageof the segment. - There is insufficient free memory to satisfy a new allocation.
If free memory is still available, OPcache lets wasted memory sit at 12%, 20%, or higher and serves every request normally. The restart never fires. Operators watching current_wasted_percentage climb past 5% and waiting for a self-heal will wait indefinitely.
flowchart TD
A[Deploy changes PHP files] --> B[Old bytecode invalidated, waste grows]
B --> C{New script needs compiling}
C --> D{Enough free memory?}
D -->|Yes, the usual case| E[Compile into free memory]
E --> F[Restart NOT triggered
waste keeps accumulating]
D -->|No, cache full| G{Wasted above max_wasted_percentage?}
G -->|No| H[cache_full true, no caching]
G -->|Yes| I[Schedule restart, reclaim waste]When opcache_get_status() reports cache_full as true but restart_pending as false, OPcache hit the memory ceiling, checked the wasted threshold, found it below the trigger (or found free memory still sufficient), and refused to restart. At that point every uncached script is recompiled per request as if OPcache were disabled: you pay the compilation cost and get no cache benefit.
If you deploy without clearing the cache, you cannot rely on max_wasted_percentage to recover headroom. You must reset explicitly.
When to reset and when to leave it alone
Not every rise in wasted memory warrants intervention. Use the thresholds below as a decision frame.
| Wasted % | Free memory | Recommendation |
|---|---|---|
| Under 5% | Any | No action. Normal operating range. |
| 5 to 15% | Above 30% free | Monitor. Not urgent. Plan a reset at next deploy window. |
| 15 to 30% | Above 20% free | Reset soon. Hit rate is likely drifting. Schedule within hours. |
| Above 30% | Any | Reset now. Severe fragmentation. Cache is effectively shrinking. |
| Any | Under 10% free and cache_full true | Reset immediately. OPcache has stopped accepting new entries. |
The combination matters more than either number alone. 15% waste with 60% free memory is harmless. 15% waste with 8% free memory means the next deploy will push you into cache-full territory where OPcache silently degrades.
Reset strategies and their tradeoffs
Three reset paths exist, each with a different blast radius.
opcache_reset() called from within FPM. Clears the entire shared memory segment immediately. Every worker’s next request recompiles every script it touches. Under load, this produces a thundering-herd of simultaneous compilations: CPU spikes, latency spikes, and if traffic is high enough, worker exhaustion while workers recompile. On PHP 8.2 with the JIT enabled, there is a known issue (php-src#11609) where opcache_reset() under load can cause all workers to enter a tight shared-memory lock loop, effectively freezing the pool. If you run JIT in production, prefer a rolling FPM reload over opcache_reset().
FPM graceful reload (SIGUSR2). The master drains workers, re-execs itself, and spawns fresh workers with an empty OPcache. This avoids the in-place lock contention of opcache_reset() but introduces the brief no-worker window inherent to SIGUSR2. Behind a load balancer with multiple FPM instances, roll the reload instance by instance to avoid any user-visible gap.
Do nothing and let pm.max_requests recycle. This does not help. Worker recycling resets per-worker memory, not the shared OPcache segment. Wasted memory persists across worker forks because the segment is shared.
For symlink-swap deploys specifically, the cleanest pattern is to combine a deploy hook that calls opcache_invalidate() on changed files (targeted, avoids a full flush) with a periodic rolling reload during low-traffic windows to reclaim fragmented space. If your deploy changes a large fraction of the codebase, skip the targeted invalidation and do a full reload.
Tuning to reduce the frequency
Two settings control how often you will hit this problem.
opcache.memory_consumption. If your segment is too small for your codebase plus deploy churn, you will fragment faster. Size it to fit your codebase with at least 30% headroom for deploy overlap. Measure the high-water mark of used_memory plus wasted_memory after a busy day and add margin.
opcache.max_accelerated_files. This caps the number of cached scripts (rounded up to the next prime internally). If your codebase plus vendor dependencies exceed it, OPcache evicts older entries, which also generates waste. Count your PHP files and set this to at least 1.5x that number.
# Count PHP files in the application, including vendor dependencies
find /path/to/app -name '*.php' | wc -l
One configuration gotcha: OPcache memory settings are INI_SYSTEM, meaning they must be set in php.ini, not in FPM pool files via php_admin_value. Setting opcache.memory_consumption in a pool file may appear in phpinfo() but the actual shared memory allocation uses the php.ini value.
Signals to watch in production
| Signal | Why it matters | Warning sign |
|---|---|---|
wasted_memory / total | Direct fragmentation measure | Sustained above 30%, or any steady upward trend between resets |
current_wasted_percentage | Same as above, precomputed | Above 5% indicates fragmentation present |
free_memory | Whether new compiles can succeed | Under 10% combined with high waste means imminent cache-full |
cache_full with restart_pending false | OPcache refusing to cache new scripts | True for a sustained period means silent degradation |
opcache_hit_rate | Whether waste is hurting hit rate | Drifting below 99% after warmup |
oom_restarts counter | OPcache restarted itself after hitting memory limits | Any non-zero value, especially if incrementing |
num_cached_scripts vs max_accelerated_files | File-count pressure | Ratio above 0.9 |
| CPU per worker after deploy | Recompilation cost | Spike coincident with wasted memory rise |
The most useful correlation is wasted_memory trend against opcache_hit_rate. If hit rate is stable above 99% and waste is under 30%, the fragmentation is cosmetic. If hit rate is drifting down in lockstep with waste rising, the cache is starving and you should reset.
How Netdata helps
- The PHP-FPM collector surfaces OPcache memory signals including
wasted_memory,free_memory, and hit rate as per-second charts, so you can see the deploy-time spike and the drift between deploys without polling a status endpoint by hand. - Correlating
wasted_memoryagainst worker CPU and request latency makes the cost of fragmentation visible: you see the recompilation tax hit at the same moment waste climbs after a deploy. - Anomaly detection on
current_wasted_percentageflags the slow upward drift that static threshold alerts miss, which is the pattern that matters most for deploy fragmentation. - Tracking
oom_restartsandcache_fullstate alongside FPM pool saturation separates an OPcache problem from a worker-pool problem during incidents. - Per-pool visibility matters when different applications share a host: each pool’s OPcache segment is independent and must be assessed independently.
Related guides
- PHP-FPM 504 Gateway Timeout: requests accepted but never finishing in time
- PHP-FPM active processes near max_children: reading pool utilization
- PHP-FPM in containers: cgroup limits and the silent OOM kill
- PHP-FPM “child N exited on signal 11 (SIGSEGV)”: worker segfaults
- PHP-FPM crash loop and fork storm: workers dying faster than they serve
- PHP-FPM dynamic mode scaling lag: why the pool cannot keep up with bursts
- PHP-FPM emergency restart: “failed processes threshold reached, initiating reload”
- PHP-FPM graceful reload: the brief no-worker window on SIGUSR2
- How PHP-FPM actually works in production: a mental model for operators
- PHP-FPM idle processes at zero: no burst headroom left
- PHP-FPM listen queue growing: the earliest signal of saturation
- PHP-FPM “server reached pm.max_children setting (N), consider raising it”






