Guide
WordPress admin-ajax.php under heavy load — finding the action and the caller
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_Querywithmeta_queryjoins. A bot looping awp_ajax_nopriv_search_productsaction 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=heartbeatfires 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
noprivaction. - 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_scripton pages that don't need the feature. - Last resort: cache the AJAX response with
transientfor 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:
- 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. - 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', '...' );. - 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. - 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:
- Top action is
heartbeator another known-benign action. Other actions are at low double-digit request rates per minute or below. wp_ajax_duration_msp95 is under 500ms. Most histogram buckets below 500ms and nearly nothing in 2000ms+ indicates workers are turning over fast.- PHP-FPM
pm.statusshows idle workers.curl http://localhost/fpm-status(if exposed) — confirm active children are not pegged atpm.max_children. wp_admin_requests_totalandwp_logged_in_requests_totalare 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-ajax —
wp_cron_runs_totaldrops whilewp_ajax_requests_totalfor an internal action rises; symptom ofDISABLE_WP_CRONset without a real cron. - REST API enumeration via /wp-json/wp/v2/users — different surface, same intent; tracked via
wp_rest_requests_totalwithroute_group="users". - xmlrpc.php brute force — the older equivalent of admin-ajax abuse; surfaces as
wp_xmlrpc_requests_totalspikes correlated withwp_login_attempts_totalfailures. - Plugin update triggering frontend script bloat —
wp_state_changefor plugin update followed bywp_ajax_duration_msregression on a previously-fast action. - Memory exhaustion on slow admin-ajax handlers —
wp_ajax_duration_mslong tail correlated withphp_fatalevents ("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.