Key Takeaways
A proper WordPress audit follows a fixed order: hosting and PHP first, then database, then caching, then application, then security
Lighthouse and PageSpeed Insights are diagnostic surface tools, not the audit itself. The real audit reads logs, query plans, and process tables
On most SA WordPress sites we audit, 70% of the performance problem is in three places: wp_options autoload bloat, missing or wrong PHP-FPM tuning, and no edge caching strategy
Cape Town hosting context introduces specific failure modes (transatlantic latency, ZAR-billing FX exposure, plugin choices made for SA payment gateways) that international audit checklists miss
Most audits surface fixes that pay back in weeks of revenue, not years. The cost of running unaudited is usually higher than the cost of fixing it
What an audit actually is
Most "audit reports" floating around in the WordPress world are PageSpeed Insights screenshots with annotations. That is not an audit. That is a screenshot.
A real audit is a structured pass through the stack that produces a written list of findings, each one with an attached piece of evidence, a recommended fix, and an estimate of how much it matters. The evidence is the work. Anyone can run PageSpeed Insights.
What follows is the actual checklist we work through on a WordPress site, applied to a recent example: a real Cape Town-based WooCommerce store we audited in early 2026 with the owner's permission to write it up here (the store name is anonymised, the numbers are not).
The store has been live for about three years, runs WooCommerce with around 1,400 products, serves a roughly 80% SA / 20% international audience, and was complaining about slow checkout and the occasional 503 error during morning email blasts.
Layer 1: hosting and PHP
Every audit starts at the bottom of the stack. If the host or PHP layer is wrong, everything above it is unfixable.
What we look at
# Server context
uname -a
cat /etc/os-release
php -v
nginx -v || apache2 -v
mysql --version
# What is actually running
ps auxf | head -50
free -h
df -hFor this Cape Town store the result was an Ubuntu 20.04 VPS on Xneelo (one year past LTS support), PHP 7.4 (end-of-life since November 2022), Apache 2.4, and MariaDB 10.3 (end-of-life since May 2023). The OS, PHP, and database were all running versions that no longer receive security updates.
Finding 1.1: OS, PHP, and database versions all past end-of-life. Recommended fix: upgrade path to Ubuntu 22.04 LTS, PHP 8.2, MariaDB 10.11. Estimated impact: security risk reduction and 15-25% PHP throughput improvement.
PHP-FPM tuning
# PHP-FPM pool configuration
cat /etc/php/7.4/fpm/pool.d/www.conf | grep -E "^pm"Result:
pm = dynamic
pm.max_children = 5
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3
These are the distribution defaults. On a 4GB VPS that could comfortably run 25-35 PHP workers, 5 means the site can handle 5 concurrent PHP requests before queuing.
# Are we hitting the ceiling?
grep "max_children" /var/log/php7.4-fpm.log | tail -20The log contained "server reached pm.max_children setting (5), consider raising it" 47 times in the past 7 days, clustered around the morning hours when the store's email blasts went out.
Finding 1.2: PHP-FPM pm.max_children set to default 5, hitting the ceiling regularly during email-blast traffic spikes. Recommended fix: tune to 22 based on measured PHP process size (~140MB average) and available RAM (4GB minus OS overhead and MariaDB). Estimated impact: eliminates 503 errors during traffic spikes.
Disk and memory
df -h
free -h
iostat -x 1 5The disk was at 78% full, which is fine but worth watching, and MariaDB was using more memory than its innodb_buffer_pool_size setting suggested, which was a hint that something else was consuming memory budget for the database.
Layer 2: the database
Once we know what's running, we go into the database itself.
Table size and bloat
SELECT
table_name,
table_rows,
ROUND(data_length/1024/1024, 2) as data_mb,
ROUND(index_length/1024/1024, 2) as index_mb
FROM information_schema.tables
WHERE table_schema = DATABASE()
ORDER BY (data_length + index_length) DESC
LIMIT 20;For this store:
| Table | Rows | Data (MB) | Index (MB) |
|---|---|---|---|
wp_postmeta | 487,000 | 142 | 38 |
wp_options | 11,400 | 88 | 4 |
wp_actionscheduler_actions | 312,000 | 76 | 24 |
wp_actionscheduler_logs | 1,140,000 | 142 | 32 |
wp_posts | 28,000 | 64 | 8 |
wp_woocommerce_sessions | 18,500 | 22 | 2 |
The relevant numbers are at the top. We have written a full post on wp_postmeta bloat and another on Action Scheduler bloat, so for the audit we ran the diagnostic queries from those posts.
-- Orphaned postmeta
SELECT COUNT(*) FROM wp_postmeta pm
LEFT JOIN wp_posts p ON pm.post_id = p.ID
WHERE p.ID IS NULL;
-- Result: 89,000 orphans
-- Completed actions older than 30 days
SELECT COUNT(*) FROM wp_actionscheduler_actions
WHERE status = 'complete'
AND scheduled_date_gmt < DATE_SUB(NOW(), INTERVAL 30 DAY);
-- Result: 271,000 stale completed actions
-- Autoloaded options size
SELECT ROUND(SUM(LENGTH(option_value))/1024/1024, 2) as autoload_mb
FROM wp_options
WHERE autoload = 'yes';
-- Result: 68 MB autoloaded on every requestFinding 2.1: wp_postmeta has 89,000 orphaned rows (18% of the table). Recommended fix: scripted orphan cleanup with monthly cron. Estimated impact: 40-60ms reduction in product page query time.
Finding 2.2: Action Scheduler has 271,000 stale completed actions. Recommended fix: cleanup query plus configuration to set 30-day retention going forward. Estimated impact: 30-50% reduction in background task processing latency.
Finding 2.3: wp_options autoloads 68 MB on every page request. Recommended fix: identify and disable autoload on large transient and plugin-orphan options. Estimated impact: 200-400ms reduction in cold-cache page generation time.
Slow query log
# Enable slow query logging for a 30-minute window
mysql -e "SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 0.2;
SET GLOBAL slow_query_log_file = '/var/log/mysql/audit-slow.log';"
# After 30 minutes of typical traffic
tail -200 /var/log/mysql/audit-slow.logThe slow log surfaced two recurring query patterns:
- WooCommerce product filtering by meta value (no index on
meta_value) taking 1.4-2.1 seconds per query. - A booking-plugin cron job running a full table scan against
wp_postmetaevery 5 minutes, looking for posts of a custom type that no longer existed in production.
Finding 2.4: Missing index on wp_postmeta.meta_value. Recommended fix: ALTER TABLE wp_postmeta ADD INDEX meta_value_idx (meta_value(191)). Estimated impact: 1-2 second reduction in product filter response time.
Finding 2.5: Booking plugin cron running ineffective query every 5 minutes. Recommended fix: disable the plugin (it is unused) or patch the cron interval. Estimated impact: 12 fewer full-table scans per hour.
Layer 3: caching
WordPress without caching is unfit for production. WordPress with poorly-configured caching is often worse than no caching, because the cache layer adds latency without adding hit rate.
Object cache
# Is Redis or Memcached available?
redis-cli ping
# Result: PONG (Redis is running)
# Is WordPress using it?
wp eval 'echo (defined("WP_REDIS_HOST") ? "Redis configured\n" : "Redis NOT configured\n");'
# Result: Redis NOT configuredRedis was installed and running on the server but WordPress was not using it. The Redis Object Cache plugin had been installed and abandoned, with the WP_REDIS_HOST constant never added to wp-config.php. The plugin's admin page showed "Not Connected" but nobody had looked at it.
Finding 3.1: Redis available but not connected to WordPress. Recommended fix: configure WP_REDIS_HOST, install the Object Cache Pro or Redis Object Cache plugin properly, verify cache hits. Estimated impact: 30-50% reduction in average page generation time.
We have written about this in detail in Redis object cache for WordPress.
Page cache
The store was using a popular caching plugin in its default configuration. We checked what was actually getting cached.
# Browse anonymously to a few pages, check response headers
curl -I https://example.com/shop/ | grep -i cache
curl -I https://example.com/product/widget/ | grep -i cacheThe cache plugin was caching pages but not pre-warming them. First visitor to each page paid the cold-cache cost. With 1,400 products and modest traffic, most products were cold most of the time.
Finding 3.2: Page cache not pre-warming. Recommended fix: enable preloading via the cache plugin or via a wp-cron job that hits the sitemap. Estimated impact: shifts the cold-cache cost from real visitors to background jobs.
Edge cache (Cloudflare)
The site was behind Cloudflare on the free plan, but no cache rules were configured beyond Cloudflare's defaults. Default Cloudflare caches static assets only. Every HTML page was hitting the origin.
# Verify with curl
curl -I https://example.com/ -H "User-Agent: Mozilla/5.0" | grep -i "cf-cache-status"
# Result: cf-cache-status: BYPASSFinding 3.3: No Cloudflare cache rules for HTML. Every page-view round-trips to the SA origin including for international visitors. Recommended fix: cache rules for product, category, and homepage paths with cookie-based bypass for logged-in and cart-session visitors. Estimated impact: 60-80% reduction in origin requests, significant TTFB improvement for international visitors.
We covered the rule structure in our post on international Woo store latency.
Layer 4: application
Once the stack underneath is sane, we look at WordPress itself.
Plugin inventory
wp plugin list --status=active --format=tableThe store had 42 active plugins. We grouped them by what they did:
- Performance / caching: 3 (one of which conflicted with the other two)
- Security: 2 (one of which had been abandoned by its developer in 2023)
- WooCommerce extensions: 18 (six of which were trial installs from years ago and not in use)
- Marketing / popup / email: 7
- Backup: 2 (running simultaneously, both writing daily backups, neither rotating)
- Form / UX: 5
- Other: 5
Finding 4.1: Plugin inventory carries 12+ inactive-by-use but active-by-status plugins, including an abandoned security plugin. Recommended fix: deactivate and remove unused plugins; replace abandoned security plugin with maintained alternative. Estimated impact: smaller attack surface, reduced administrative burden, marginal performance improvement.
Theme
wp theme list --status=activeThe store used a popular Woo-compatible theme with a child theme containing 4,200 lines of custom CSS and JavaScript. The custom code had been written over three years by three different developers. No tests, no documentation.
Finding 4.2: Child theme contains significant custom code with no tests or documentation. Recommended fix: not a fix in the strict sense, but a flag that future development time will be high; budget for refactoring or a redesign if the site is intended to live another 3+ years.
WP Cron
wp cron event listThe cron list contained 89 scheduled events. Three were running every minute. The site had moved to a system cron for performance (good) but the original DISABLE_WP_CRON constant was set incorrectly so both system cron and wp-cron.php were firing on every page view.
Finding 4.3: WP Cron firing on every page view despite being intended to run from system cron. Recommended fix: set define('DISABLE_WP_CRON', true); in wp-config.php correctly. Estimated impact: 100-300ms reduction on uncached page loads.
Layer 5: security
Security findings often overlap with the layers above. The audit groups them together for the written report.
Surface checks
# Are we exposing PHP version in headers?
curl -I https://example.com/ | grep -i "x-powered-by"
# Result: X-Powered-By: PHP/7.4.33
# Is the WordPress version exposed?
curl https://example.com/ | grep "generator"
# Result: <meta name="generator" content="WordPress 6.4.2">
# Is wp-config.php correctly outside the docroot?
ls -la /var/www/example.com/
# Result: wp-config.php is at /var/www/example.com/wp-config.php (inside docroot)
# Is xmlrpc.php enabled?
curl -X POST https://example.com/xmlrpc.php -d "<?xml version=\"1.0\"?><methodCall><methodName>demo.sayHello</methodName></methodCall>"
# Result: <string>Hello!</string>Finding 5.1: PHP version, WordPress version both exposed in headers and HTML. Recommended fix: remove X-Powered-By header, remove generator meta. Estimated impact: reduces automated targeting in vulnerability scans.
Finding 5.2: wp-config.php inside docroot. Recommended fix: move to parent directory, update wp-config.php path references. Estimated impact: reduces blast radius of any future path-traversal vulnerability.
Finding 5.3: XML-RPC enabled and responding. Recommended fix: disable or restrict to specific IPs (Jetpack's IP range if used). Estimated impact: closes a common brute-force attack vector.
Redis exposure
# Is Redis listening on a public interface?
netstat -tlnp | grep 6379
# Result: tcp 0 0 0.0.0.0:6379 LISTEN -- Redis on the public interfaceRedis was listening on 0.0.0.0:6379 with no password, accessible from the public internet. Anyone could connect to it and read every cached value, including login session tokens.
Finding 5.4: Redis publicly accessible without authentication. Critical severity. Recommended fix: immediate. Restrict Redis to 127.0.0.1 or set a strong password and firewall to specific source IPs. Estimated impact: prevents trivial compromise of all cached data.
Login hardening
# Are there login throttling protections?
cat /etc/fail2ban/jail.local 2>/dev/null | grep -A3 wordpress
# Result: no jail configured
# What's the admin username?
wp user list --role=administrator --format=table
# Result: usernames include 'admin' and the owner's first nameFinding 5.5: No fail2ban jail for WordPress login. Admin username is admin, which is the default brute-force target. Recommended fix: configure fail2ban WP jail, rename admin account. Estimated impact: drops successful brute-force probability significantly.
What the report ends with
The written audit report for this store had 24 findings, grouped by layer, each one with the diagnostic command we ran, the result, the recommendation, and an estimated impact. The summary table looked like:
| Severity | Count | Examples |
|---|---|---|
| Critical | 2 | Redis publicly exposed, PHP version end-of-life |
| High | 6 | Action Scheduler bloat, missing meta_value index, no edge caching |
| Medium | 11 | Plugin inventory issues, autoload bloat, no fail2ban |
| Low | 5 | Generator meta exposure, theme refactor flag |
The store owner had three options:
- Take the report to their existing developer and have it implemented.
- Hire us to implement it on retainer.
- Implement the critical and high-severity items themselves and queue the rest.
For this client we ended up on retainer, and ninety days later the store was on PHP 8.2, Redis was properly connected and locked down, edge caching was configured, the database was 35% smaller, and the checkout was responding in 280ms instead of 1.8s.
What we'd recommend before booking an audit
Audits work best when there is something specific to look at. If your site is doing fine and you just want a check-up, that's reasonable, but the highest-value audits come at one of three moments:
- The site has slowed down meaningfully in the past 6-12 months and you don't know why.
- The site is about to take significant new traffic (campaign launch, press, a big sale).
- You inherited the site and don't know what's in it.
For all three, the audit page has the brief. We do them as a fixed-scope engagement, return the written report within a week or two, and from there the implementation can be done by anyone competent (us or otherwise).
Related reading
- PHP-FPM Tuning for WordPress. Layer 1 of the audit, expanded.
- wp_postmeta is Destroying Your WooCommerce Store's Speed. The most common Layer 2 finding.
- Action Scheduler Table Bloat. The second most common Layer 2 finding on Woo stores.
- Redis Object Cache for WordPress. The Layer 3 fix that produced the biggest single improvement on this audit.
- How to Clean Your wp_options Table (The Right Way). For the autoload bloat finding.
- WP Rocket and Cloudflare configuration. The application-and-edge half of Layer 3.
- WordPress Slow After 2 Years? Here's What's Actually Wrong. The shorter version of this audit, intended for site owners rather than engineers.

Written by
Barry van Biljon
Full-stack developer specializing in high-performance web applications with React, Next.js, and WordPress.
Ready to Get Started?
Have questions about implementing these strategies? Our team is here to help you build high-performance web applications that drive results.
