
OPcache, PHP-FPM, and PHP Workers: The Server Settings Behind WordPress Speed
PHP workers are your real concurrency limit, OPcache is why WordPress isn't slower, and JIT probably won't help. Here's the layer under the plugins.

Most WordPress performance advice stops at the application layer: fewer plugins, better caching, smaller images. Underneath all of that sits a set of PHP and server settings that determine how many requests your site can process at once, how much work each one repeats unnecessarily, and what happens when more traffic arrives than you have capacity for.
Site owners rarely see this layer. On managed hosting it's configured for you, which is mostly a good thing. But it's worth understanding, because it explains a class of problems that make no sense otherwise: a site that's fast when you test it and falls over at moderate traffic, or a host that offers "more PHP workers" as an upgrade without explaining what they are.
TL;DR
- OPcache stores precompiled PHP bytecode in shared memory so scripts aren't parsed and compiled on every request. It's bundled with PHP since 5.5 and its defaults are frequently too small for WordPress.
- "PHP workers" is hosting-industry terminology, not a PHP concept. It maps to PHP-FPM's
pm.max_children, which php.net describes as setting "the limit on the number of simultaneous requests that will be served."- When all workers are busy, new requests queue in the OS listen backlog. If none frees up before the upstream proxy times out, the visitor gets a 502 or 504, and requests that do get served show inflated TTFB from the time spent waiting.
- Page caching is a worker-count multiplier. A cached request never reaches PHP-FPM, so it consumes no worker. This is why an uncacheable store needs far more worker capacity than a blog at the same traffic level.
- OPcache JIT probably won't help your WordPress site. PHP's own JIT RFC reports WordPress at 326 requests/sec with JIT versus 315 without, roughly 3.5%, while synthetic CPU-bound benchmarks showed 2 to 4x gains.
opcache.validate_timestampsis the setting that most often causes confusion, since disabling it means code changes don't take effect until the cache is explicitly reset.
OPcache: Not Repeating Work
PHP is an interpreted language. Without a bytecode cache, every request reads your PHP files from disk, parses them, and compiles them to bytecode before executing anything. For WordPress, that means core, your theme, and every active plugin, on every single request.
OPcache eliminates the repetition. Per php.net: "OPcache improves PHP performance by storing precompiled script bytecode in shared memory, thereby removing the need for PHP to load and parse scripts on each request." It's been bundled with PHP since 5.5.0.
Practically every serious PHP host has it enabled. If yours doesn't, that's the single largest server-side change available to you.
The Settings That Matter
Defaults from php.net's OPcache configuration documentation:
| Directive | Default | What it controls |
|---|---|---|
opcache.memory_consumption |
128 (MB) | Shared memory for cached bytecode. Minimum enforced value is 8. |
opcache.max_accelerated_files |
10000 | Maximum number of cacheable script keys. Valid range 200 to 1,000,000; actual capacity is clamped to the nearest prime in a fixed set. |
opcache.interned_strings_buffer |
8 (MB) | Memory for interned strings. |
opcache.validate_timestamps |
1 (on) | Whether to check files for changes on a schedule. |
opcache.revalidate_freq |
2 (seconds) | How often timestamps are checked when validation is on. 0 means every request. |
Where WordPress runs into the defaults. A WordPress install with 30 plugins can easily exceed 10,000 PHP files. When max_accelerated_files is hit, OPcache stops caching new files, and the ones that don't fit get parsed on every request. Similarly, a large install can exceed 128MB of bytecode, at which point OPcache starts evicting.
Both failure modes are quiet. Nothing errors; the site just does more work than it should. This is why checking OPcache's actual hit rate and memory usage on a large site is worth doing rather than assuming the defaults fit.
The validate_timestamps Trap
opcache.validate_timestamps defaults to on, meaning OPcache checks whether files have changed every revalidate_freq seconds and recompiles as needed. Turning it off is a genuine performance win, because PHP stops doing filesystem checks entirely.
The catch, straight from php.net: with validation disabled, the cache is only cleared via opcache_reset(), opcache_invalidate(), or a web server restart.
On a WordPress site, that means updating a plugin does not take effect until you explicitly reset OPcache. You'll upload new code and see the old behaviour, and if you don't know about this setting you can lose an afternoon to it. If validation is disabled on your host, your deployment process needs an OPcache reset step. If you're not sure, leave it on.
JIT: Probably Not for You
PHP 8.0 added a JIT compiler to OPcache. It gets a lot of attention, and it's mostly irrelevant to WordPress.
PHP's own JIT RFC, the authoritative source, is candid about this. It frames JIT as most valuable for CPU-intensive, non-web work: it "may open the door for PHP being more frequently used in other, non-Web, CPU-intensive scenarios – where the performance benefits will actually be very substantial."
On WordPress specifically, the RFC states: "it currently doesn't seem to significantly improve real-life apps like WordPress (with opcache.jit=1235 326 req/sec vs 315 req/sec)" — roughly a 3.5% gain. Synthetic CPU-bound benchmarks in the same RFC showed 2 to 4x improvements.
The reason is structural. A WordPress request is dominated by database queries, filesystem access, and template rendering. It's I/O-bound. JIT accelerates computation, and there isn't much raw computation in generating a WordPress page.
A version nuance worth getting right if you write about this. The defaults changed at PHP 8.4.0. Before 8.4, opcache.jit defaulted to "tracing" but opcache.jit_buffer_size defaulted to 0, which disabled JIT in practice. From 8.4, opcache.jit defaults to "disable" while jit_buffer_size defaults to 64M. Net effect either way: JIT is not active in a stock configuration unless you explicitly enable it. Any blanket claim that "JIT is on by default" is wrong for both eras.
PHP-FPM and the Worker Question
This is the layer that determines what happens under load, and the terminology causes real confusion.
What a "PHP Worker" Actually Is
Search php.net for "PHP worker" and you'll find nothing. The term doesn't exist in PHP's documentation. PHP-FPM talks about child processes managed by a pool.
"PHP workers" is hosting-industry marketing terminology. The mapping is straightforward and worth stating precisely: one PHP worker equals one PHP-FPM child process equals one PHP request that can be processed at a time.
When a host says a plan includes four PHP workers, they mean their pool is configured so at most four PHP requests execute concurrently. That's your concurrency ceiling for anything that reaches PHP.
The Settings Behind It
From php.net's PHP-FPM configuration documentation:
pm (mandatory) controls how child processes are managed:
static— a fixed number of processes equal topm.max_children, always running.dynamic— the count fluctuates betweenpm.min_spare_serversandpm.max_spare_servers, capped atpm.max_children.ondemand— processes spawn only when a request arrives, and idle ones can be killed.
pm.max_children (mandatory, no default) is the important one. php.net describes it as "the number of child processes to be created when pm is set to static and the maximum number... when pm is set to dynamic or ondemand" and states directly that it "sets the limit on the number of simultaneous requests that will be served."
Supporting settings for dynamic mode: pm.start_servers (children spawned at startup, defaulting to the average of min and max spare), pm.min_spare_servers, and pm.max_spare_servers.
pm.max_requests (default 0, meaning unlimited) recycles a child process after it handles that many requests. Its documented purpose is mitigating memory leaks in third-party libraries. On a WordPress site with a large plugin set, setting this to a few hundred or thousand is common insurance.
listen.backlog (default -1 on BSD, 511 on Linux) sets the OS-level queue depth for incoming connections once every worker is busy. This is the mechanism that determines whether excess requests wait or get refused outright.
What Exhaustion Looks Like
When all pm.max_children slots are occupied, new requests don't fail immediately. They sit in the listen backlog waiting for a worker to free up.
Two things follow. Requests that eventually get served show inflated TTFB, because their measured time includes queue time. And requests that don't get a worker before the upstream proxy's timeout elapses return a 502 or 504 to the visitor.
PHP-FPM logs a specific warning when this happens: server reached pm.max_children setting (N), consider raising it. That log line is your definitive confirmation, and it's worth knowing the exact string so you can grep for it.
One honest caveat: php.net documents pm.max_children and listen.backlog individually, but it doesn't spell out this queue-to-timeout-to-502 narrative in prose. The explanation follows directly from the documented mechanics, but treat it as reasoning from documentation rather than a quoted passage.
The symptom pattern is distinctive and worth recognising: a site that's perfectly fast when you test it alone, and that starts returning gateway errors at a certain traffic level rather than degrading gradually. That's not a slow site, it's a site out of workers.
Sizing Workers
The constraint is memory. Each PHP-FPM child holds its own memory. Multiply your average per-process memory by pm.max_children and that's your worst-case PHP memory footprint, which has to fit alongside MySQL, your web server, and the OS.
Setting pm.max_children too high doesn't buy you capacity, it buys you swapping, which is worse than queueing. Setting it too low leaves capacity unused.
This is genuinely one of the better arguments for managed hosting: it's a tuning exercise that depends on your specific application's memory profile and your server's resources, and getting it wrong in either direction has real costs.
Caching Is a Worker Multiplier
Here's the connection that makes all of this matter for WordPress specifically.
A request served from a full-page cache, whether by the web server, a reverse proxy, or an edge network, never reaches PHP-FPM at all. No worker is consumed. The request is answered before PHP is involved.
So a site with four workers and a 90% cache hit rate has effective capacity roughly ten times what its worker count suggests. The same site with a 0% hit rate is capped at four concurrent requests, full stop.
This explains several things that otherwise look inconsistent:
Why blogs scale on modest hosting. Almost everything is cacheable, so almost nothing touches PHP.
Why WooCommerce stores need far more capacity at the same traffic. Cart, checkout, and account pages are uncacheable by design, so every shopper in the funnel occupies a worker for the duration of their request.
Why logged-in traffic is expensive. Membership sites, forums, and any site where visitors log in bypass the page cache and consume workers.
Why a traffic spike to one uncacheable page can take down a site that handles far more total traffic comfortably.
Infrastructure that auto-scales, as MagicWP's managed hosting does, adds capacity when the uncacheable load rises rather than queueing behind a fixed worker count. But the underlying relationship holds regardless of host: your effective capacity is workers divided by cache miss rate.
One More Setting: realpath_cache
Worth mentioning because it's frequently tuned alongside OPcache and is a different thing entirely.
realpath_cache_size is a core PHP setting, not an OPcache one. Per php.net it defaults to "4M" and "determines the size of the realpath cache to be used by PHP. This value should be increased on systems where PHP opens many files." realpath_cache_ttl defaults to 120 seconds.
WordPress opens a lot of files. Core, theme, and every plugin means hundreds of include and require calls per request, each requiring path resolution. A larger realpath cache reduces that filesystem work.
One documented caveat with real consequences: "Using open_basedir will disable the realpath cache." Hosts that sandbox WordPress installs with open_basedir silently negate this optimisation entirely, which is worth knowing before you spend time tuning a setting that isn't in effect.
What to Actually Check
- Confirm OPcache is enabled, via Tools → Site Health → Info, or
php -i | grep opcache. - Check whether you're hitting the file limit. If your install has more PHP files than
max_accelerated_files, some are being parsed on every request. - Check OPcache memory usage against
memory_consumption. Evictions mean the cache is too small. - Find out whether
validate_timestampsis on. If off, your deployment process needs an OPcache reset, or plugin updates won't take effect. - Grep your PHP-FPM logs for
max_children. That warning is the clearest possible signal you're out of capacity. - Know your cache hit rate. It's the multiplier on everything else here.
- Don't enable JIT expecting a WordPress win. PHP's own data says roughly 3.5%.
On managed hosting, most of this is already handled, and the useful version of this knowledge is diagnostic rather than operational: knowing that intermittent 502s under load means worker exhaustion rather than a slow site tells you what to ask your host for.
Frequently Asked Questions
What are PHP workers and how many do I need?
"PHP worker" is hosting terminology for a PHP-FPM child process, which php.net documents as governed by pm.max_children, "the limit on the number of simultaneous requests that will be served." How many you need depends on your cache hit rate, since cached requests consume none. A well-cached blog needs few; an uncacheable store needs many.
What happens when a site runs out of PHP workers?
New requests queue in the OS listen backlog waiting for a free worker. Requests that get served show inflated TTFB from the wait; those that don't get one before the upstream timeout return a 502 or 504. PHP-FPM logs server reached pm.max_children setting (N), consider raising it.
Does OPcache actually make WordPress faster? Substantially. Without it, PHP parses and compiles core, your theme, and every plugin on every request. OPcache stores precompiled bytecode in shared memory so that work happens once. It's bundled with PHP since 5.5 and enabled on most quality hosting.
Should I enable OPcache JIT for WordPress? Probably not. PHP's own JIT RFC reports WordPress at 326 requests per second with JIT versus 315 without, roughly 3.5%, because WordPress requests are I/O-bound rather than CPU-bound. JIT's large gains show up in computation-heavy workloads, not typical web applications.
Why don't my plugin updates take effect after uploading new code?
Likely opcache.validate_timestamps is disabled on your server. php.net documents that with validation off, the cache only clears via opcache_reset(), opcache_invalidate(), or a web server restart. Your deployment process needs an explicit reset step.
Does caching reduce PHP worker usage? Yes, and this is the most important relationship here. A request served from a full-page cache never reaches PHP-FPM, so it consumes no worker. Your effective capacity is your worker count divided by your cache miss rate.
Why does my site work fine in testing but return 502 errors under real traffic?
That pattern points to worker exhaustion rather than a slow site. Testing alone never fills the pool; real concurrent traffic does. Check PHP-FPM logs for the max_children warning.
What's the difference between OPcache and object caching? OPcache caches compiled PHP bytecode, so PHP doesn't recompile your code. Object caching (Redis or Memcached) caches the results of database queries, so WordPress doesn't re-query. They operate on completely different things and both are worth having.
Conclusion
The server layer under WordPress explains a category of problems that application-level optimisation can't touch. OPcache is why your site isn't several times slower than it is, and its defaults may not fit a large install. PHP workers are your real concurrency ceiling, and the interaction between that ceiling and your cache hit rate determines whether you scale comfortably or fall over at a specific traffic level.
If you take one thing: your effective capacity is your worker count divided by your cache miss rate. That single relationship explains why blogs scale on cheap hosting, why stores need more, why logged-in traffic is expensive, and why a spike to one uncacheable page can take down a site that handles ten times the traffic elsewhere.
And skip JIT. PHP's own benchmark says 3.5% on WordPress, and there are better places to spend the effort.
Get the best of MagicWP in your inbox.
Monthly engineering notes, product updates, and WordPress performance tips. No spam, unsubscribe anytime.

