We have rebuilt slow WordPress installations and custom PHP applications enough times to know speed problems accumulate — unoptimized JPEGs, missing indexes, slow plugins, unbounded PHP workers, and nginx servers that never cache. We work through concrete, measured changes, not a single magic plugin.

Image compression: WebP and the numbers that matter

Images are usually the heaviest payload on any site we audit. Before we touch code, we measure the total image weight on the homepage. We have seen WordPress sites carrying 6 MB of uncompressed PNG files for hero banners that could be 180 KB WebP images with identical visual quality. We convert all raster images to WebP with a compression quality of 82, which we find is the point where further compression produces visible artifacts on text and fine edges.

Our concrete workflow uses cwebp in a batch script. For a typical travel blog we rebuilt recently, the original set of 24 JPEG banners totaled 11.2 MB. After conversion to WebP at quality 82, the same set was 1.4 MB — an 87 percent reduction. The homepage load time on a 3G connection dropped from 4.7 seconds to 1.2 seconds before we changed a single line of PHP. We then add responsive srcset attributes so mobile browsers do not download desktop-sized files. This is not optional optimization; it is the baseline.

We also enforce lazy loading on images below the fold. We use the native loading="lazy" attribute where possible and fall back to a lightweight JavaScript observer for browsers without support. On one custom PHP portfolio site we maintain at /work/, this reduced the initial render payload by 2.3 MB without changing any design.

Caching strategies: nginx fastcgi_cache and WordPress layers

For custom PHP sites, we rely on nginx fastcgi_cache. It is faster than any PHP-level cache plugin because it avoids spawning a PHP worker at all for cached pages. Our production server block includes:

fastcgi_cache_path /var/run/nginx-cache levels=1:2 keys_zone=SITECACHE:100m max_size=500m inactive=60m use_temp_path=off;

server {
    listen 80;
    server_name example.com;
    root /var/www/example/public;

    location / {
        try_files $uri $uri/ =404;
    }

    set $no_cache 0;
    if ($request_method = POST) { set $no_cache 1; }
    if ($query_string != "") { set $no_cache 1; }

    location ~ \.php$ {
        fastcgi_pass unix:/run/php/php8.2-fpm.sock;
        include fastcgi_params;
        fastcgi_cache SITECACHE;
        fastcgi_cache_valid 200 60s;
        fastcgi_cache_bypass $no_cache;
        fastcgi_no_cache $no_cache;
        fastcgi_cache_use_stale error timeout updating;
    }
}

The key settings: fastcgi_cache_use_stale keeps a stale cached page available if PHP-FPM is temporarily down, which prevents 502 errors during restarts. We set the cache valid for 60 seconds on dynamic content. For static assets, we set far longer headers. On WordPress sites where we do not control the server directly, we use LiteSpeed Cache or WP Super Cache. We prefer LiteSpeed Cache when the host supports it because it writes rules directly to .htaccess and handles browser cache headers more aggressively than WP Super Cache.

We also enable object caching with Redis. On a WooCommerce site we recently tuned, enabling Redis reduced database queries per page from 142 to 28 — a 5x drop — which directly reduced PHP execution time from 780 milliseconds to 220 milliseconds.

Database query optimization: EXPLAIN and index fixes

Slow WordPress sites almost always have a database problem hiding behind a plugin. We start every audit with mysql slow query logging enabled. Our my.cnf always includes:

slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1
log_queries_not_using_indexes = 1

When we find a query over 1 second, we run EXPLAIN SELECT .... A common result we see is type: ALL with rows: 45000, which means the query scans the entire table because a column used in WHERE or JOIN lacks an index. For example, a query like:

SELECT p.*, m.meta_value FROM wp_posts p
LEFT JOIN wp_postmeta m ON p.ID = m.post_id
WHERE p.post_type = 'product' AND m.meta_key = 'price';

If wp_postmeta has no index on (meta_key, meta_value), MySQL scans every meta row for each product. Adding CREATE INDEX idx_meta_key_value ON wp_postmeta(meta_key, meta_value(32)) reduced this query from 3.2 seconds to 18 milliseconds in a real production database.

We clean post revisions; on one site, deleting 12,000 revisions and running OPTIMIZE TABLE wp_posts; reduced the database from 340 MB to 110 MB and backup time from 45 s to 12 s.

CSS, JavaScript, and asset delivery

We minify all CSS and JavaScript with esbuild or a simple Python script using rjsmin and rcssmin. More important than minification is removing unused rules. We use a coverage audit in Chrome DevTools to identify CSS rules that never match any DOM element on the site. On a recent custom PHP site, we removed 68 percent of the original CSS file — 42 KB of unused selectors — without changing a single visual element.

We serve static assets through a CDN and set explicit cache headers: Cache-Control: public, max-age=31536000, immutable for versioned assets, shorter headers for index pages.

PHP OPcache tuning: the settings that change everything

Every production PHP server we manage has OPcache enabled, but the default settings are too conservative. Our php.ini settings are:

opcache.enable = 1
opcache.memory_consumption = 256
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 20000
opcache.revalidate_freq = 60
opcache.validate_timestamps = 0
opcache.save_comments = 1
opcache.fast_shutdown = 1

Setting opcache.validate_timestamps = 0 is critical in production: it disables the file-system check for code changes, which eliminates a significant overhead on every request. We only change this when deploying updates; our deployment script runs php -r "opcache_reset();" or reloads PHP-FPM after code changes. Before we made this change on a high-traffic site, the average response time was 920 milliseconds. After setting validate_timestamps = 0 and increasing memory_consumption from the default 128 MB to 256 MB, the average response time dropped to 340 milliseconds — a 63 percent improvement from a configuration change alone.

We also set opcache.max_accelerated_files = 20000 because some WordPress installations with many plugins exceed the default 4000 files quickly. When OPcache runs out of slots, it falls back to file-system reads, which eliminates the performance gain entirely. We verify the current usage with php -i | grep opcache and check the Current number of cached keys against the maximum.

Removing unused plugins and themes

We audit plugins with Query Monitor. Any plugin that adds queries on every page load — contact forms, social sharing widgets, analytics plugins that load JavaScript synchronously — gets removed or replaced with a server-side alternative. We have removed plugins that added 15 database queries and 0.8 seconds to every page. We also delete inactive themes; WordPress loads theme data on certain admin pages, and extra themes slow updates and backups.

We also disable wp-cron, replacing it with a system cron job so slow visitors do not trigger slow events.

Concrete priority list: what we do in the first hour

When a site is slow, we prioritize actions by impact and time required.

  • Measure first: check total page weight, load time (before any changes), database query count, and OPcache status.
  • Convert hero images to WebP and add responsive srcset — often a 60-80 percent payload reduction in 15 minutes.
  • Enable nginx fastcgi_cache or a WordPress caching plugin with page-level rules — usually the largest single improvement for dynamic sites.
  • Add missing database indexes identified by slow query logs — often a 50-150x improvement on specific pages.
  • Adjust OPcache settings (memory_consumption, validate_timestamps, max_accelerated_files) and reload PHP-FPM.
  • Remove unused plugins, disable wp-cron, and clean old post revisions.
  • Serve static assets with long cache headers and a CDN for global sites.
  • Review our other work on projects and reach out through contact for production audits. You can also compare these steps with our LEMP production case study and the LEMP hardening checklist.

None of these steps requires a full rebuild. They are incremental, measurable, and cumulative. We apply them in sequence, retesting load time after each change. Most of the sites we work on drop from over 3 seconds to under 1 second within a single working session.