TL;DR
- Every PHP request either reads precompiled opcodes from a shared memory buffer (OPcache) - or compiles the files from scratch, on that request, every time. The buffer has three separate hard ceilings (memory, file slots, interned strings), and when any of them is hit, OPcache doesn’t evict old entries to make room. It just silently stops caching.
- On the default settings my test server shipped with (128 MB / 1,000 file slots / 8 MB strings), a bare WooCommerce store already exceeds two of the three ceilings - before you install a single plugin.
- Who actually gets bitten: after the first draft, I started crowd-checking real Site Health readouts from other people’s stores. Decent shared and managed hosts mostly have this handled - generous buffers, cache not full, in most cases. The tight configs concentrate where you own the stack: self-hosted VPSes and the provisioning panels on top of them (GridPane, RunCloud, SpinupWP & co.), where the buffer is whatever the stack shipped - or whatever you set - and nobody upstream is watching it.
- With six popular plugins (Yoast, Elementor, Wordfence, Mailchimp, Polylang, Smush), the store needs 2.6× more file slots than the default allows. The result: ~1,200 full PHP file compilations on every front-end page view, including the homepage. Forever.
- The damage, measured: TTFB 1.4–1.9× worse on every page, −29% throughput under load, CPU at 150% of capacity (run queue), and per-request worker memory ×2.1 - from 24 MB to 50 MB median. RAM does not grow on trees; compilation happens inside your worker’s request memory.
- Two identical stores sharing one PHP-FPM pool: the one whose traffic arrives first after a restart runs at ~210 ms; the other gets ~415 ms - a 2× penalty decided purely by request order. I found 1,637 of one site’s compiled files sitting in the other site’s buffer.
- Through all of this, OPcache’s “hit rate” metric stayed above 99% in the early failure stages, zero restart counters fired, and nothing appeared in any panel. This failure mode is completely silent.
- Worse: it contaminates every other benchmark you run on the store. The Redis test that “barely helped”, the plugin A/B, the host comparison - all of them measured on top of a machine that was busy compiling itself. Verify the buffer first, or you don’t know what you’re measuring.
- Everyone in WordPress performance talks about page cache - nginx, WP Rocket, LiteSpeed, Varnish. Almost nobody talks about the cache that compiles the code which renders every uncached page, every cart, every checkout, every admin screen, every REST call and every webhook.
- ~4,500 measured requests, three measurement sessions, sequential isolated curl + k6 load tests, every number in this article reproducible from raw CSVs. Methodology at the bottom.
Table of Contents
This article started with a conversation at WordCamp Europe
At WCEU I ran a workshop on WooCommerce performance, and one fragment of it - the part about OPcache ceilings - sparked a conversation afterwards with Ivan Zahariev, CTO of ICDSoft. That part resonated with him from the platform side: he was already thinking about surfacing exactly this kind of alerting for customers on their infrastructure, because the failure mode is so quiet. We talked through how it plays out at hosting scale - customers get a container with a fixed slice of RAM, inside it lives an OPcache buffer of a fixed size, often shared between several sites; pile a typical plugin stack onto a couple of WooCommerce stores in there and the buffer overflows, and nothing visibly breaks. The sites just get… slower. Heavier on CPU. Hungrier for RAM.
The bigger benchmarks in this article germinated from that discussion. I left with a list of things neither of us had hard numbers for - and a strong urge to get them.
That matched something I keep seeing in my own WooCommerce performance work: stores where every page cache layer is configured beautifully - and the uncached requests, the ones that actually matter for a shop (cart, checkout, my-account, admin, REST), are mysteriously 2× slower than the hardware says they should be.
So I did what I did with the EmDash benchmark: stopped speculating and built a lab. One WooCommerce store on a dedicated 4-vCPU Hetzner box, PHP 8.5, Redis object cache always on, full control of php.ini, telemetry on every request (a small MU-plugin that logs TTFB, per-worker CPU, peak memory, and the complete OPcache state to CSV - it’s in my shift64-opcache-tools repo). Then I measured everything: cold starts, steady states, plugin-by-plugin buffer growth, load tests, shared-pool races, and simulated plugin updates under traffic.
What follows is, as far as I know, the first public measurement of what actually happens to a WooCommerce store when the OPcache buffer overflows.
What OPcache actually is, in one minute
PHP is a compiled language that pretends not to be. Every time PHP executes a file, it first compiles it to opcodes. Without OPcache, that compilation happens on every single request, for every single file - WordPress core, WooCommerce, every plugin, every template.
OPcache fixes this by storing compiled opcodes in a shared memory segment that all PHP-FPM workers read from. First request compiles and stores; every subsequent request reuses. It’s the single biggest free performance win in PHP, it ships enabled by default, and that is precisely why nobody thinks about it.
Here is what almost nobody knows. That shared memory segment has three independent hard ceilings:
opcache.memory_consumption- the total size of the buffer in MB.opcache.max_accelerated_files- how many files can be cached. This is a hash table sized to the next prime number above your setting. Set1000, and PHP silently gives you 1,979 slots.opcache.interned_strings_buffer- a sub-buffer for deduplicated strings (class names, docblocks, literals) shared across all workers.
And one brutal design decision that drives everything in this article: OPcache has no eviction policy. It is not an LRU. When the buffer is full - by bytes or by file count - new files are simply not cached. They get compiled on the request that needed them, the compilation result is thrown away, and the next request compiles them again. The cache doesn’t restart itself, doesn’t log anything, doesn’t increment any error counter. It just quietly becomes a machine for burning CPU.
Whoever gets into the buffer first after a restart, stays. Everyone else compiles forever. Remember that sentence - it’s the key to the weirdest results below.
The defaults I found in the wild
My test server is a standard managed-stack provisioning on Hetzner. The PHP 8.5 FPM php.ini it came with:
opcache.memory_consumption = 128 ; MB
opcache.max_accelerated_files = 1000 ; → silently rounded up to 1,979
opcache.interned_strings_buffer = 8 ; MB
opcache.validate_timestamps = 1
opcache.revalidate_freq = 60
opcache.fast_shutdown = 1 ; dead directive - removed in PHP 7.2
An important calibration before we go further, because I don’t want this to read as a gotcha aimed at one stack. max_accelerated_files=1000 is the low end of what you’ll meet in the wild. PHP’s own default, when the directive is simply unset, is 10,000, and plenty of stacks ship that. I kept 1,000 as the benchmark baseline for two honest reasons: it’s what this server actually came with, and it makes the failure mode visible quickly and cleanly - you can reproduce every number in this article in an afternoon.
So where do you actually meet configs like this one? After the first draft of this article, I started crowd-checking: asking store owners to paste the OPcache lines WordPress shows under Tools → Site Health → Info → Server (no plugin, no shell access needed), plus their host and plan. The picture that came back reshaped who this article is really for. The mainstream shared and managed hosts mostly do this well. Generous buffers, Is the Opcode cache full? → No in most submissions. It makes sense once you think about it: one platform team tunes the buffer for thousands of customers, and when it’s wrong, their support queue tells them - which is exactly the instinct Ivan had at WCEU, thinking about alerting on this from the platform side. Someone whose job it is watches the buffer. (Some accounts show no OPcache lines in Site Health at all - which does not mean OPcache is off; hosts can block the status API while the cache runs fine. More on that in the how-to-check section.)
The tight configs live somewhere else: in the self-hosted world. A VPS you provisioned yourself, or - more commonly - a VPS managed by a provisioning panel: GridPane, RunCloud, SpinupWP, Ploi, CyberPanel and friends. That’s not a dig at any panel; it’s structural. On these stacks you hold php.ini (or the panel’s PHP settings screen), the shipped value is whatever the stack’s authors picked as a safe baseline for a generic site, and the person responsible for noticing that a WooCommerce working set outgrew it is - nobody. My test server is exactly this case: a standard panel provisioning on a Hetzner box. If you run WooCommerce on this kind of stack, or you’re about to move to one for performance reasons, the defaults above are the defaults in question - and checking what OPcache config your panel ships (it’s in their docs; if it isn’t, ask before you sign up) belongs on the pre-purchase checklist, right next to PHP versions and backups.
But do not read “my host ships 10,000” as “not my problem” - this is the sentence to keep for the rest of the article. The ceilings belong to the FPM pool, not to your site. If your production store shares a pool with a staging copy, a dev copy, or a couple of other sites - the single most common layout in shared and container hosting - the working sets add up against one buffer. The measurements below put hard numbers on that: one plugin-equipped store needs ~5,200 key slots and ~100 MB of opcodes. Two copies of it (production + staging, say) blow through a 128 MB buffer outright. And the 8 MB interned-strings default falls to a single store. The 1,000-slot config isn’t an exotic worst case - it’s a preview of what any pool becomes the day its combined working set outgrows whichever ceiling binds first.
Now let’s see what those numbers mean for an actual store.
A bare WooCommerce store already doesn’t fit
I started with a deliberately minimal setup: WordPress + WooCommerce + Query Monitor + Redis object cache. No page builder, no SEO plugin, nothing. The kind of store that exists only in benchmarks.
After one full walk through the shop (home, category, products, cart, checkout, my-account) plus the admin (dashboard, orders, products, settings):
| Ceiling | Default | A bare Woo store needs | Verdict |
|---|---|---|---|
| File slots | 1,979 | 2,038 | exceeded |
| Interned strings | 8 MB | > 8 MB (0 bytes free) | exceeded |
| Memory | 128 MB | ~67 MB | fits (the only one) |
Read that again: an empty WooCommerce store overflows two of the three ceilings on contact. The file inventory tells you why - WordPress core ships 1,285 PHP files and WooCommerce another 3,965. A 1,979-slot table cannot hold the inventory of one bare shop, full stop.
And the practical consequence showed up immediately in the request logs: with the front of the store warmed up, every wp-admin page view was recompiling 49–66 files, on every single request, forever - because the admin’s files arrived after the buffer was full, and there is no eviction. The store owner clicking through orders pays a compile tax on every click and will never, ever know.
Meanwhile OPcache’s headline metric - the one thing a monitoring plugin might show you - read 99.9% hit rate. The misses are concentrated in the pages you visit least and care about most. Hit rate is a vanity metric.
What each plugin actually costs
Then I made the store realistic. I installed six of the most popular plugins in the Woo ecosystem, one at a time, on a deliberately enlarged buffer (256 MB / 16,229 slots / 32 MB strings) so I could measure each plugin’s true footprint without ceilings distorting the numbers. After each install: full front walk, full cart-and-checkout flow, full admin walk, then a snapshot of the buffer.
| Stack state | Files cached | Δ files | Buffer used* | Δ MB | Key slots used |
|---|---|---|---|---|---|
| Bare WP + WooCommerce | 1,979 | - | 90.5 MB | - | 2,038 |
| + Yoast SEO | 2,854 | +875 | 101.5 MB | +10.9 | 3,775 |
| + Elementor | 3,599 | +745 | 115.9 MB | +14.5 | 4,619 |
| + Wordfence | 3,737 | +138 | 124.0 MB | +8.1 | 4,771 |
| + Mailchimp for WooCommerce | 3,771 | +34 | 125.3 MB | +1.3 | 4,807 |
| + Polylang | 3,897 | +126 | 127.1 MB | +1.8 | 5,019 |
| + Smush | 4,046 | +149 | 130.6 MB | +3.6 | 5,168 |
* includes the 32 MB interned-strings region; pure opcode payload at the end ≈ 98.6 MB.
opcache.max_accelerated_files=1000, silently rounded up to the next prime (1,979). The bare store crosses the default limit before the first plugin is installed.
Useful trivia from this table: Yoast alone is +875 files. Elementor is +14.5 MB of opcodes. Mailchimp, which lazy-loads aggressively, costs almost nothing until its admin screens are touched. And the grand total for a moderately equipped store - this is six plugins, not the forty-seven of WordPress folklore - is 5,168 key slots against a default of 1,979, and ~21.5 MB of interned strings against a default of 8 MB.
The full stack needs 2.6× more file slots and 2.7× more string space than the defaults provide. Only the megabyte ceiling - the only one anyone ever talks about - still fits.
The silent disaster: what overflow actually does
Now the main event. Same store, same six plugins, same server. I flipped the configuration back to the shipped defaults and warmed the cache by browsing only the front of the store - home, category, two products - the way real traffic would.
After 160 warm-up requests, the OPcache statistics read:
- Cache full: yes (1,979 / 1,979 slots)
- Misses: 192,707
192,707 misses from 160 requests is ~1,200 compilations per page view. The front-end working set of this store - roughly 3,000 files once plugins hook into the render path - simply does not fit in 1,979 slots. So every request compiles whatever didn’t make it in. Not once. Every time. The homepage of this store has never been fully cached in its life.
Under continued mixed traffic the hit rate - 99.95% on the bare store - collapsed to 57.9%. And here’s the part that should bother you: zero restarts fired, zero errors were logged, zero counters of any kind moved. A full buffer doesn’t restart itself; it just stops caching. There is no alarm. This is a failure mode with no failure signal.
The per-page cost, measured properly (medians; n=29 healthy / n=19 defaults; sequential curl from the server itself, CDN bypassed):
Median TTFB. WP + WooCommerce 10.8 + six popular plugins, PHP 8.5, Hetzner 4 vCPU dedicated. The penalty lands hardest exactly where it costs money: checkout 1.8×, admin 1.9×.
Checkout - the page that pays for everything - is 1.8× slower. The admin your client lives in all day is 1.9× slower. And this is the gentle version of the numbers: a fast dedicated AMD machine, one request at a time. Compilation is pure CPU work, so the moment requests start competing for cores, it gets worse. We’ll get to concurrency in a moment - but first, the consequence that quietly poisons every other performance decision you make.
While you’re testing Redis, OPcache may be wrecking the whole experiment
Stop and think about what a thrashing buffer does to every other benchmark you run on that store.
You install Redis object cache and conclude it “barely helped” - because your TTFB isn’t dominated by database queries, it’s dominated by 1,200 compilations per request, and no object cache touches that. You A/B test two plugins to pick the faster one - but what you actually measured is which set of files happened to win the buffer race during each test run. You deactivate a plugin to “measure its impact” and the numbers move - partly because the deactivation itself invalidated files and reshuffled the buffer. You migrate to a new host and it “feels faster” for a week - same store, the pool just hasn’t filled up yet. You compare hosting providers for a client and credit the hardware - when half the gap is one php.ini line neither host showed you.
Every one of those conclusions is built on sand. OPcache overflow isn’t just a performance problem - it’s a confounder underneath all your other performance work, silently rewriting the results of every test you run on top of it. This is why the very first step of any WooCommerce performance audit - before Redis, before plugin triage, before host comparisons - has to be confirming two numbers: cache_full: no, and a misses counter that stays frozen on a warm site. Until those two check out, you don’t actually know what you’re measuring.
Now, the load test.
Under load: RAM does not grow on trees
I ran the identical k6 scenario - 10 virtual users walking the shop path for 2.5 minutes - against both configurations. Same store, same hardware, same traffic. Only the buffer settings differ.
| Metric (k6, 10 VU, 2.5 min) | Healthy buffer | Shipped defaults | Delta |
|---|---|---|---|
| Throughput | 17.5 req/s | 12.3 req/s | −29% |
| Median response | 357 ms | 520 ms | +46% |
| p95 | 612 ms | 831 ms | +36% |
| Server CPU (median / p95) | 89% / 103% | 99.8% / 150% | saturated + queued |
| Worker peak RAM per request (median) | 24 MB | 50 MB | ×2.1 |
| Recompilations during the test | 0 | +2.3 million | ~15,400/s |
| OPcache restarts / errors / alarms | 0 | 0 | silence |
Three things in that table deserve to be said out loud.
Throughput dropped 29% with no change to the application. If you’re capacity-planning a store - how many concurrent shoppers can this box take on Black Friday - a broken OPcache config means you bought a third more server than you needed, or you’ll fall over a third earlier than you planned.
CPU went past saturation into the run queue (load at 150% of cores). Compilation isn’t free; 15,400 file compilations per second is a full-time job for a CPU core or two. Your “render” time is now competing with a compiler for hardware.
And my favorite: median per-request worker memory doubled, from 24 MB to 50 MB. Compilation happens inside the worker’s request memory before the result is (not) stored. This is the suspicion from that WCEU conversation, confirmed end to end: an overflowing OPcache makes your stack need more CPU and more RAM to do the same work, slower. When a hosting plan sells you “unlimited PHP workers” while quietly capping the one buffer all workers share - the physics has to give somewhere. A buffer is not a marketing line item. It’s physical RAM that has to be allocated, and if it isn’t, your workers pay for it on every request.
The shared pool: your TTFB depends on who woke up first
Here’s where it gets properly weird. On many hosting setups - including the very common one-FPM-master-per-PHP-version layout - multiple sites share a single OPcache segment. All pools under one PHP-FPM master read and write the same buffer.
I had two additional, identical, bare Woo stores on this server sharing one PHP 8.4 master. Before I touched anything, I queried the OPcache status through one site’s FPM socket and found 1,637 compiled files belonging to the other site sitting in the shared buffer - which was already full at 1,979/1,979 slots. Two empty stores. Zero plugins. Buffer: full.
Since there’s no eviction, sharing a full buffer turns into a race: after every restart of the master (deploys, PHP reloads, memory pressure - it happens more often than you think), whichever site’s traffic arrives first claims the slots. The other site compiles, on every request, until the next restart.
I measured the race in both directions:
Two identical bare WooCommerce stores under one PHP-FPM 8.4 master (one shared OPcache, shipped defaults). Median homepage TTFB, n=20 per cell. Same store, same config: 210 ms or 420 ms depending purely on who got traffic first.
The same store, on the same server, with the same configuration, is either a 210 ms store or a 420 ms store - determined by the order in which requests happened to arrive after the last restart. This is the “my site is sometimes slow and nobody knows why, restarting PHP helps for a while” mystery, measured. Your store’s performance is a lottery ticket, and the draw happens every time the pool restarts.
Who actually shares your buffer depends on the layout - and it’s worth being precise here. On the most common setups (one container per customer, or one FPM master per PHP version), the “neighbor” is you: your staging copy, your dev copy, your second shop, every site your panel runs under that PHP version. On well-isolated shared hosting (CloudLinux-style, one account = one PHP runtime), a stranger’s sites generally can’t reach your buffer. True stranger-sharing does still exist - legacy mod_php setups and shared FPM pools across customers - and the fact that opcache.validate_permission exists at all tells you the PHP team has met that layout in the wild. But the property that matters is the same in every variant: from inside a single WordPress site, the buffer’s other tenants are invisible. No plugin, no dashboard, no hosting panel will show you what else is in there - the measurement above required root and a socket-level query to see it.
Plugin updates are landmines (the restart storm, dissected)
There’s one more mechanism in the failure chain, and measuring it overturned what I thought I knew from the documentation.
When a plugin updates, its files change on disk. OPcache notices (with validate_timestamps on, within revalidate_freq seconds) and marks the old compiled entries as wasted memory - dead weight that can’t be reused. When wasted memory crosses max_wasted_percentage (default 5%) and the cache needs space it can’t get, OPcache schedules a full restart: the entire buffer is dumped and every site in the pool recompiles everything, simultaneously, under live traffic.
I simulated updates under constant traffic (a request every ~0.6 s, with telemetry on each) in three configurations, and each run taught me something the docs don’t say:
- One plugin updated, defaults: wasted memory climbed to 4.0% - just under the 5% threshold. No restart. The takeaway is nasty: invalidations accumulate silently across updates. Each “small” update arms the mine a little more.
- Full update batch (all plugins), defaults: the hash restart fired ~60 seconds after the file touches - exactly
revalidate_freq. On a buffer that was already thrashing, the restart was almost invisible in response times: when you recompile 1,200 files on every request anyway, dumping the cache barely registers. The disease masks its own symptom. - Full update batch, healthy 256 MB buffer: this is the run worth seeing. The whole update was digested in one slow request - a single 1.29-second hiccup as the invalidation wave hit, then straight back to ~260 ms:
One request every ~0.6 s against the homepage, healthy 256 MB buffer. All plugin files touched (simulated update batch) at t=38 s. One 1,293 ms hiccup ~10 s later as files revalidate, then back to baseline. Wasted memory jumped to 19.8% - and stayed there, armed, waiting for the buffer to fill.
But note what the chart doesn’t show: wasted memory jumped to 19.8% - nearly four times the restart threshold - and no restart happened, because the oversized buffer still had room. The threshold doesn’t trigger anything by itself; it only matters at the moment the cache is also out of space. Which means the truly dangerous combination is exactly what shipped defaults produce: a buffer that is always full, plus quietly accumulating wasted memory, plus a nightly auto-update batch. That’s a full cache dump under morning traffic - and on a shared pool, it hits every site at once, and the post-restart lottery (see above) re-runs with new winners.
The morning cold start, and a trap for benchmarkers
One more measured distribution, because “the first visitor of the day” is a real scenario for most stores. Twenty full PHP-FPM restarts per configuration, three requests measured after each:
Shipped defaults + six plugins. On the healthy config, request #2 drops to 286 ms - on defaults it stays at 420 ms. The thrashing penalty starts from the second request and never leaves.
Here’s the trap, and it’s a beauty. On the healthy config, the cold first request was actually slightly slower (median 1,522 ms vs 1,388 ms) - because it does the honest work of writing all ~3,000 compiled files into shared memory. The broken config hits its 1,979-slot wall partway through, stops writing, and “saves” that effort - then bills you for it on every request until the end of time (request #2: 420 ms vs 286 ms, +47%, permanently).
Think about what that means for how this stack gets benchmarked in the wild: anyone who tests a config change by restarting PHP and measuring the first page load - which is exactly how quick hosting comparisons get done - will conclude the broken configuration is faster. The only way to see the truth is to measure from the second request onward. Single-shot TTFB tests cannot detect this entire class of failure.
The cliff is binary - and the directive that matters is the one nobody tunes
Final experiment: where exactly is the edge? I swept both main directives independently, full plugin stack, ten measured walks per point.
Sweeping max_accelerated_files with memory fixed at 256 MB:
| File slots | Homepage | Checkout (full cart) | Recompiles per request |
|---|---|---|---|
| 1,000 (→1,979) | 348 ms | 242 ms | 1,149 |
| 4,000 | 242 ms | 125 ms | 0 |
| 10,000 | 243 ms | 122 ms | 0 |
| 16,229 | 278 ms | 145 ms | 0 |
Sweeping memory_consumption with file slots fixed at 16,229:
| Buffer size | Homepage | Checkout (full cart) | Recompiles per request |
|---|---|---|---|
| 256 MB | 278 ms | 145 ms | 0 |
| 192 MB | 261 ms | 142 ms | 0 |
| 128 MB | 255 ms | 144 ms | 0 |
| 96 MB | 322 ms | 224 ms | 840 |
| 64 MB | 523 ms | 398 ms | 2,014 |
There is no gradual degradation. The cliff is binary: the working set either fits (zero recompiles, flat performance) or it doesn’t (hundreds to thousands of recompiles per request, TTFB ×1.5–3). And notice which axis failed first for this store: at the default settings, the megabyte ceiling was never the problem - 128 MB would have been enough. The killer was max_accelerated_files=1000, the directive that measures count, not size, the one that every tuning guide skips because everyone reasons about cache size in megabytes.
A 2026 WooCommerce store is not big in megabytes. It’s big in files - thousands of small autoloaded classes. The mental model of “is my cache big enough” is measuring the wrong dimension.
And if your stack ships the friendlier 10,000-slot value: your first binding ceiling simply moves. With more than one site (or a staging/dev copy) in the pool, it’s the 128 MB memory line and the 8 MB strings buffer that go first - and the memory sweep above shows exactly what crossing them costs.
How to check your own store (five minutes, no plugin required)
Everything below works on any host where you can run PHP. One calibration first, because it tripped me up during the crowd-check: not being able to see OPcache stats does not mean OPcache is off. A host can block the telemetry while the cache works fine - either by listing opcache_get_status in disable_functions, or via opcache.restrict_api, which makes the status API return false for any script outside a path the host controls. That’s why Site Health on some shared accounts shows no Opcode cache lines at all: the section simply disappears when the function isn’t callable, and support may not even know why. On a well-run shared platform, blocked telemetry is a livable trade - someone on their side is (hopefully) watching the buffer for you. On a VPS you provisioned yourself there’s no reason to fly blind: you own the config, so unblock it and look.
Drop this anywhere you can execute PHP as the site (a WP-CLI eval won’t work - CLI has its own separate OPcache; this must run through FPM, e.g. as an MU-plugin):
$s = opcache_get_status(false);
printf(
"full: %s | scripts: %d | keys: %d/%d | mem: %.0f/%.0f MB | strings free: %.1f MB | hits: %d | misses: %d | oom/hash restarts: %d/%d\n",
$s['cache_full'] ? 'YES' : 'no',
$s['opcache_statistics']['num_cached_scripts'],
$s['opcache_statistics']['num_cached_keys'],
$s['opcache_statistics']['max_cached_keys'],
$s['memory_usage']['used_memory'] / 1048576,
($s['memory_usage']['used_memory'] + $s['memory_usage']['free_memory']) / 1048576,
$s['interned_strings_usage']['free_memory'] / 1048576,
$s['opcache_statistics']['hits'],
$s['opcache_statistics']['misses'],
$s['opcache_statistics']['oom_restarts'],
$s['opcache_statistics']['hash_restarts']
);
Or, with shell access, cachetool against your FPM socket:
cachetool opcache:status --fcgi=/var/run/php/php8.3-fpm.sock
What you’re looking for - in order of severity:
cache_full: true- you are in the failure mode this article measured. Right now.num_cached_keysnearmax_cached_keys- you’re one plugin update away from it.- Interned strings free ≈ 0 - string overflow; new strings now duplicate into every worker’s private memory.
- Misses growing while the site is warm - don’t trust hit rate (vanity metric, see above); watch whether the misses counter keeps climbing on a site that’s been running for hours. On a healthy buffer it freezes.
oom_restarts/hash_restarts> 0 - your buffer has been dumping itself under traffic.
And what to set, as a starting point for a real WooCommerce store:
opcache.memory_consumption=256 ; 512 if the pool hosts several sites
opcache.max_accelerated_files=16229 ; count, not megabytes, is what kills you
opcache.interned_strings_buffer=32
Why oversize instead of right-size? Three measured reasons. Your working set only grows - every update adds files. Updates create duplicate entries (old wasted + new live) - my single update batch parked 50 MB of wasted memory in one minute. And if your pool hosts more than one site, you’re provisioning for the sum of the working sets, plus the race politics described above. Headroom in this buffer costs you a few hundred megabytes of RAM. The lack of it, as measured here, costs 29% of your throughput and a doubled per-request memory bill - which is more RAM than you “saved.”
That’s the real arithmetic behind this whole article: the OPcache buffer is not an optional optimization to sprinkle on top. It’s a fixed, physical slice of server RAM that your stack’s working set either fits into or doesn’t. A host advertising unlimited-everything while shipping a 1,979-slot table made that allocation decision for you, in 2014, and never told you.
Caveats I want to head off
- “My host ships 10,000 slots, so this doesn’t apply to me.” A single store does fit at 10,000 - my own sweep confirms it (zero recompiles from 4,000 slots up). But the buffer belongs to the pool, not the site. Production + staging + dev of the same store is 3× the working set; a couple of neighboring sites is more; and they all share one 128 MB / 8 MB allocation that a single equipped store already strains (98.6 MB of opcodes, 21.5 MB of strings against an 8 MB default). The 1,000-slot baseline I measured is simply that end state arriving early. Check the live numbers on your own pool - that’s the point of the article. If they’re green, genuinely: good config.
- “So shared hosting is the villain here.” The crowd-checked data says the opposite: mainstream shared and managed hosts mostly ship generous, well-run OPcache configs - the segment everyone loves to blame is the one that mostly has this handled. The tight buffers concentrate on self-provisioned stacks, VPSes and panels, where the tuning is yours. Which is arguably worse news, not better: the people who moved to a VPS + panel for performance are exactly the ones most likely to be paying the compile tax without knowing.
- “Page cache makes this irrelevant.” Page cache serves anonymous GETs of cacheable pages. A WooCommerce store’s revenue path - cart, checkout, account, payment webhooks, REST, admin - is uncacheable by definition. Those requests are exactly the ones paying the compile tax. Page cache hides this problem precisely where it doesn’t matter.
- “My hit rate is 99%, so I’m fine.” That was this store’s hit rate while its admin recompiled 66 files per click. The cumulative hits counter dwarfs everything once a site has been up for weeks. Watch
cache_full, keys vs max, and whether misses still climb on a warm site. - “Just restart PHP nightly.” That re-runs the lottery (and is precisely the “it’s mysteriously fast again for a while” folk remedy). The working set still doesn’t fit; you’ve just chosen a different subset of your store to be slow until tomorrow.
- The 4-vCPU box is small. Deliberately - it matches the class of machine most stores actually run on. More cores absorb more compilation before saturating, but the per-request latency tax and the ×2 worker memory are per-request physics, not capacity effects.
- Sequential numbers are medians of n=19–29; cold starts n=20; counters (misses, files, MB) are exact reads, not samples. The thrash state reproduced to the exact miss count (192,707) across independent runs - this failure mode is deterministic, not noise.
Conclusions
- OPcache has three ceilings, no eviction, and no alarm. Overflow is silent, permanent until restart, and invisible to every dashboard I’ve seen.
- Conservative defaults don’t fit a bare WooCommerce store - by file count and string space, before the first plugin. And crowd-checked Site Health data says where those defaults actually run in production: not on mainstream shared hosting, which mostly provisions past this, but on self-hosted VPSes and provisioning panels - the stacks where the tuning is yours and nobody upstream will ever fix it for you.
- A realistically-equipped store on those defaults recompiles ~1,200 files per page view - homepage included - costing 1.4–1.9× TTFB sequentially and −29% throughput, 150% CPU, ×2.1 worker RAM under load.
- Shared pools turn this into a lottery between sites: 2× TTFB decided by traffic order after a restart. And the sites sharing your buffer are usually your own - staging, dev, the second shop - invisible tenants no panel will ever show you.
- Plugin updates arm a restart mine (wasted memory accumulates across updates; the threshold only fires when the buffer is also full - which, on defaults, is always).
- The cliff is binary and the killer directive is
max_accelerated_files- the one measured in files, not megabytes, that no tuning guide mentions. - An overflowing buffer is a confounder under every other test you run - the Redis evaluation, the plugin A/B, the host comparison. Verify the buffer before you benchmark anything else, or you don’t know what you measured.
- Five minutes with
opcache_get_status()tells you which side of the cliff you’re on. It’s the highest-leverage five minutes available in WooCommerce performance right now, and nobody is spending them.
Everyone keeps optimizing the cache that serves pages to people who aren’t logged in and aren’t buying anything. Meanwhile the cache that compiles your checkout was sized in an era when WordPress was a blog engine, and it has been quietly failing under every plugin-heavy store ever since.
Check your buffer - especially if the box it lives on is one you (or your panel) provisioned. The numbers in this article say there’s a decent chance it’s full right now.
Measurement setup: WordPress 7.0 + WooCommerce 10.8 on PHP 8.5 (FPM), dedicated Hetzner 4-vCPU box, Redis object cache enabled throughout. ~4,500 measured requests across three sessions: sequential isolated curl from the server (CDN bypassed) for latency medians, k6 (10 VU) for load tests, per-request telemetry (TTFB, worker CPU via getrusage, peak memory, full OPcache state) logged to CSV by an MU-plugin available in shift64-opcache-tools, buffer state read via cachetool over the FPM socket. Plugin stack: Yoast SEO, Elementor, Wordfence, Mailchimp for WooCommerce, Polylang, Smush. Raw CSVs and the measurement scripts are available - if you want to re-run this on your own stack, ping me on X or LinkedIn.