Diagnosing high admin-ajax.php load in WordPress

A hosting dashboard or APM trace that ranks admin-ajax.php as the top URL by request count or CPU time tells you almost nothing on its own. The file is a dispatcher: it routes every AJAX call WordPress plugins make to the right PHP handler. The real question is which action is generating the load, and why. This article covers how to identify the caller, fix the five most common causes, and avoid the fixes that make things worse.

What admin-ajax.php is and who calls it

admin-ajax.php is not a feature. It is a dispatcher. Every AJAX call a WordPress plugin makes lands on the same URL (/wp-admin/admin-ajax.php), and the file reads the action parameter from the POST body to decide which PHP function should handle the request. For logged-in users the hook is wp_ajax_{$action}, for anonymous visitors it is wp_ajax_nopriv_{$action}.

The performance cost is in the bootstrap. Every call to admin-ajax.php loads the full WordPress admin environment: core, all active plugins, all hooks, and the admin_init action. A trivial "return a JSON counter" action still boots tens of megabytes of PHP before it runs a single line of plugin code. The REST API (merged in WordPress 4.7, December 2016) skips that admin bootstrap and is typically 15–25% faster for equivalent operations, but admin-ajax.php is not deprecated. Hundreds of plugins still use it, and the Heartbeat API runs on it by design.

That makes "admin-ajax.php is your top URL" the equivalent of saying "your phone's home screen is your most-used app." The home screen is not the problem. The app behind it is.

admin-ajax.php vs wc-ajax vs the REST API

Not every AJAX request on a WordPress site actually goes through admin-ajax.php. WooCommerce introduced its own lightweight endpoint in version 2.4 (2015): /?wc-ajax=get_refreshed_fragments. This runs through the wp_loaded hook instead of the admin-ajax dispatcher, so it does not fire admin_init. The URL is different. The PHP bootstrap cost is similar but not identical.

If your access log or APM tool shows high traffic to /?wc-ajax= rather than /wp-admin/admin-ajax.php, the problem is WooCommerce-specific and the Heartbeat API is not involved.

The REST API (/wp-json/) is a third, separate endpoint. Block editor autosave in WordPress 5.0+ uses the REST API, not admin-ajax.php. Plugins increasingly migrate to it. If the load shows up under /wp-json/, this article does not apply.

Endpoint URL pattern Bootstrap Typical callers
admin-ajax.php POST /wp-admin/admin-ajax.php Full admin environment Heartbeat, legacy plugins, form plugins, analytics beacons
wc-ajax GET /?wc-ajax=<action> wp_loaded (lighter) WooCommerce cart fragments, add-to-cart
REST API GET/POST /wp-json/... REST bootstrap (lightest) Block editor, modern plugins, oEmbed

Symptoms that point to admin-ajax.php load

The problem surfaces differently depending on where you look:

In hosting dashboards. The "top URLs by request count" or "top URLs by CPU time" table shows /wp-admin/admin-ajax.php at the top, sometimes with thousands of hits per hour. This is the most common trigger for the email from your hosting provider. The problem: access logs only record the URL, not the POST body. The action= parameter that identifies the caller is invisible.

In APM tools (New Relic, Datadog). The transaction named WebTransaction/Action/admin-ajax.php appears at the top of "most time consuming" or "highest throughput" lists. New Relic traces this as a single transaction regardless of which action fires inside it. The New Relic Reporting for WordPress plugin (by 10up) augments transaction names with WordPress-specific metadata, which makes the slow action visible in the segment tree.

In browser developer tools. On the front end, DevTools Network tab shows repeated admin-ajax.php POST requests firing on every page load. The Payload tab reveals the action= value. If you see this on the public site (not in wp-admin), anonymous visitors are generating it too.

In server metrics. PHP-FPM worker count stays elevated even during off-peak hours, and the pool approaches saturation. Since admin-ajax.php requests are uncacheable POST requests, they each claim a PHP worker for the full duration of the response.

Identifying the caller

The core diagnostic problem is that standard web server access logs do not record POST bodies. The URL /wp-admin/admin-ajax.php appears on every line, but the action= parameter that identifies the responsible plugin is nowhere in the log. You need one of these approaches:

Browser DevTools (fastest for front-end traffic). Open Chrome or Firefox DevTools, switch to the Network tab, filter by Fetch/XHR, and reload the page or trigger the user flow. Click any admin-ajax.php row and open the Payload tab. The action field is visible in plain text.

Query Monitor (server-side, admin-only). Install Query Monitor and reproduce the flow. Its AJAX panel shows each request's action name, query count, memory usage, and hooks fired. Output is admin-only by default; use QM's auth cookie mechanism to test as a non-admin user.

WP Engine admin-ajax logging. On WP Engine hosting, add this to wp-config.php:

define( 'WPE_MONITOR_ADMIN_AJAX', true );

This writes POST bodies to wp-content/__wpe_admin_ajax.log. Parse it:

# Show the 20 most frequent actions
grep "action" wp-content/__wpe_admin_ajax.log \
  | sort | uniq -c | sort -rn | head -20

APM transaction tracing. In New Relic One: APM, then Transactions, sort by "Most time consuming", filter by admin-ajax.php, drill into a transaction trace, and look for the slow hook in the segment tree.

Finding the plugin from the action name. Once you have the action name (say adrotate_impression), the quickest approach is to search the action name online: a search for wp_ajax adrotate_impression almost always returns the plugin's documentation or support forum. If the action name starts with a recognizable plugin prefix (woocommerce_, elementor_, wpforms_), the owner is obvious.

If you have SSH access: search your plugin directory directly:

grep -r "wp_ajax_nopriv_adrotate_impression\|wp_ajax_adrotate_impression" \
  wp-content/plugins/

This tells you the exact plugin and file that owns the handler.

Fixing Heartbeat-dominated spikes

The Heartbeat API sends action=heartbeat to admin-ajax.php at default intervals of 15 seconds in the post editor and 60 seconds on other wp-admin screens. If your logs confirm heartbeat is the top action, the fix is throttling, not disabling.

Throttle the interval to 60 seconds everywhere:

// In a mu-plugin or functions.php
add_filter( 'heartbeat_settings', function( $settings ) {
    $settings['interval'] = 60; // allowed range: 15–300 seconds
    return $settings;
} );

Disable Heartbeat on admin screens where autosave does not matter (everything except the post editor):

add_action( 'admin_init', function() {
    $screen = get_current_screen();
    if ( $screen && $screen->base !== 'post' ) {
        wp_deregister_script( 'heartbeat' );
    }
} );

Do not disable Heartbeat entirely. That breaks autosave and post locking. With 50 editors in the post editor, you would go from roughly 200 uncacheable PHP requests per minute (at 15-second intervals) to about 50 (at 60-second intervals). If the Heartbeat API concept article applies to your situation, it covers the full detail.

Verification: after deploying the filter, open the post editor, open DevTools Network tab, and confirm that admin-ajax.php requests with action=heartbeat now appear at 60-second intervals instead of 15.

Fixing plugin-generated spikes

If the top action is not heartbeat but something like woocommerce_get_cart_fragments, adrotate_impression, a page builder auto-draft action, or a chat plugin session keepalive, the fix depends on the caller.

WooCommerce cart fragments

The woocommerce_get_cart_fragments action refreshes the mini-cart widget on every page load. On WooCommerce versions before 7.8, the wc-cart-fragments.js script was enqueued on every page regardless of whether a cart widget existed. WooCommerce 7.8 (2023) changed this to conditional loading: the script only fires when a Cart Widget is rendered or the shortcode is present.

Fix: update WooCommerce to 7.8+. If a third-party theme or plugin force-enqueues wc-cart-fragments, conditionally dequeue it on non-shop pages:

add_action( 'wp_enqueue_scripts', function() {
    if ( ! is_woocommerce() && ! is_cart() && ! is_checkout() ) {
        wp_dequeue_script( 'wc-cart-fragments' );
    }
}, 99 );

The WooCommerce Cart block (the block editor version, not the legacy widget) does not use the fragments API at all.

Analytics and ad-rotation plugins

Plugins that fire impression beacons or tracking events through admin-ajax.php for every visitor are the most expensive pattern. A real-world WP Engine admin-ajax log analysis found a single AdRotate installation generating 47,608 calls. The fix is usually replacing the plugin with one that uses client-side tracking or a REST API endpoint, or disabling the feature entirely.

Page builder auto-draft saves

Elementor, Avada, and Visual Composer all make admin-ajax.php calls for auto-draft saves and preview refreshes. These fire only for logged-in editors, so they only matter on sites with many concurrent backend users. Throttling is typically handled in the builder's own settings panel.

Verification for any plugin fix: after the change, monitor the admin-ajax log or your APM tool for 24 hours. The specific action's request count should drop to near-zero or to the expected rate.

Why caching admin-ajax.php responses is not the fix

POST requests to admin-ajax.php are uncacheable by design. Standard page caches (Nginx FastCGI cache, Varnish, WP Rocket, W3 Total Cache) correctly exclude them. Some CDN configurations can be forced to cache POST responses, but doing so for admin-ajax.php causes real damage:

  • Cached action=heartbeat responses mean all users share the same autosave state. Data loss risk.
  • Cached cart fragment responses mean all users see the same cart contents. A WooCommerce store serving one visitor's cart to another is a checkout disaster.
  • Cached nonce-gated actions serve stale nonces. WordPress nonces expire after 12–24 hours, and a cached nonce response breaks form submissions and security checks.

The fix is reducing the number of requests, not caching the responses. If a plugin's action is well-suited to caching, the correct path is migrating it to a GET-based REST API endpoint that can be cached safely.

For a full picture of how WordPress caching layers interact and where bypasses happen, see why your WordPress cache is not working.

Why blocking admin-ajax.php is not the fix

admin-ajax.php lives under /wp-admin/, but it is legitimately called from the public front end. Plugins that provide live search, AJAX-powered contact forms, cart updates, and infinite scroll all send requests to admin-ajax.php from anonymous visitors. Blocking the file breaks:

  • Every plugin AJAX feature on the front end
  • WordPress automatic update progress reporting
  • Any plugin using wp_ajax_nopriv_ for public-facing features

Rate limiting per IP at the web server or CDN level (Nginx limit_req_zone, Cloudflare rate rules) is the appropriate control for bot traffic hammering admin-ajax.php. Block bots; do not block the file.

When wp-cron over admin-ajax.php is the real issue

WordPress ships with ALTERNATE_WP_CRON, a mode where scheduled tasks fire through a redirect to admin-ajax.php?action=wp_cron. Some hosting providers enable this by default. The result is that every cron run (plugin updates checks, backup schedules, email queues, WooCommerce order cleanup) routes through admin-ajax.php and shows up in your access log under the same generic URL.

If your log analysis shows a high proportion of requests with action=wp_cron, the fix is replacing WP-Cron with a real system cron so scheduled tasks run on a predictable interval rather than piggybacking on visitor traffic.

When to escalate

If you have identified the action, applied the relevant fix, and admin-ajax.php load has not dropped meaningfully after 24 hours, collect the following before contacting your hosting provider or a developer:

  • The top 10 actions from your admin-ajax log or APM transaction breakdown
  • PHP version and WordPress version (visible in wp-admin under Tools, then Site Health, then Info; or via wp core version --extra if you have WP-CLI access)
  • Active plugin list (visible in wp-admin under Plugins; or via wp plugin list --status=active with WP-CLI)
  • Whether the traffic is frontend (anonymous visitors) or backend (logged-in users), visible from the Referer header in access logs
  • PHP-FPM pool configuration: pm, pm.max_children, pm.max_requests values (ask your hosting provider if you do not have direct access)
  • Whether ALTERNATE_WP_CRON is enabled in wp-config.php (check via the hosting panel's file manager or SFTP)
  • Steps you have already tried, with before/after request counts

How to prevent recurrence

  • Audit new plugins before activation. Check whether a plugin registers wp_ajax_nopriv_ actions that fire on every page load. Query Monitor shows this in real time.
  • Prefer plugins that use the REST API. The REST API skips the admin bootstrap. A plugin that uses /wp-json/ for its AJAX instead of admin-ajax.php consumes less CPU per request.
  • Set up a real system cron. Disabling ALTERNATE_WP_CRON and running WP-Cron from a crontab eliminates the entire class of cron-via-admin-ajax traffic.
  • Monitor the action breakdown, not just the URL. A monthly check of your top admin-ajax actions (via Query Monitor, APM, or a 15-minute WP Engine log analysis) catches new offenders before they become load problems.

Done chasing slowdowns?

Performance issues tend to come back after quick fixes. WordPress maintenance keeps updates, caching and limits consistent.

See WordPress maintenance

Search this site

Start typing to search, or browse the knowledge base and blog.