Skip to content
Core Web Vitals specialists
Server & hosting

PHP Runtime Tuning: OPcache, JIT and FPM Workers

Between web server and database sits the PHP runtime. How OPcache, preloading, JIT and FPM workers shape your response time under load.

12 min read Server-OptimierungTTFBPHPOPcacheBackend

When a page responds slowly, the search for the cause almost universally starts at the same two ends: people switch the hosting plan, or they go after the database. Between the two sits a third layer that gets checked far less often and that, in WordPress and Shopware installations, frequently carries the largest share of the server response time -- the runtime of the application itself. This is where source files are read, compiled into opcodes and executed, long before the database sees a single query. How fast this part works comes down to three levers: the OPcache, the question of whether the JIT compiler contributes anything at all during a short web request, and the configuration of the FPM workers that process the requests. A server response time of at most 800 milliseconds (web.dev) counts as good; many installations hold that value while idle and lose it exactly when business picks up. This article sorts out the three levers, shows which values can actually be read out, and explains why a single page load in the browser proves very little. In our server optimization work this intermediate layer is a fixed checkpoint.

Key takeaways

  • OPcache saves the two most expensive steps of every request: reading the file and compiling it into opcodes. Out of the box it gets 128 MB (PHP manual) of shared memory and 10,000 (PHP manual) file slots -- for larger shops neither is usually enough.
  • The hit rate is the number that matters. It can be read via opcache_get_status() (PHP manual); restarts caused by exhausted memory or a full file table are the clearest warning sign.
  • JIT is off by default: since PHP 8.4 opcache.jit is set to disable, while the buffer opcache.jit_buffer_size stands at 64M; up to PHP 8.3 the buffer was 0 and JIT failed on that alone (PHP manual). It delivers a lot for compute-heavy loops and little for typical web requests: in the JIT RFC, WordPress throughput rose from 315 to 326 requests per second (PHP RFC JIT).
  • The FPM process model determines behaviour under load. pm.max_children is not a wish, it is the result of a calculation from usable memory and the actual memory footprint of a worker process.
  • Worker saturation looks like a slow backend in measurements, but it is a queuing problem: the request sits in the listen queue before a single line of PHP runs. It becomes visible on the FPM status page (PHP manual), not in the browser.

The layer between web server and database

The path of a dynamic page is quickly described but expensive in several places. The web server accepts the request and hands it over a socket to PHP-FPM. There a free worker process picks it up, loads the entry file, resolves autoloader paths, reads every required class file from the file system, parses it, compiles it into opcodes and executes it. Only then do database queries, cache lookups and calls to external interfaces occur. What the visitor experiences as Time to First Byte is the sum of all these steps -- the metric itself and its components are covered in detail in the article on server response time.

In many projects the diagnosis lands too early. If the response time is high, the database falls under suspicion -- and indeed, indexes and the removal of N+1 patterns can produce big jumps there, as the article on database query optimization shows. But if a shop pulls in several thousand class files per request and the opcode cache is undersized, a considerable share of the waiting time arises before the first query has even been formulated. That time appears in no slow query log. It sits in the runtime -- and it is usually reachable through configuration rather than a code rewrite.

The practical value of this distinction lies in the ordering. Anyone who knows which share of the response time goes to waiting, compiling, executing and I/O does not have to guess which measure pays off. And they spot early when a measure cannot possibly help: a perfectly sized opcode cache does not shorten a single millisecond of waiting in front of an occupied worker, and additional workers do not speed up a slow query.

Where the time in a PHP request goes

A PHP request breaks down into four rough sections: waiting for a free worker, loading and compiling the code, executing the opcodes and finally I/O against database, cache and external systems. OPcache addresses only the second section, JIT only the third, the FPM configuration only the first. If the sections are not separated, it is easy to optimize the wrong place -- and then to wonder why a correctly implemented measure moves nothing in the measurement.

OPcache: compile once instead of on every request

Without an opcode cache, PHP repeats the same work on every single request: read files from the file system, parse the source, build a syntax tree and generate opcodes from it. Only then does the actual execution begin. OPcache stores the result of that compilation in shared memory so that all worker processes can use it. From the second request onwards, reading, parsing and compiling drop away -- execution is what remains. For applications with many small class files, meaning practically every modern shop and every framework, this is the single most effective step in the runtime layer.

Sizing is what decides the outcome, and three values matter. opcache.memory_consumption sets how much shared memory is available -- 128 MB (PHP manual) by default. opcache.max_accelerated_files limits the number of files in the cache, 10,000 (PHP manual) by default; the value you set is internally rounded up to the next number in a fixed series (PHP manual), which is why 32,531 is a more sensible entry than a round number. opcache.interned_strings_buffer reserves room for repeatedly used strings, 8 MB (PHP manual) by default; with many classes, translations and configuration keys that gets tight quickly. If any of the three runs short, the cache evicts entries and recompiles -- and that produces an effect which, in operation, looks like a backend that suddenly turned slow.

opcache.ini
; Size the opcode cache instead of guessing
opcache.enable = 1
opcache.memory_consumption = 512
opcache.interned_strings_buffer = 32
opcache.max_accelerated_files = 32531

; Production: do not check timestamps on every request,
; reset the cache deliberately during deployment instead
opcache.validate_timestamps = 0

; Without a buffer, JIT does nothing - switch it on
; deliberately and measure it apart from other changes
opcache.jit_buffer_size = 0

Whether the sizing holds up can be measured instead of guessed. The function opcache_get_status() returns the hit rate, used and free memory, the number of cached scripts and counters for restarts (PHP manual). In practice the hit rate in live operation should sit close to one hundred percent; noticeably lower values mean PHP is recompiling on a regular basis. The restart counters say even more: oom_restarts stands for restarts caused by exhausted memory, hash_restarts for a full file table, manual_restarts for triggered resets. If the first two rise during operation, the cache is too small -- regardless of how good the average hit rate looks.

Value from opcache_get_status()What it tells youResponse
opcache_hit_rateShare of requests that find compiled codeWell below one hundred percent: check memory and file slots
num_cached_scriptsNumber of cached filesClose to max_accelerated_files: raise the limit
free_memoryFree share of the shared memoryPermanently tight: increase memory_consumption
oom_restartsRestarts caused by exhausted memoryAbove zero: the cache is undersized
hash_restartsRestarts caused by a full file tableAbove zero: raise max_accelerated_files

The opcode cache is only the lowest of several cache layers. Above it sit the object cache, the page cache and delivery through upstream layers; how these levels interlock and which request even reaches PHP is explained in the article on caching strategies. For a page answered entirely from a page cache, the runtime layer is irrelevant -- for every personalized page, every cart and every login, it matters a great deal.

Invalidation after a deployment

A cache that holds compiled code has to know when that code is outdated. PHP solves this with timestamps: opcache.validate_timestamps is active by default, and opcache.revalidate_freq defines after how many seconds a file is checked again -- 2 seconds (PHP manual) out of the box. On production systems the check is frequently switched off because every comparison costs file system access. That is a defensible decision, but it shifts responsibility: whoever switches the check off has to reset the cache actively after every deployment, otherwise the old state keeps running.

The classic case: half old, half new code

If a directory is overwritten while the site is running, a single request can mix classes from the old and the new state -- part of it still sits in the cache, another part is read fresh. The result is sporadic errors that dissolve after the next restart and are therefore hard to reproduce. A more robust approach deploys into a new directory, then switches a symlink and resets the opcode and realpath caches deliberately. One thing to keep in mind: the realpath cache resolves paths and has to be considered during the symlink switch, otherwise the workers keep pointing at the old path.

WordPress installations with many extensions add a second effect. Every update changes files, and with timestamp validation switched off the change only takes effect once the cache has been reset. Anyone updating extensions as part of everyday editorial work needs a defined procedure for this rather than a hopeful restart. Which other bottlenecks typically accumulate in such installations is described in the article on common WordPress slowdowns.

Preloading: load the core once at startup

Since PHP 7.4 there has been a level above the plain opcode cache: preloading (PHP manual). Through opcache.preload, a script is executed when the service starts that reads in a list of classes. These classes stay permanently in shared memory and are available to every request immediately -- without an autoloader, without file system access and with inheritance relationships already resolved. For frameworks with a stable core this is an additional gain on top of the opcode cache, because even the lookup path disappears.

The price is operational discipline. Preloaded classes are bound to the process: a change to a preloaded file only takes effect after a restart of the FPM service, regardless of timestamp validation. In addition, opcache.preload_user has to be set when the service starts as root (PHP manual), and the list should cover the stable core rather than application code that changes weekly. In shop projects preloading pays off above all where the framework core is large and the share of customer-specific classes stays manageable; for Shopware installations it is a separate step behind cache sizing, not a replacement for it.

What JIT delivers and what it does not

PHP 8.0 added the JIT compiler (PHP manual). It translates frequently traversed opcode sequences into machine code at runtime and thereby bypasses the virtual machine. That sounds like a big lever, and in certain load profiles it is one -- just not where most web applications spend their time. Worth knowing: JIT is not active by default. Since PHP 8.4 opcache.jit is set to disable and opcache.jit_buffer_size to 64M; up to PHP 8.3 it was the other way round, with the buffer at 0 (PHP manual). Without a buffer nothing happens, no matter how opcache.jit is configured, and a buffer alone is not enough either as long as opcache.jit stays off. Anyone who believes JIT is enabled without having checked both is measuring an effect that does not exist.

The figures from the corresponding RFC show the difference clearly. On a synthetic compute benchmark the runtime dropped from 0.320 to 0.140 seconds (PHP RFC JIT), and on a Mandelbrot calculation the factor was even above four (PHP RFC JIT). With WordPress, by contrast, throughput rose only from 315 to 326 requests per second (PHP RFC JIT) -- roughly 3.5 percent. The official summary of the Tracing JIT in PHP 8.0 reads similarly: about three times the performance on synthetic benchmarks, one and a half to two times on certain long-running applications, and for typical applications a level comparable to PHP 7.4 (PHP 8.0 release notes).

The reason lies in the profile of a web request. It is short, jumps through many different code paths and spends a considerable share of its time waiting on database, cache and file system. JIT, on the other hand, needs hot loops to earn back its translation cost. Where such loops actually occur -- image processing, price and discount calculations across large volumes, import and export runs, statistical evaluations -- the gain is real and measurable. In the ordinary page build of a shop they are rare.

Where JIT carries

Long-running, compute-heavy loops: image processing, bulk calculations, import and export runs, statistical evaluations. The RFC measures factors from two to above four here (PHP RFC JIT).

Where JIT barely helps

The short web request with many code paths and a lot of waiting on I/O. For WordPress the RFC recorded 315 versus 326 requests per second (PHP RFC JIT), roughly 3.5 percent.

What JIT costs

The buffer occupies memory that would otherwise benefit the opcode cache, and translation needs a warm-up phase. This is why JIT should be switched on on its own and measured separately, not together with other changes.

The core in one sentence

OPcache shortens the path to the code, JIT speeds up pure computation, and the FPM configuration decides how many requests benefit at the same time -- the three do not replace one another, they act on three different sections of the same request.

Whether saved compute time shows up at all also depends on the machine underneath. On a shared plan with fluctuating processing power, a gain of a few percent disappears into the noise of the neighbours; on dedicated capacity it becomes visible. The role the hosting model plays for response time is explained in the article on choosing hosting.

The FPM process model: static, dynamic, ondemand

PHP-FPM manages a pool of worker processes. How that pool comes about is set by the pm directive, and it knows three values: static, dynamic and ondemand (PHP manual). With static, a fixed number of processes runs permanently. With dynamic, FPM starts a base amount and adjusts between a lower and an upper bound of idle processes. With ondemand, no process exists at first; processes are created on demand and terminated again after an idle period. All three modes solve the same task, but they distribute the cost differently between memory consumption and start-up time.

pm modeBehaviourFitsPrice
staticFixed number of processes, permanently kept readyEven, predictable load on a dedicated machineMemory is tied up permanently
dynamicBase amount plus adjustment between two boundsFluctuating load with a recognizable baselineStart-up cost when scaling upwards
ondemandProcesses are created on demand, end after idlingMany small sites on a shared machineThe first request after a pause pays for the start

Above all modes stands pm.max_children -- the upper bound of simultaneously working processes and therefore the actual capacity limit of the application. This value is not a wish, it is the result of a calculation: usable memory minus what database, cache service and operating system need, divided by the real memory footprint of a worker process under load. If the footprint is estimated too low, the operating system starts swapping under load -- and a swapping server answers more slowly than one with a tightly measured number of workers.

In addition, pm.max_requests limits how many requests a worker handles before it terminates and restarts; by default the value is 0, meaning unlimited (PHP manual). A finite value is a pragmatic safeguard against slowly growing memory consumption in extensions. It does not remove the cause, but it keeps the process pool healthy. Comparable growth exists on the browser side, where it shows up in long sessions as increasing sluggishness -- described in the article on frontend memory leaks.

www.conf
[www]
; (usable memory - reserve) / memory footprint per process
pm = dynamic
pm.max_children = 40
pm.start_servers = 10
pm.min_spare_servers = 6
pm.max_spare_servers = 14

; Recycle a process after 500 requests - a safeguard, not a fix
pm.max_requests = 500

; Make saturation visible
pm.status_path = /fpm-status
slowlog = /var/log/php-fpm/slow.log
request_slowlog_timeout = 5s

Worker saturation: the hidden cause

The most unpleasant state is not slow code but an exhausted process pool. Once all workers are occupied, new requests move into the listen queue of the socket and wait there without a single line of PHP running. To the visitor this looks like a slow server; in application profiling it looks like nothing at all, because the waiting time sits before the measured section. The queue has an upper bound of its own: listen.backlog is set to 511 (PHP manual) by default. Once that is full too, connections are refused -- a slow page turns into a broken one.

The state becomes visible through the FPM status page. Via pm.status_path the service reports, among other things, the number of active and idle processes, the current and the highest listen queue length ever reached, and the max children reached counter (PHP manual). If that counter is above zero, the process pool was exhausted at least once -- the clearest indication that capacity, not code, is the problem. The slowlog with request_slowlog_timeout additionally shows which scripts exceed a defined threshold, including the call chain (PHP manual); it is off by default and therefore unused in most installations.

  • The max children reached counter sits above zero -- the pool has already been exhausted
  • Listen queue and its recorded maximum are permanently different from zero
  • Response time jumps sharply above a certain level of concurrency while individual database queries stay equally fast
  • Processor utilization is low even though requests are waiting -- a sign of too few workers, not of too little compute power
  • Restarting the service brings brief relief, and the state returns under load

This limit can be probed deliberately before the peak season instead of being discovered in live operation; what such a test setup looks like is described in the article on load testing before traffic spikes. It is worth looking at the total request volume while doing so: a growing share of accesses comes not from customers but from automated retrievals that occupy the same workers -- a development covered in the article on crawler load on the server.

Measuring before and after realistically

The most common mismeasurement in this layer is the single page load. You change a value in the configuration, reload the page in the browser, see a better number and write it into the report. The problem: the first request after a restart hits an empty opcode cache, the second hits a warm one, and neither says anything about behaviour with twenty simultaneous users. A measurement only becomes reliable with three ingredients -- a defined warm-up state, repeated load at realistic concurrency and an evaluation based on percentiles rather than averages.

The average hides exactly the effect that matters here. If nine out of ten requests find a free worker and the tenth waits, the average stays calm while the 95th percentile swings noticeably. That is why the values at 75 and at 95 percent belong in every before-and-after comparison, together with a note on how many simultaneous requests the measurement ran under. How lab and field measurement complement each other and why both are needed is explained in the article on field data and lab data.

  1. Record the starting state: log the hit rate, used and free memory, number of scripts, restart counters and the values from the FPM status page
  2. Measure the memory footprint of a worker under real load instead of estimating it while idle
  3. Calculate pm.max_children from usable memory and that footprint, and pick the pm mode that fits the load profile
  4. Size OPcache so that oom_restarts and hash_restarts stop rising during operation
  5. Secure the deployment procedure: reset the cache, switch the symlink, account for the realpath cache
  6. Switch on preloading and JIT one at a time and measure each separately -- two changes at once cannot be evaluated
  7. Measure repeatedly under realistic concurrency and compare the values at 75 and 95 percent, not the average

For the improvement to stay, it belongs in ongoing monitoring. A threshold for response time in the delivery pipeline reports regressions before they reach the field -- an addition that fits into existing procedures and is described in the article on performance budgets in CI. Without that safeguard the configuration tends to drift back to default values with the next server migration or the next release.

This is exactly the order we work through in a project. In the performance analysis we first read out the runtime values, separate waiting time, compile time and execution time from one another and state which share is actually available to gain. The interventions then follow in the order of their benefit: cache sizing, deployment invalidation, process model, and only after that preloading and -- if the load profile supports it -- JIT. For WordPress installations and Shopware shops this is a well-rehearsed sequence, because both systems behave similarly in the runtime layer.

Commercially the effort pays off where waiting time translates directly into completed orders. Improving mobile load time by 0.1 seconds lifted retail conversion rates by 8.4 percent on average (Deloitte Ireland 2020), and across all sectors an average of 70.22 percent (Baymard Institute) of carts are abandoned -- waiting time is one of the reasons that can be influenced technically. How load time and revenue relate to each other overall is set out in the article on page speed, conversion rate and revenue.

A server rarely feels evenly slow. It is usually fast enough until the process pool runs out -- and from that point on every further request costs more than the one before it. Anyone measuring only single page loads tends to see that point once customers have already experienced it.

Project experience from server optimization work

The effort for this layer stays manageable because almost all of it is configuration rather than a code rewrite. A properly sized opcode cache, a deployment that resets it reliably, a process model that fits the load profile and a measurement that accounts for concurrency: that covers the largest part before more exotic means even come into question. If response time remains after that, the layers above and below take over -- TTFB optimization at delivery and the database at the query. Which steps are involved and how such a project runs is shown in our overview of performance services.

Sources and Studies

This article is based on data from PHP manual, PHP RFC JIT, web.dev and Deloitte Ireland, complemented by the PHP 8.0 release notes and the Baymard Institute. The figures quoted refer to the state at the time of the respective publication.

Related Articles