Guide

WordPress admin-ajax.php under heavy load — finding the action and the caller

Your WordPress site is sluggish. Load average is climbing, PHP-FPM workers are saturated, and every other line in the access log is a POST /wp-admin/admin-ajax.php returning 200 in 600–4000 ms.

1. Problem

Your WordPress site is sluggish. Load average is climbing, PHP-FPM workers are saturated, and every other line in the access log is a POST /wp-admin/admin-ajax.php returning 200 in 600–4000 ms. The dashboard "feels normal" — pages render, plugins look fine — but the server is on its knees and your host just emailed about CPU. You searched "wordpress admin-ajax.php hammering server" because the access log shows the URL but not what is actually being called.

The problem with admin-ajax.php is the URL is the same for everything. WordPress core uses it. Heartbeat uses it. Autosave uses it. Half your plugins use it. Bots have learned that unauthenticated wp_ajax_nopriv_* actions are a soft target for amplification — one cheap HTTP request, one expensive WP_Query on the server. From nginx's perspective it is all the same endpoint. From WordPress's perspective every request is a different action parameter doing wildly different work.

"POST /wp-admin/admin-ajax.php HTTP/1.1" 200 117 "-" "Mozilla/5.0..." rt=2.847
"POST /wp-admin/admin-ajax.php HTTP/1.1" 200 32  "-" "python-requests/2.31" rt=1.204
"POST /wp-admin/admin-ajax.php HTTP/1.1" 200 89  "-" "Mozilla/5.0..." rt=0.041

You need a per-action breakdown of what is hitting that file, how long each call takes, and whether the caller is logged in or anonymous. Without that breakdown you are guessing. This guide shows how to get it from logs, then how to tell legitimate plugin polling from abuse.

2. Impact

admin-ajax.php hammering is rarely the headline incident. It is the silent multiplier that makes other incidents worse.

  • PHP-FPM saturation. Each admin-ajax call holds a worker for the full duration. A 2-second action firing 50 times per second needs 100 workers just for itself. Real page loads queue behind it and time out.
  • Database CPU. Most expensive admin-ajax actions issue uncached WP_Query with meta_query joins. A bot looping a wp_ajax_nopriv_search_products action for ten minutes can pin RDS CPU at 100% and trigger autoscaling that costs real money.
  • Cache layer bypass. Page caches (Cloudflare, Varnish, WP Rocket) deliberately do not cache admin-ajax responses. Every request goes to origin and bypasses the protection you paid for.
  • Security exposure. Unauthenticated wp_ajax_nopriv_* actions in older plugins are a known vector for SQL injection and privilege escalation. The same traffic pattern that hammers the server is also the reconnaissance phase of an exploit attempt — and you cannot tell which is which without the action name.
  • Cascading 5xx. When workers are exhausted, the next legitimate visitor gets a 502 and uptime monitors flip red. The root cause is one plugin or one abused action; the symptom is "the site is down".

3. Why It’s Hard to Spot

Several reasons admin-ajax overload sneaks past normal monitoring:

  • One URL, many semantics. Standard APM tools record the URL only. Every admin-ajax call shows as POST /wp-admin/admin-ajax.php. The action — the only field that matters for diagnosis — is in the request body.
  • Status codes are misleading. Admin-ajax returns 200 even when the handler returns an error. A bot probing a vulnerable action that responds with wp_send_json_error() gets a 200 OK. Status-code dashboards show green while the server burns.
  • Heartbeat noise. action=heartbeat fires every 15 seconds per open /wp-admin/ tab. On a site with five editors that is 1200 calls per hour from heartbeat alone. It is easy to assume "30,000 admin-ajax requests today" is the baseline when 28,000 are heartbeat and 2,000 are abuse.
  • Plugin polling looks like attack traffic. Security scanners, cart fragments, real-time chat poll admin-ajax aggressively from the frontend. Many requests per second from many IPs is identical in shape to a botnet hitting a nopriv action.
  • No native breakdown. WordPress core has no per-action metrics. Query Monitor shows the current request only. New Relic and DataDog roll admin-ajax up as a single transaction unless you write custom instrumentation.
  • Cache layer hides volume. If admin-ajax is excluded from your CDN (it should be), the CDN dashboard never sees the traffic. You are looking at the wrong dashboard.

4. Cause

/wp-admin/admin-ajax.php is WordPress's universal AJAX router. It accepts POST and GET requests with an action parameter, then dispatches to whichever plugin or core handler registered that action. There are two registration hooks:

  • wp_ajax_{action} — runs only for logged-in users. Requires a valid auth cookie.
  • wp_ajax_nopriv_{action} — runs for unauthenticated visitors. No auth required. This is the surface bots target.

When a request hits admin-ajax.php, WordPress boots the full stack — autoload, plugins, theme functions.php — and fires the matching hook. There is no lightweight path. A trivial request loads as much code as a 200-element product grid.

The Logystera WordPress plugin emits a wp_ajax_requests_total counter for every admin-ajax call, with labels for action, authenticated (true/false), and status. It also emits wp_ajax_duration_ms as a histogram of execution time per action. The supporting signal wp_admin_requests_total covers all /wp-admin/* traffic, and wp_logged_in_requests_total separates authenticated from anonymous load.

A healthy site shows wp_ajax_requests_total{action="heartbeat"} dominating the breakdown — heartbeat fires every 15–60 seconds per open admin tab. A sick site shows a single non-heartbeat action with thousands of requests per minute, often with authenticated="false" and wp_ajax_duration_ms stretched into the seconds. That is the fingerprint of either a runaway plugin or active abuse.

5. Solution

5.1 Diagnose (logs first)

When you ask "wordpress admin-ajax slow what is calling it" the answer is always: get the per-action breakdown. There is no shortcut.

1. Rank actions by request count. Most nginx setups do not log POST bodies by default. A faster first pass: grep the action out of the query string, since many frontends send action= as a query param even on POST:

tail -f /var/log/nginx/access.log | grep "admin-ajax.php"
awk '$7 ~ /admin-ajax/ {print $0}' /var/log/nginx/access.log \
  | grep -oE "action=[a-z_]+" | sort | uniq -c | sort -rn | head -20

You are reconstructing what would otherwise be a wp_ajax_requests_total per-action breakdown. The output ranks actions by request count. If the top action is heartbeat, that is fine. If the top action is search_products, get_user_data, or anything plugin-specific with five-figure counts, that is your culprit.

2. Capture POST bodies for the slow ones. The action is in the POST body, not the URL. With nginx, log the body for admin-ajax temporarily:

log_format ajaxlog '$remote_addr "$request" rt=$request_time body=$request_body';
location = /wp-admin/admin-ajax.php {
    access_log /var/log/nginx/ajax.log ajaxlog;
    fastcgi_pass unix:/run/php/php8.2-fpm.sock;
    # ... rest of fastcgi config
}

Reload nginx, wait a minute, then:

grep "rt=[2-9]\." /var/log/nginx/ajax.log \
  | grep -oE "action=[a-z_]+" | sort | uniq -c | sort -rn

This lists actions that took more than 2 seconds. Each row corresponds to a wp_ajax_duration_ms histogram bucket above 2000ms. Slow + frequent is the worst combination.

3. Match against PHP-FPM slow log. If you have request_slowlog_timeout enabled (you should — set it to 5s), it captures the PHP backtrace of any request slower than the threshold:

grep -B1 -A30 "admin-ajax.php" /var/log/php-fpm/www.slow.log | head -100

The backtrace ends in the action handler — typically wp_hook->do_action() calling something like MyPlugin\Search::handle_ajax(). That tells you which plugin and which function. Each entry here is a wp_ajax_duration_ms outlier above 5 seconds.

4. Distinguish authenticated from anonymous. This is the legitimate-vs-abuse line. Check whether the request carried a wordpress_logged_in_* cookie:

grep "admin-ajax.php" /var/log/nginx/access.log \
  | awk '{ if ($0 ~ /wordpress_logged_in/) print "AUTH"; else print "ANON" }' \
  | sort | uniq -c

A healthy ratio looks like the live editor count divided by total — heartbeat dominates AUTH, plugin frontend polling dominates ANON but at modest rates. If ANON is 95%+ and rates are high, you are looking at wp_ajax_nopriv_* abuse. This is the same split that wp_logged_in_requests_total versus the anonymous remainder of wp_admin_requests_total exposes natively.

5. Source IP cardinality. Legitimate plugin polling comes from many IPs (real visitors). Abuse comes from many IPs too (botnets) but with telltale patterns — same User-Agent, same body length, no Referer:

grep "admin-ajax.php" /var/log/nginx/access.log \
  | grep "action=SUSPECT_ACTION" \
  | awk '{print $1}' | sort | uniq -c | sort -rn | head -20

If 90% of traffic to one action comes from 3 IPs, it is probably a misconfigured plugin or a single bad actor — easy to block. If it is spread across thousands of IPs with no geographic pattern, it is a botnet — needs a different response.

The pattern: wp_ajax_requests_total ranked by action gives you the suspect. wp_ajax_duration_ms confirms it is expensive. The authenticated split tells you whether it is plugin behavior or abuse. All three together are the diagnosis.

5.2 Root Causes

(see root causes inline in 5.3 Fix)

5.3 Fix

Fixes depend on what the breakdown reveals. The first job is classifying which bucket the offending action falls into.

Cause 1: Heartbeat polling too frequently

If action=heartbeat is the top action and wp_ajax_duration_ms for it is normal (under 100ms) but volume is excessive, the issue is editor count and heartbeat interval, not abuse.

Signal: wp_ajax_requests_total{action="heartbeat", authenticated="true"} dominates and wp_logged_in_requests_total is high.

Fix: raise the heartbeat interval in functions.php or a mu-plugin:

add_filter( 'heartbeat_settings', function( $settings ) {
    $settings['interval'] = 60; // default 15
    return $settings;
});

For sites with many concurrent editors, also disable heartbeat on the dashboard and frontend, leaving it only on post-edit screens where autosave needs it.

Cause 2: A plugin polling its own admin-ajax action from the frontend

Signal: a single plugin-specific action with authenticated="false" and steady, high request rate. wp_ajax_duration_ms may be moderate but the volume crushes throughput. Often correlated with no spike in wp_admin_requests_total because the calls come from the public site, not wp-admin pages.

Common offenders: WooCommerce cart fragments (wc_ajax_get_refreshed_fragments), real-time notification plugins, "live visitor count" widgets.

Fix:

  • For WooCommerce cart fragments, dequeue the script on non-cart pages or cache the response.
  • For other plugins, check settings for a polling interval and raise it. If none exists, dequeue the frontend script with wp_dequeue_script on pages that don't need the feature.
  • Last resort: cache the AJAX response with transient for 30–60 seconds inside the action handler.

Cause 3: Unauthenticated abuse of a wp_ajax_nopriv_* action

Signal: wp_ajax_requests_total{action="X", authenticated="false"} spiking, wp_ajax_duration_ms for that action well above baseline, source IPs distributed widely. Often coincides with stable or even reduced wp_logged_in_requests_total (real users are getting timeouts).

Fix in this order:

  1. Rate-limit at the edge. Cloudflare WAF rule: http.request.uri.path eq "/wp-admin/admin-ajax.php" and http.request.body.raw contains "action=THE_ACTION" → challenge or block.
  2. Disable the action if not needed. If the abused action is from a plugin you don't use, deactivate the plugin. If you need the plugin but not that action, remove it explicitly: remove_action( 'wp_ajax_nopriv_THE_ACTION', '...' );.
  3. Add nonce verification. If the action is one of yours and shouldn't be reachable without a valid nonce, enforce check_ajax_referer() and require a valid _wpnonce. Bots without a session cannot generate one.
  4. Patch or remove vulnerable plugins. Cross-reference the action against WPScan and Patchstack databases. Many wp_ajax_nopriv_* abuse cases are CVEs with public exploits.

Cause 4: A scheduled job firing through admin-ajax

Some plugins schedule periodic work via a frontend-triggered AJAX call instead of WP-Cron. If wp_ajax_duration_ms shows long-tail multi-second runs but request volume is low (one or two per minute), this is likely a misconfigured maintenance task.

Fix: move the work to WP-Cron or to a real cron-triggered wp-cli command. AJAX is not a job runner.

Cause 5: Logged-in admin running a long action

Signal: wp_ajax_requests_total{authenticated="true"} correlated with one IP, wp_ajax_duration_ms very high for one or two requests. wp_admin_requests_total shows the same admin user active.

This is usually an editor running a bulk import, an SEO scan, or a media regeneration. Not abuse, but it is hogging workers. Fix: provide that admin a CLI path (wp media regenerate, wp import) so the work runs outside PHP-FPM.

5.4 Verify

After applying the fix, the wp_ajax_requests_total breakdown should re-shape within minutes.

awk '$7 ~ /admin-ajax/ {print $0}' /var/log/nginx/access.log \
  | grep -oE "action=[a-z_]+" | sort | uniq -c | sort -rn | head -10

Healthy state:

  1. Top action is heartbeat or another known-benign action. Other actions are at low double-digit request rates per minute or below.
  2. wp_ajax_duration_ms p95 is under 500ms. Most histogram buckets below 500ms and nearly nothing in 2000ms+ indicates workers are turning over fast.
  3. PHP-FPM pm.status shows idle workers. curl http://localhost/fpm-status (if exposed) — confirm active children are not pegged at pm.max_children.
  4. wp_admin_requests_total and wp_logged_in_requests_total are stable. No more spikes correlated with admin-ajax volume.

Watch for 30 minutes under normal traffic. If wp_ajax_requests_total for the offending action stays low and wp_ajax_duration_ms p95 holds under 500ms, the fix held. If volume creeps back up — particularly with authenticated="false" — the rate-limit is being bypassed and you need a more specific WAF rule.

For ongoing health, no single non-heartbeat action exceeding 100 requests per minute is a reasonable baseline. Crossing that without explanation is the canary.

6. How to Catch This Early

Fixing it is straightforward once you know the cause. The hard part is knowing it happened at all.

This issue surfaces as wp_ajax_requests_total.

admin-ajax abuse stays invisible because nobody runs awk against access logs preemptively. The shape of the traffic — same URL, 200 status, modest individual response time — disguises the aggregate impact. By the time someone investigates, PHP-FPM is saturated and the site is throwing 502s.

The detection is not exotic. wp_ajax_requests_total broken down by action and authenticated tells you what is being called and by whom. wp_ajax_duration_ms tells you how expensive each call is. wp_admin_requests_total and wp_logged_in_requests_total provide the context — admin work, plugin polling, or anonymous abuse.

The Logystera WordPress plugin emits these signals on every admin-ajax request. Rules detect a single non-heartbeat action exceeding its baseline rate, or wp_ajax_duration_ms p95 climbing into the seconds, and surface the action name in the alert. You get "action search_products jumped from 5 rpm to 800 rpm with 92% anonymous and p95 of 3.4s" — not "your server is slow".

The site does not need a new APM contract. It needs the per-action breakdown the access log refuses to give you, surfaced before PHP-FPM saturates rather than after.

7. Related Silent Failures

  • WP-Cron stalled, work moved to admin-ajaxwp_cron_runs_total drops while wp_ajax_requests_total for an internal action rises; symptom of DISABLE_WP_CRON set without a real cron.
  • REST API enumeration via /wp-json/wp/v2/users — different surface, same intent; tracked via wp_rest_requests_total with route_group="users".
  • xmlrpc.php brute force — the older equivalent of admin-ajax abuse; surfaces as wp_xmlrpc_requests_total spikes correlated with wp_login_attempts_total failures.
  • Plugin update triggering frontend script bloatwp_state_change for plugin update followed by wp_ajax_duration_ms regression on a previously-fast action.
  • Memory exhaustion on slow admin-ajax handlerswp_ajax_duration_ms long tail correlated with php_fatal events ("Allowed memory size exhausted") in handler stack traces.

See what's actually happening in your WordPress system

Connect your site. Logystera starts monitoring within minutes.

Logystera Logystera
Monitoring for WordPress and Drupal sites. Install a plugin or module to catch silent failures — cron stalls, failed emails, login attacks, PHP errors — before users report them.
Company
Copyright © 2026 Logystera. All rights reserved.