Skip to content
EdgeServers

OPcache and JIT in PHP 8.3 production — what actually moves the needle

OPcache is mandatory. JIT is conditional. Here is the production config we ship, the JIT mode debate settled with numbers, and the workloads where JIT genuinely hurts.

May 19, 2026 · 9 min · by Sudhanshu K.

OPcache and JIT in PHP 8.3 production — what actually moves the needle

There is no production PHP deployment in 2026 that shouldn't have OPcache enabled, tuned, and monitored. There are plenty of production PHP deployments where JIT would slow them down. Knowing the difference matters, because the internet advice on PHP performance tends to lump the two together: "turn on OPcache and JIT and you'll get 50% more throughput." That's true for some workloads. For a typical Laravel or WordPress site, it's misleading.

This post walks through how we configure OPcache for production on every managed PHP server we run, when we turn JIT on, when we leave it off, and the specific numbers we've seen across customer workloads on AWS, GCP, Azure, and DigitalOcean.

OPcache, briefly, and what changes for production

OPcache stores compiled PHP bytecode in shared memory so each request doesn't re-parse and re-compile the script files. The performance win is enormous and not really negotiable. The defaults that ship with PHP, however, are tuned for development — they assume you'll edit a file and want the change to take effect on the next request. In production, you want the exact opposite: never check, never re-parse, deploy by explicit cache reset.

The production config we ship:

; /etc/php/8.3/mods-available/opcache.ini
opcache.enable=1
opcache.enable_cli=0
opcache.memory_consumption=512
opcache.interned_strings_buffer=32
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
opcache.revalidate_freq=0
opcache.save_comments=1
opcache.fast_shutdown=1
opcache.huge_code_pages=1
opcache.file_cache=/var/cache/php/opcache
opcache.file_cache_only=0
opcache.file_cache_consistency_checks=1

The flags worth defending individually:

validate_timestamps=0 is the single most impactful one. With it off, OPcache never stats a file to check for modifications. Every request reads bytecode straight from shared memory. On a busy WordPress site, this removes tens of thousands of stat calls per second from the filesystem. Trade-off: a code deploy doesn't take effect until you opcache_reset() or reload PHP-FPM. That's exactly what a controlled deploy pipeline does anyway.

memory_consumption=512 — 512 MB of shared memory for cached bytecode. The default of 128 MB is enough for a small app and not enough for anything modern. WordPress with a half-dozen plugins, WooCommerce, and a custom theme will burn through 200-300 MB without trying. We size to leave 30% headroom; if opcache.memory_consumption runs out, OPcache resets itself, which is catastrophic for cold-start latency.

max_accelerated_files=20000 — the maximum number of PHP files OPcache will track. The default of 10000 is hit by any non-trivial Symfony or Laravel app the moment vendor/ gets loaded. WordPress with WooCommerce and 30 plugins is comfortably over 15000 files. We default to 20000 and bump to 30000 for big enterprise codebases. The flag must be a prime number internally; PHP rounds up if you set a non-prime.

interned_strings_buffer=32 — shared memory for interned strings. Default 8 MB is too low; modern frameworks generate enormous numbers of interned strings (class names, method names, config keys). 32 MB is a comfortable ceiling for most workloads.

huge_code_pages=1 — uses Linux 2 MB huge pages for the OPcache shared memory region, reducing TLB pressure. Free 1-3% throughput on memory-bound workloads. Requires the kernel to have vm.nr_hugepages configured; we set this in the host provisioning step.

file_cache — a secondary disk cache for OPcache. Useful because it survives PHP-FPM restarts; the first request after a reload doesn't pay the full re-compile cost. We set file_cache_consistency_checks=1 because we'd rather pay a tiny consistency-check cost than serve stale bytecode from disk.

Monitoring OPcache: hits, memory, key utilisation

OPcache provides excellent introspection via opcache_get_status(). We expose it on an internal endpoint and scrape three numbers:

<?php
// /var/www/internal/opcache-status.php
header('Content-Type: application/json');
$s = opcache_get_status(false);
echo json_encode([
    'hit_rate'      => $s['opcache_statistics']['opcache_hit_rate'],
    'memory_used'   => $s['memory_usage']['used_memory'],
    'memory_free'   => $s['memory_usage']['free_memory'],
    'wasted_pct'    => $s['memory_usage']['current_wasted_percentage'],
    'cached_scripts'=> $s['opcache_statistics']['num_cached_scripts'],
    'max_scripts'   => ini_get('opcache.max_accelerated_files'),
]);

Alert thresholds we use:

  • hit_rate < 99% for more than 5 minutes — something is invalidating cache aggressively, or max_accelerated_files is too low and OPcache is evicting.
  • wasted_pct > 10% — memory fragmentation; OPcache is unable to reuse freed slots. The fix is a scheduled reset, not a bigger memory pool.
  • cached_scripts / max_scripts > 0.85 — you'll hit the wall during the next deploy; bump max_accelerated_files.

These are wired into Prometheus on every managed PHP host. The wasted-percentage alert is the one that catches the long-tail problems: it goes off three weeks after a deploy because OPcache has been quietly fragmenting, and a daily 4am opcache_reset() keeps it healthy.

OPcache preloading: when it's worth the effort

PHP 7.4 added preloading, where you nominate a script that runs at FPM startup and loads classes into OPcache shared memory before any request. Those classes then have no per-worker init cost, and the JIT (if on) compiles them before traffic hits.

The win is real but workload-dependent:

  • Symfony: huge. The framework auto-generates a preload script. 10-15% reduction in p50 latency, 20%+ on cold-start scenarios.
  • Laravel: moderate. There are community packages that generate a preload list. We see 5-10% on real apps.
  • WordPress: marginal. WordPress's plugin architecture means a lot of code is loaded conditionally, and preloading the wrong things just wastes shared memory.

Our default is preloading on for Symfony and Laravel, off for WordPress unless the customer has a specific reason. A preload script looks like:

<?php
// /var/www/preload.php
opcache_compile_file('/var/www/vendor/autoload.php');
foreach (glob('/var/www/vendor/symfony/**/*.php') as $file) {
    opcache_compile_file($file);
}

The catch: preloaded classes can't be redeclared. If your app uses any kind of dynamic class generation (Doctrine proxies, mock objects in dev), preload will reject them at FPM startup with an opaque error. Always test preload in a staging environment that mirrors prod exactly.

JIT: the part where the internet advice gets sloppy

PHP 8.0 introduced JIT. PHP 8.3 made it production-stable. The marketing materials for any new PHP release lead with a JIT benchmark. The benchmark is almost always Mandelbrot or a pure-PHP cryptography routine, where JIT genuinely produces 2-3x speedups.

Real PHP workloads do not look like Mandelbrot. A typical request to a Laravel API spends:

  • 30-50% in database I/O wait
  • 10-20% in Redis or cache I/O
  • 10-20% in framework overhead (routing, container, middleware)
  • 5-15% in actual application logic
  • The rest in template rendering and serialisation

JIT speeds up the CPU-bound parts. On a workload that's 60% I/O wait, doubling the speed of the CPU-bound 40% gets you a 20% improvement, not 100%. That's still real, but it's a long way from the brochure number.

The two JIT modes

opcache.jit takes a four-digit string (a "CRTO" bitmask). In practice you only need two values:

  • tracing (or 1254) — tracing JIT. Compiles hot loops based on runtime observation. Slower to warm up, but better at code that doesn't look like a hot loop on static analysis.
  • function (or 1205) — function JIT. Compiles whole functions when they're called frequently. Faster warm-up, less aggressive optimisation.

For typical web workloads, tracing wins by a small margin in steady state but pays a higher warm-up cost. Combined with pm = static (so workers are long-lived), tracing JIT is our default.

opcache.jit=tracing
opcache.jit_buffer_size=128M

128 MB of JIT buffer is generous. We've never seen a web workload need more, and going lower (64 MB) can cause the buffer to fill and JIT to disable itself silently mid-day. Check opcache_get_status()['jit']['buffer_free'] in monitoring.

Benchmark numbers from real workloads

Across customer workloads we manage, our measured impact of enabling JIT (tracing mode, on top of an already-tuned OPcache) over the last quarter:

WorkloadJIT off req/sJIT on req/sDelta
WordPress + WooCommerce, anonymous browse142156+9.8%
WordPress + WooCommerce, logged-in checkout3841+7.9%
Laravel API (DB-heavy, Redis caching)8801010+14.8%
Laravel API (CPU-heavy, image processing)64102+59.4%
Symfony admin (heavy templating, Twig)215268+24.7%
Custom PHP, math-heavy reporting1841+127%

The pattern is clear. The more CPU-bound the workload, the more JIT helps. The more I/O-bound the workload, the less JIT helps. On the heavily I/O-bound WordPress workloads, +8% is real and worth having, but it's not transformational.

When JIT genuinely hurts

JIT is not free. Three cases where we leave it off:

Tiny VMs with constrained memory. On a 1GB instance, the 128MB JIT buffer is significant, and the working-set memory pressure can push a worker into swap. We disable JIT on anything under 2GB and use the memory for more pm.max_children instead.

Workloads with hot deploys multiple times an hour. Each FPM reload throws away JIT-compiled code. If you're deploying 6 times an hour, the workers spend more time warming up JIT than running JIT-optimised code. Either deploy less often or skip JIT.

Workloads with active runtime opcache mutations. Code that does extensive eval() (please don't), or that uses Twig's on-the-fly compilation without warming the cache, or that hits opcache_reset() from within a request, defeats JIT's warm-up. We've had one customer where the right answer was JIT off, full stop, because their custom CMS regenerated PHP class files at runtime.

The deploy-time choreography

The single bit that catches teams out: a git pull doesn't change anything in OPcache (because validate_timestamps=0). Production code remains on the bytecode from the previous deploy until OPcache is told otherwise.

Our deploy step looks like:

# Stage code into new release directory
rsync -a build/ /var/www/releases/$(date +%s)/
 
# Atomic symlink swap
ln -sfn /var/www/releases/$(date +%s) /var/www/current
 
# Reload FPM (gracefully, no dropped requests)
sudo systemctl reload php8.3-fpm

The systemctl reload issues SIGUSR2 to PHP-FPM, which gracefully recycles all workers. New workers come up with fresh OPcache and fresh JIT buffer. Brief CPU spike for 30-60 seconds as everything warms up; no dropped requests because Nginx queues for the brief reload window.

For zero-downtime across multiple PHP hosts, do this rolling, one host at a time, with health checks at the load balancer. This is the same pattern we ship for Laravel deployment workflows.

The summary, if you skipped to the end

  • OPcache is mandatory. Tune memory_consumption, max_accelerated_files, validate_timestamps=0. Monitor hit rate, wasted percentage, and script count.
  • Preload for Symfony and Laravel. Skip for WordPress.
  • JIT on for CPU-bound workloads. JIT off for tiny VMs and hot-deploy workloads. Benchmark before assuming.
  • Deploy by atomic symlink + graceful FPM reload. Never trust validate_timestamps=1 in production.

Get this right and a 4GB Droplet will quietly serve more traffic than a 16GB box with the defaults. Get it wrong — usually by leaving the dev-mode OPcache settings live — and you'll spend money scaling out a problem that wasn't a scaling problem.


Sudhanshu K. is a Staff DevOps engineer at EdgeServers (RemotIQ Pty Ltd, ABN 91 682 628 128). He has spent more weekends than he'd like to admit chasing OPcache fragmentation tickets, and is now firmly in the "schedule a 4am reset and move on" camp.