Diagnosing admin-ajax.php High CPU Usage on WordPress and WooCommerce
· 14 min read
The Symptom Nobody Could Explain
A client running a WooCommerce store on a 4-core VPS messaged me about intermittent slowdowns. The site would be fine for hours, then product pages would take 8-10 seconds to load. Sometimes the WordPress admin would barely respond. Then it would clear up on its own.
Their hosting panel showed CPU usage spiking to 90%+ during these episodes. But there was no pattern to it — no traffic spikes, no cron jobs running, no obvious explanation.
I SSH'd in during one of these episodes and ran top. PHP-FPM workers were maxed out. But the interesting part was what those workers were actually doing.
Finding the Bottleneck in the Access Log
The first thing I check in situations like this is what URLs are actually hitting the server. A quick count of the most requested endpoints over the last hour:
awk '{print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20
The output told the story immediately:
4,218 /wp-admin/admin-ajax.php
312 /
187 /shop/
94 /product/best-seller-widget/
...
Over 4,000 requests to admin-ajax.php in one hour. On a site that had maybe 50 concurrent visitors and three staff logged into the admin. That is not normal.
Understanding What admin-ajax.php Actually Does
Every AJAX request in WordPress — whether it is a Heartbeat pulse, a plugin checking for notifications, a page builder auto-saving, or a live search widget — routes through a single file: wp-admin/admin-ajax.php. Each request boots the entire WordPress stack. It loads wp-load.php, initialises the database connection, loads active plugins, fires hooks, runs the requested action, and tears down.
On a well-optimised site, a single admin-ajax request takes 50-200ms and consumes 30-60MB of RAM. That is fine when it happens a few times a minute. But when plugins start firing dozens of requests per page view, every 15 seconds, across multiple browser tabs — you get the CPU meltdown I was looking at.
Step 1: Identify Which Actions Are Hammering the Server
Every admin-ajax.php request includes an action parameter that tells WordPress which hook to fire. This is your diagnostic key. The action parameter is sent as POST data, so it will not show up in a standard access log. You need to log it yourself.
I added a temporary snippet to wp-content/mu-plugins/ajax-logger.php:
<?php
if ( defined( 'DOING_AJAX' ) && DOING_AJAX && isset( $_REQUEST['action'] ) ) {
$log_line = sprintf(
"[%s] action=%s ip=%s ua=%s\n",
date( 'Y-m-d H:i:s' ),
sanitize_text_field( $_REQUEST['action'] ),
$_SERVER['REMOTE_ADDR'] ?? 'unknown',
$_SERVER['HTTP_USER_AGENT'] ?? 'unknown'
);
file_put_contents(
WP_CONTENT_DIR . '/admin-ajax.log',
$log_line,
FILE_APPEND | LOCK_EX
);
}
I left this running for 30 minutes, then analysed the log:
awk -F'action=' '{print $2}' wp-content/admin-ajax.log | awk '{print $1}' | sort | uniq -c | sort -rn | head -15
The results:
1,847 heartbeat
986 wp_live_notifications_check
412 some_slider_refresh
203 wc_ajax_get_refreshed_fragments
89 wp_ajax_save_post
...
Three actions accounted for over 90% of the traffic: the WordPress Heartbeat, a notifications plugin polling every 10 seconds, and a slider plugin refreshing content from the database on every page load.
Step 2: Taming the Heartbeat API
The WordPress Heartbeat API sends an AJAX request every 15 seconds on the post editor screen and every 60 seconds on the dashboard. It handles autosaving, post locking (so two editors do not overwrite each other), and login session management.
With three staff members each having two or three browser tabs open to the admin, that is 15-20 Heartbeat requests per minute just from the team. It adds up.
The fix is not to disable it entirely — autosave and post locking are genuinely useful. Instead, reduce the frequency and disable it where it is not needed.
I created wp-content/mu-plugins/heartbeat-control.php:
<?php
/**
* Control WordPress Heartbeat API frequency.
* Reduce editor interval to 60s, disable on dashboard and frontend.
*/
add_filter( 'heartbeat_settings', function ( $settings ) {
// Slow the post editor heartbeat from 15s to 60s.
// Autosave still works, just less frequently.
$settings['interval'] = 60;
return $settings;
} );
// Completely disable Heartbeat on the dashboard and frontend.
// It is only genuinely needed on the post/page editor.
add_action( 'init', function () {
if ( ! is_admin() ) {
wp_deregister_script( 'heartbeat' );
return;
}
global $pagenow;
if ( $pagenow !== 'post.php' && $pagenow !== 'post-new.php' ) {
wp_deregister_script( 'heartbeat' );
}
} );
This single change cut Heartbeat requests from ~1,800 per hour down to roughly 180 — a 90% reduction.
Step 3: Deal with the Offending Plugins
The notifications plugin was polling admin-ajax.php every 10 seconds to check for new alerts. For a three-person team. That is 1,080 requests per hour for a feature nobody was actually watching.
I had two options: configure the plugin's polling interval (it did not have that setting), or replace it. I replaced it with a plugin that used the WordPress Heartbeat API for its notifications instead of its own polling loop, which meant the notifications piggybacked on an existing request rather than creating new ones.
The slider plugin was making a full uncached database query on every page load via AJAX to "ensure fresh content." The slider content changed once a month. I disabled the AJAX refresh and let the slider use the server-side rendered content with a short transient cache:
add_filter( 'some_slider_use_ajax', '__return_false' );
The exact filter name will vary by plugin — check the plugin's documentation or search its codebase for apply_filters calls related to AJAX or refresh behaviour.
Step 4: Block Abuse at the Server Level
Even after fixing the legitimate sources, admin-ajax.php is still a target for bots and brute-force tools. Unlike wp-login.php, it does not get as much attention in security hardening guides, but it processes POST requests that load the full WordPress stack — making it an attractive target for resource exhaustion attacks.
I added rate limiting in the nginx configuration:
# In the http block — define a rate limit zone
limit_req_zone $binary_remote_addr zone=ajax:10m rate=2r/s;
# In the server block — apply to admin-ajax.php
location = /wp-admin/admin-ajax.php {
limit_req zone=ajax burst=10 nodelay;
limit_req_status 429;
include fastcgi_params;
fastcgi_pass unix:/run/php/php-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
This allows 2 requests per second per IP with a burst tolerance of 10. Legitimate users will never hit this limit. Bots and misconfigured plugins on the frontend will. This kind of nginx-level tuning is standard on the servers I look after — it is part of what WordPress server management covers.
After applying the change:
nginx -t && systemctl reload nginx
Step 5: Verify the Fix with Query Monitor
Once the immediate fire was out, I installed Query Monitor to get ongoing visibility. Query Monitor has a dedicated AJAX panel that shows every admin-ajax request, the action that triggered it, how long it took, and how many database queries it ran.
In the browser dev tools Network tab, filter requests by admin-ajax and watch the waterfall. Each request now shows a qm response header with timing data. If you see any action consistently taking over 500ms or running more than 50 database queries, that is your next optimisation target.
The key metrics I check in Query Monitor for any AJAX action:
- Total query count — anything over 30 queries for a background poll is suspicious
- Query time — should be under 100ms for routine AJAX
- Peak memory — if a single AJAX action uses 80MB+ of RAM, it is loading something it should not
The Result
After these changes, the server's profile looked completely different:
| Metric | Before | After |
|---|---|---|
| admin-ajax.php requests/hour | ~4,200 | ~250 |
| Average CPU usage | 65% | 18% |
| PHP-FPM active workers (avg) | 7/8 | 2/8 |
| Admin page load time | 4-8 seconds | 1.2 seconds |
The intermittent slowdowns stopped entirely. The WooCommerce frontend, which had been collateral damage from the CPU saturation, went from 3-4 second page loads back to under a second.
The Same Problem on a Busier WooCommerce Store
On a WooCommerce store I looked at later — around 800 products and 150 orders a day, on a 4-core, 8GB VPS running CloudPanel, PHP 8.2-FPM, nginx, and MariaDB 10.11 — the same investigation turned up two culprits the first incident did not have. The scale was worse too: nearly 15,000 admin-ajax.php requests in a single hour, twelve of fifteen PHP-FPM workers active, seven requests stacked in the listen queue, and 341 slow requests logged in six hours. The hosting provider had suggested upgrading to a bigger VPS. The hardware was fine.
The PHP-FPM status page is worth checking before the access log on any FPM-based stack:
curl -s http://127.0.0.1/status?full | grep -E "active processes|listen queue|slow requests"
If the listen queue is non-zero, requests are waiting for a free worker — and something is eating the workers.
WooCommerce Cart Fragments on Every Page
The biggest WooCommerce-specific 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. Each request bootstraps the full WordPress stack, runs multiple database queries, and returns session-specific JSON — none of it cacheable.
On that store — 2,000 daily visitors averaging 4 pages each — that meant 8,000 cart fragment requests per day on top of actual page loads. The nopriv_ prefix in the AJAX log confirmed they were firing for logged-out visitors too, which is the majority of traffic on any ecommerce site.
The store used a header mini-cart showing the item count, so disabling fragments entirely would have broken the live count. Instead 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 dynamic mini-cart at all, dequeue wc-cart-fragments unconditionally and render the count server-side instead.
A Rogue Mailchimp Sync Plugin
The second culprit was a mailchimp_sync action firing 1,800 times in 30 minutes from logged-out visitors. A Mailchimp for WooCommerce integration was queuing a script on every frontend page load that pinged admin-ajax.php to check sync status — something that should only run in the admin, if at all. The plugin was two major versions behind; updating it fixed the leak, because the newer version had moved the sync check to a background Action Scheduler job.
This is a pattern I see regularly: plugins registering handlers on wp_ajax_nopriv_ hooks when they should use wp_ajax_ only, or no AJAX at all. Any nopriv handler fires for every logged-out visitor who triggers it, and those requests bypass page caching entirely.
With the Heartbeat throttled the same way as above, cart fragments restricted, and the plugin updated, that store's admin-ajax.php traffic dropped from ~15,000 to ~1,400 requests per hour — a 90% reduction. Active PHP-FPM workers went from 12 to 3, the listen queue went to zero, and page loads came back down to 1.8 seconds. Keeping plugin-level AJAX leaks like this from creeping back in is exactly what my WooCommerce maintenance plans exist for.
How to Check Your Own Site Right Now
You do not need to wait for a crisis. Run this on your server to see how many admin-ajax.php requests you are getting:
grep "admin-ajax.php" /var/log/nginx/access.log | wc -l
If that number divided by the hours in the log gives you more than 500 requests per hour on a small-to-medium site, something is polling too aggressively. Follow the diagnostic steps above to find out what.
If you do not have SSH access, open your site in an incognito window (logged out), open the browser dev tools Network tab, filter by admin-ajax, and browse a few pages for 60 seconds. You should see Heartbeat requests at a steady interval in the admin and very little on the frontend. Every frontend request you see is an uncacheable hit to your server from every visitor. On WooCommerce, also look for wc-ajax=get_refreshed_fragments in your access logs.
The frustrating thing about admin-ajax.php load is that it is invisible on paper. Your caching plugin reports a 95% hit rate because page caching is working perfectly — but AJAX requests bypass page caching by design. A site can look perfectly optimised while admin-ajax.php quietly eats all your PHP workers behind the scenes.
Do Not Forget to Clean Up
Remove the AJAX logger mu-plugin once you have finished diagnosing. It writes to disk on every AJAX request — useful for a 30-minute diagnostic session, but you do not want it running permanently.
rm wp-content/mu-plugins/ajax-logger.php
rm wp-content/admin-ajax.log
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.
