TechEarl

admin-ajax.php High Traffic: Attack or Normal?

Thousands of hits to wp-admin/admin-ajax.php are almost always your own site: WordPress Heartbeat and plugins, not a DDoS. How to read the action parameter, when it is a real attack, and why blocking the file breaks your site.

Ishan Karunaratne⏱️ 10 min readUpdated
Share thisCopied
Thousands of hits to admin-ajax.php are usually WordPress Heartbeat and plugins, not an attack. How to read the action parameter and the fix that does not break your site.

A wall of POST /wp-admin/admin-ajax.php in your access log, or a spike of it in a security plugin's report, is almost always your own site, not an attacker. WordPress and its plugins use that one file as the front-door for a lot of their background requests: the editor's post-lock heartbeat, page builders, contact forms, "load more" buttons, analytics pings, and WooCommerce's cart widget. (Modern WordPress also leans on the REST API, so admin-ajax.php is no longer the only AJAX path, but it is still where most plugin traffic lands.) The traffic looks alarming because it is high-volume and all aimed at a single endpoint, which is exactly what a Layer-7 flood looks like too.

So before you block anything: the count alone tells you almost nothing. What tells you whether it is normal or an attack is the action parameter on each request. This article shows how to read it, the handful of cases where admin-ajax.php genuinely is the attack surface, and why the most common "fix", denying the file in .htaccess or a firewall, quietly breaks your editor and every plugin that depends on it.

Where the traffic comes from: Heartbeat and friends

admin-ajax.php is not a bug or a backdoor. It is the documented WordPress way to do server-side AJAX. The plugin handbook tells every developer to send AJAX to wp-admin/admin-ajax.php with an action parameter, which WordPress dispatches to a wp_ajax_{action} hook (for logged-in users) or a wp_ajax_nopriv_{action} hook (for everyone else). That is why traffic from completely unrelated plugins all converges on the same URL.

The single biggest source on most sites is the Heartbeat API, added in WordPress 3.6 (August 2013). Heartbeat POSTs to admin-ajax.php with action=heartbeat on a timer, and the default cadence trips people up:

  • In the post editor: every 15 seconds. This is the post-lock refresh (wp-refresh-post-lock) that powers the "another user is editing this post" warning, not autosave. Autosave to the server has its own interval, AUTOSAVE_INTERVAL, which defaults to 60 seconds.
  • On the dashboard and other admin screens: every 60 seconds.
  • On the front end: off by default. Core Heartbeat does not run for logged-out visitors unless a plugin or theme explicitly enqueues it.

That last point matters: if your front-end shows heavy admin-ajax.php traffic, core Heartbeat is not the cause. A plugin is. Leave the editor open in a background tab on a busy admin and Heartbeat alone can be a few requests a minute per logged-in user, which adds up fast across a team.

The other notorious source is WooCommerce cart fragments. The wc-cart-fragments.js script refreshes the mini-cart on page load by calling get_refreshed_fragments. Older WooCommerce routed that through admin-ajax.php; WooCommerce 2.4 (2015) moved it to a dedicated ?wc-ajax=get_refreshed_fragments endpoint to avoid loading all of wp-admin on every request, and WooCommerce 7.8 (2023) stopped enqueuing the script on pages without a cart widget. On any store between those versions, an uncached POST fires on most front-end pageviews. Same conceptual problem as Heartbeat, different endpoint name in the log.

Add page builders (Elementor, Divi), form plugins, infinite scroll, and stats plugins, and a normal, healthy WordPress site generates a steady stream of admin-ajax.php hits all day. None of it is an attack.

Find the culprit: read the action parameter

The count is noise. The action value is the signal. The catch is that a standard access log only records the request line (POST /wp-admin/admin-ajax.php HTTP/1.1), and the action rides in the POST body, so the log shows a wall of identical lines with nothing to tell them apart.

The reliable way to see what is firing is the browser. Open DevTools, go to the Network tab, filter to admin-ajax.php, and watch. Each request's payload carries the action:

Browser DevTools Network tab filtered to admin-ajax.php, showing repeated POST requests on a timer, with one selected request's form data revealing action equals heartbeat
The DevTools Network tab is the honest source: filter to admin-ajax.php and read the action in each request payload. A heartbeat every 15 seconds in the editor is WordPress working as designed.

Once you can see the actions, the diagnosis is usually obvious:

  • action=heartbeat on a regular cadence, on admin screens, by logged-in users: core Heartbeat. Normal.
  • action=get_refreshed_fragments (or ?wc-ajax=get_refreshed_fragments) on every front-end page: WooCommerce cart fragments. Normal, often tunable.
  • action=elementor_*, action=*_load_more, a form plugin's action: that plugin doing its job. Normal.

To sanity-check the volume rather than just the action: one editor left open polls every 15 seconds, which is roughly 240 hits an hour from that single tab. A few editors plus cart fragments on a busy store reach into the thousands a day, and that is still normal. The raw count almost never tells you anything on its own. And when the action name is generic, the payload often names the culprit anyway: nonce fields and parameters frequently carry the plugin's slug (a fusion-* nonce points at Avada, for instance), so expand the request body, not just the action column.

Some managed hosts log the action separately so you can rank it from the shell. WP Engine, for example, keeps a dedicated admin-ajax log, and the one-liner there is the pattern worth knowing in general:

bash
grep "action" admin-ajax.log | sort | uniq -c | sort -rn | head

The plugin Query Monitor will also surface AJAX calls with their actions if you would rather not leave the browser. Whichever route, the goal is the same: turn "lots of admin-ajax.php" into "lots of this specific action", because that is what you can actually act on.

Is it ever a real attack?

Yes, and this is the part the "it's always just Heartbeat, ignore it" advice gets wrong. admin-ajax.php is a genuine attack surface in two ways.

The serious one is unauthenticated actions in vulnerable plugins. A wp_ajax_nopriv_{action} hook is public by design: registering it exposes that action to anyone on the internet through admin-ajax.php. That is fine until the handler does something sensitive without checking the caller's capabilities. (A nonce guards against cross-site request forgery; it is not an access-control check and does not make a nopriv action private.) This is one of the most common shapes of WordPress vulnerability, and new ones ship regularly: unauthenticated settings changes, privilege escalation, and SQL injection have all reached production through a nopriv handler that ran a privileged operation without verifying who was asking. If your logs show a flood of one unusual action, especially from logged-out requests across many IPs, that is worth investigating as an attack rather than dismissing.

The cruder one is Layer-7 flooding. Because admin-ajax.php exists on every WordPress install and bootstraps the full stack on each call, it is a convenient target for a request flood designed to exhaust CPU and database connections. Here the action may be missing or junk; the tell is volume from many sources with no legitimate pattern.

The honest rule: high admin-ajax.php traffic is usually your own Heartbeat and plugins, but "usually" is not "always". Read the action. A familiar action on the expected screens is your site. A strange action, hammered unauthenticated, is a lead.

What not to do (and why)

The instinct, especially after a security plugin flags the hits, is to block the file. Don't:

Proposed actionVerdictWhy
Deny admin-ajax.php in .htaccess or a firewallDon'tThis is the entry point for most WordPress AJAX. Blocking it breaks Heartbeat post-locking (the editing-collision warning), the classic editor, and every plugin's front-end and back-end AJAX. The block editor autosaves over the REST API so it survives, but plenty around it does not, and you get a site that silently fails to load dynamic content.
Disable admin-ajax.php entirely / rename itDon'tSame breakage, harder to undo, and core and most plugins hardcode the path.
Block all wp_ajax_nopriv_ requests site-wideCarefulBlocking unauthenticated AJAX is a narrower hardening step, but plenty of legitimate front-end features (search-as-you-type, add-to-cart, public forms) run through nopriv. Block by specific action after you know what your site uses, not wholesale.

Blocking the endpoint treats the symptom (a high number) and breaks the function. The number was never the problem.

The right fixes

Once you know which action is responsible, the fix is targeted:

  • Throttle Heartbeat where it is safe. The Heartbeat Control plugin lets you set the interval, or disable Heartbeat, per location: keep it running in the post editor (where post-locking genuinely matters), and slow it down or turn it off on the dashboard and front end. Most performance caching plugins (WP Rocket and others) include the same control. Do not kill it everywhere by reflex; you lose the editing-collision warning (and on the classic editor, its autosave).
  • Tame WooCommerce cart fragments. On modern WooCommerce (7.8+) the script is already scoped to pages with a cart widget. On older stores, disable cart fragments on pages that have no cart (the homepage, blog posts), or move to a server-side mini-cart. This removes a per-pageview POST from your busiest pages.
  • Move legitimately heavy AJAX to the REST API. If you are writing the code, a custom REST API endpoint is the modern home for anything substantial, with proper authentication on write endpoints. admin-ajax.php bootstraps wp-admin on every call; a REST route does not have to. WooCommerce's own wc-ajax endpoint exists for exactly this reason.
  • Cache and rate-limit, don't block. If you are genuinely being flooded, a WAF or CDN rate-limit on admin-ajax.php (allow a sane request rate, challenge the rest) protects the endpoint without breaking it. That is different from denying it outright.

The throughline: identify the action, then act on that action. "admin-ajax.php is busy" is a starting point, not a diagnosis, and definitely not a reason to wall off the file your site depends on.

Sources

Authoritative references this article was fact-checked against.

TagsWordPressadmin-ajax.phpHeartbeat APIWooCommercePerformanceSecurityDDoSAJAX

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Software Systems Architect · Senior Software Engineer · Engineering Leadership

Software systems architect and senior software engineer with more than two decades designing, building, and running production software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Now a CTO, though what I write here is drawn from the full arc of that work, across architecture, engineering, and operations, not any single job.

Keep reading

Related posts

Fifty sysadmin jokes covering reboots, ping, DNS, undocumented cron jobs, the cable nobody dares unplug, and on-call at 4 a.m.

Sysadmin Jokes That Hit Too Close to Home

Fifty sysadmin jokes about reboots, undocumented cron jobs, ping packet loss, the printer that hates you, and the cable nobody dares unplug. The whole job in punchlines.

WP-CLI patterns that compose with AI: multi-step plans, generated scripts, database surgery, content migrations, what to never delegate. Real commands, real limits.

Using AI with WP-CLI for Faster WordPress Operations

The WP-CLI patterns that compose well with AI assistants: multi-step plans with checkpoint approval, generated one-off scripts, database surgery, content migrations at scale, and what to never delegate.