Diagnosing admin-ajax.php High Server Load on a WooCommerce Store
· 11 min read
The Server Was Melting and Nobody Knew Why
A client running a mid-sized WooCommerce store — around 800 products, 150 orders a day — called because their site had slowed to a crawl. Pages that used to load in under two seconds were taking eight to twelve. The hosting provider had already pointed the finger at "high PHP usage" and suggested upgrading to a larger VPS. The site was on a 4-core, 8GB VPS with CloudPanel, PHP 8.2-FPM, Nginx, and MariaDB 10.11. That should handle this traffic without breaking a sweat.
I SSH'd in and the first thing I checked was the PHP-FPM status page.
curl -s http://127.0.0.1/status?full | grep -E "active processes|listen queue|slow requests"
active processes: 12
listen queue: 7
slow requests: 341
Twelve active processes out of the fifteen configured. Seven requests queued and waiting. And 341 slow requests since the last FPM restart — which was only six hours ago. The PHP workers were being consumed by something, and incoming requests were stacking up in the listen queue.
Following the Trail in the Access Log
The next step was finding out what those PHP workers were actually doing. I pulled the Nginx access log and counted the most-hit PHP endpoints over the last hour:
awk '{print $7}' /var/log/nginx/access.log | grep '\.php' | sort | uniq -c | sort -rn | head -10
14823 /wp-admin/admin-ajax.php
1204 /
891 /?wc-ajax=get_refreshed_fragments
743 /shop/
612 /my-account/
...
Nearly 15,000 requests to admin-ajax.php in a single hour. On a store doing 150 orders a day, that is wildly disproportionate. And that separate wc-ajax=get_refreshed_fragments line was another red flag — WooCommerce cart fragments running on every single page load.
The problem was clear: admin-ajax.php was consuming the majority of PHP workers. But "admin-ajax.php" is just a router. I needed to find out which AJAX actions were actually being called.
Identifying the Guilty Actions
Every AJAX request to WordPress includes an action parameter that tells WordPress which hook to fire. On POST requests, this lives in the request body, so access logs don't capture it by default. I added a small must-use plugin to log the action names temporarily:
<?php
// wp-content/mu-plugins/ajax-logger.php
// TEMPORARY — remove after diagnosis
add_action('admin_init', function () {
if (!defined('DOING_AJAX') || !DOING_AJAX) {
return;
}
$action = $_REQUEST['action'] ?? 'unknown';
$user = is_user_logged_in() ? get_current_user_id() : 'guest';
error_log(sprintf('[AJAX] action=%s user=%s ip=%s', $action, $user, $_SERVER['REMOTE_ADDR']));
});
After 30 minutes, I pulled the debug log and counted:
grep '\[AJAX\]' /var/log/php/error.log | awk -F'action=' '{print $2}' | awk '{print $1}' | sort | uniq -c | sort -rn | head -10
4211 heartbeat
3087 nopriv_wc_get_refreshed_fragments
1844 nopriv_mailchimp_sync
923 wp_mail_smtp_admin_ajax
412 nopriv_live_search
...
Three culprits jumped out immediately.
Culprit 1: The Heartbeat API Running Wild
Over 4,000 heartbeat requests in 30 minutes. The WordPress Heartbeat API sends an AJAX request every 15 seconds by default — it powers auto-save, login session management, and real-time notifications in the admin. That is 240 requests per hour from a single browser tab with the dashboard open.
This client had three staff members who routinely left the WordPress admin open in a browser tab all day. That is 720 requests per hour just from heartbeat across three tabs. But 4,000 in 30 minutes — 8,000 per hour — was far more than three tabs should generate. I checked: two of them had multiple admin tabs open (orders, products, a post editor), each running its own heartbeat instance.
The fix was a small snippet in the theme's functions.php:
// Slow heartbeat to 60 seconds everywhere, disable on frontend
add_action('init', function () {
// Completely deregister heartbeat on the frontend
if (!is_admin()) {
wp_deregister_script('heartbeat');
return;
}
});
// Reduce heartbeat frequency in admin to 60 seconds
add_filter('heartbeat_settings', function ($settings) {
$settings['interval'] = 60;
return $settings;
});
This reduced heartbeat requests by 75% in the admin and eliminated them entirely on the frontend where they serve no purpose for most sites. Auto-save still works — it just triggers every 60 seconds instead of 15.
Culprit 2: WooCommerce Cart Fragments on Every Page
The second offender was wc_get_refreshed_fragments. WooCommerce fires this AJAX request on every single page load to update the mini-cart widget in the header. Even on the About page. Even on the Privacy Policy. Every visitor, every page, one uncacheable AJAX request that bootstraps the entire WordPress stack, runs multiple database queries, and returns the cart HTML.
On a site with 2,000 daily visitors averaging 4 pages each, that is 8,000 cart fragment requests per day — on top of actual page loads. Each one spawns a PHP-FPM worker, queries the database, and returns a JSON response. None of it is cacheable because it is session-specific.
The nopriv_ prefix in the log confirmed these were firing for logged-out visitors too, which is the majority of traffic on any ecommerce site.
The fix depends on the store's setup. This client used a header mini-cart that showed the item count. Rather than disabling cart fragments entirely (which would break the live cart count), I restricted them to pages where they matter:
add_action('wp_enqueue_scripts', function () {
// Only load cart fragments on shop, cart, and checkout pages
if (is_shop() || is_product() || is_cart() || is_checkout()) {
return;
}
wp_dequeue_script('wc-cart-fragments');
}, 20);
For stores that do not use a header mini-cart at all, you can disable cart fragments completely:
add_action('wp_enqueue_scripts', function () {
wp_dequeue_script('wc-cart-fragments');
}, 20);
If you still want a cart count in the header without the AJAX overhead, you can render it server-side and only update it when the customer actually adds or removes an item — using a page reload or a targeted lightweight AJAX call rather than polling on every page.
Culprit 3: A Rogue Mailchimp Sync Plugin
The third issue was mailchimp_sync firing 1,800 times in 30 minutes from logged-out visitors. This turned out to be a Mailchimp for WooCommerce integration that was running an audience sync check via AJAX on the frontend. The plugin was queuing a JavaScript file on every page load that pinged admin-ajax.php to check sync status — something that should only run in the admin, if at all.
I checked the plugin version and found it was two major versions behind. Updating to the latest release fixed the frontend AJAX leak. The newer version had moved the sync check to a background Action Scheduler job instead.
This is a pattern I see regularly: plugins registering AJAX handlers with wp_ajax_nopriv_ hooks when they should be using wp_ajax_ only (logged-in users) or not using AJAX at all. Any nopriv AJAX handler fires for every logged-out visitor who triggers it, and those requests bypass page caching entirely.
Verifying the Fix
After deploying all three changes, I watched the access log for an hour:
# Count admin-ajax.php hits over the next hour
watch -n 60 "grep 'admin-ajax.php' /var/log/nginx/access.log | wc -l"
The request count dropped from ~15,000/hour to around 1,400/hour — a 90% reduction. PHP-FPM active processes dropped from 12 to 3. The listen queue went to zero. Page load times came back down to 1.8 seconds.
I removed the temporary logging mu-plugin and confirmed the debug log was clean:
rm /var/www/html/wp-content/mu-plugins/ajax-logger.php
How to Audit Your Own Site
If you suspect admin-ajax.php is causing load on your site, here is the quick diagnostic checklist:
-
Check your access logs — count how many
admin-ajax.phprequests you are getting per hour relative to normal page views. More than 5x your page views is a red flag. -
Install Query Monitor — the free Query Monitor plugin will show you which AJAX actions are firing and how many database queries each one triggers. Look at the AJAX panel in the browser developer tools.
-
Check for
noprivAJAX on the frontend — open your site in an incognito window (logged out), open Chrome DevTools Network tab, filter foradmin-ajax, and browse a few pages. Every request you see is an uncacheable hit to your server from every visitor. -
Audit the Heartbeat API — if you see
heartbeatin your AJAX logs and you do not need real-time features in the admin, throttle it to 60 seconds or disable it on the frontend entirely. -
Check cart fragments — if you run WooCommerce, look for
wc-ajax=get_refreshed_fragmentsin your access logs. If you do not use a dynamic mini-cart, disable them.
The Bigger Picture
The frustrating thing about admin-ajax.php load is that it is invisible. Your caching plugin reports a 95% hit rate because page caching is working perfectly. But AJAX requests bypass page caching by design — they are dynamic, uncacheable, and each one boots the full WordPress stack. A site can look perfectly optimised on paper while admin-ajax.php quietly eats all your PHP workers behind the scenes.
This is exactly the kind of issue that surfaces during routine maintenance audits. Not a crisis, not a hack — just a slow drain on server resources that gets worse as traffic grows until one day the site falls over and nobody understands why.
Stop Firefighting. Start Maintaining.
I manage 70+ WordPress sites for UK agencies and businesses. Whether you need ongoing maintenance, emergency support, or a one-off performance fix — I can help.
