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_freq seconds (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:

  1. Wasted memory exceeds max_wasted_percentage of the segment.
  2. 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 memoryRecommendation
Under 5%AnyNo action. Normal operating range.
5 to 15%Above 30% freeMonitor. Not urgent. Plan a reset at next deploy window.
15 to 30%Above 20% freeReset soon. Hit rate is likely drifting. Schedule within hours.
Above 30%AnyReset now. Severe fragmentation. Cache is effectively shrinking.
AnyUnder 10% free and cache_full trueReset 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

SignalWhy it mattersWarning sign
wasted_memory / totalDirect fragmentation measureSustained above 30%, or any steady upward trend between resets
current_wasted_percentageSame as above, precomputedAbove 5% indicates fragmentation present
free_memoryWhether new compiles can succeedUnder 10% combined with high waste means imminent cache-full
cache_full with restart_pending falseOPcache refusing to cache new scriptsTrue for a sustained period means silent degradation
opcache_hit_rateWhether waste is hurting hit rateDrifting below 99% after warmup
oom_restarts counterOPcache restarted itself after hitting memory limitsAny non-zero value, especially if incrementing
num_cached_scripts vs max_accelerated_filesFile-count pressureRatio above 0.9
CPU per worker after deployRecompilation costSpike 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_memory against 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_percentage flags the slow upward drift that static threshold alerts miss, which is the pattern that matters most for deploy fragmentation.
  • Tracking oom_restarts and cache_full state 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.