Why admin-ajax.php Was Eating 40% of a Client's Server CPU

· 10 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.

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.

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.

And if you do not have SSH access, open your browser dev tools on any admin page, go to the Network tab, filter by admin-ajax, and just watch for 60 seconds. You should see Heartbeat requests at a steady interval. If you see a flood of other requests between them, you have found your problem.

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.

View Maintenance Plans Get in Touch

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.

View Maintenance Plans Get in Touch

Get in Touch to Discuss Your Needs