Skip to content

admin-ajax.php high CPU usage in WordPress: how to find what is calling it

admin-ajax.php is the address WordPress, plugins and themes use for background requests. High CPU from it means something calls it too often, or one kind of call is slow. Find the action behind the requests, fix whatever sends it, and do not block the file.

By
WP Ministry
Published

In short

  • The file is not the fault. Every request to it names an action, and the action tells you which plugin or feature is responsible.
  • The access log shows who is asking, how often and from which page. It shows the action only for GET requests.
  • For POST requests, read the action in your browser's Network panel, or log it on the server for an hour and then remove the logger.
  • The action heartbeat is WordPress's own. Slow it with the heartbeat_settings filter. Do not switch it off.
  • One address, no referrer and a steady high rate is a bot. Ask the host or a firewall in front of the site to limit the rate.
  • Do not deny, rename or cache admin-ajax.php. Plugin features on the public site, the editor and the dashboard depend on it.

admin-ajax.php is not a fault and not a threat. It is the one address WordPress, plugins and themes use for background requests, from the dashboard and from the public site. When your host says it is using too much CPU, something is calling it too often, or one kind of call is slow.

Find the action behind the requests, then deal with whatever sends it. Do not block the file. Parts of your site depend on it.

What admin-ajax.php does

A page uses AJAX to ask the server for something without reloading: to save a draft, send a form, load more results or check for new orders. WordPress's Plugin Handbook says all WordPress AJAX requests must be sent to wp-admin/admin-ajax.php.

Every request carries a field called action. The name is chosen by whoever wrote the code, and WordPress uses it to decide what runs.

Who is askingThe hook WordPress runs
Someone who is logged inwp_ajax_{action}
A visitor who is not logged inwp_ajax_nopriv_{action}

An action answers visitors only if WordPress, a plugin or a theme registered it under nopriv. An action nobody registered gets a 400 answer with a body of 0.

Three things explain the CPU.

  • Each request starts WordPress. The file loads WordPress before it looks at the action. A request that ends in a 400 has still cost that much.
  • No page cache answers for it. WordPress sends every answer from this file with headers that tell browsers and caches not to keep it. A page cache hands over a saved copy without building the page. A request here is built fresh each time.
  • Many features share one address. In a host's report, an editor's autosave, a contact form and a visitor counter are all the same file. The file name alone tells you nothing.

The file sits in /wp-admin/ and is public on purpose. The robots.txt WordPress generates disallows /wp-admin/ and then allows /wp-admin/admin-ajax.php by name.

Find out what is calling it

There are four places to look. Start with the access log, because it covers every visitor and needs no change to the site.

The access log: who asks, how often, from which page

Ask your host for the raw access log for the hours when CPU was high, or download it from the control panel. The commands below assume Apache's combined log format: the visitor's address, the time, the request line, the status code, the size, the referrer and the user agent, in that order. nginx's predefined combined format has the same order. If your host uses a format of its own, the field numbers differ.

Run the commands in a terminal, in the folder that holds access.log.

  1. Step 1: Count the requests by method and status

    This prints one line for each pairing, such as POST 200, with a count in front. A large count of 400s means requests for actions that do not exist.

    bash
    awk '$7 ~ /^\/wp-admin\/admin-ajax\.php(\?|$)/ { print substr($6, 2), $9 }' access.log | sort | uniq -c | sort -rn
  2. Step 2: Group them by address

    The 20 addresses that asked most often. Note whether one address stands far above the rest, or the count is spread across many.

    bash
    awk '$7 ~ /^\/wp-admin\/admin-ajax\.php(\?|$)/ { print $1 }' access.log | sort | uniq -c | sort -rn | head -n 20
  3. Step 3: Group them by the page that sent them

    The referrer is the page the browser was on when it made the request. "-" means the request named no page.

    bash
    awk '$7 ~ /^\/wp-admin\/admin-ajax\.php(\?|$)/ { print $11 }' access.log | sort | uniq -c | sort -rn | head -n 20
  4. Step 4: Group them by hour

    One line for each hour in the log. Compare the busy hours with the times your host gave you.

    bash
    awk '$7 ~ /^\/wp-admin\/admin-ajax\.php(\?|$)/ { print substr($4, 2, 14) }' access.log | uniq -c
  5. Step 5: List the actions sent by GET

    A GET request carries its action in the address, so the log holds it.

    bash
    awk '$7 ~ /^\/wp-admin\/admin-ajax\.php\?/ && match($7, /[?&]action=[^&]*/) { print substr($7, RSTART + 1, RLENGTH - 1) }' access.log | sort | uniq -c | sort -rn

The log cannot show the action of a POST. The combined format records the request line, which is the method, the address and the protocol. A POST carries its action in the body of the request, and the format does not record the body. Heartbeat sends POSTs, and the handbook's own example for plugin authors is a POST. For those, use the browser or the server, below.

The pattern in the log already narrows it down.

What the log showsWhat it points to
Many addresses with a few requests each, referrers that are your public pages, a count that rises and falls with visitorsA plugin or theme calling from the public site for every visitor
A handful of addresses, referrers under /wp-admin/People who are logged in: Heartbeat, or a plugin's own dashboard screen
One address or a few, a referrer of "-", a steady high rate, often status 400A bot or an attack
Few requests, and the host still names the fileOne slow action

The browser: which action, on which page

  1. Step 1: Open the Network panel in a private window

    A private window shows the site as a visitor who is not logged in gets it. Open the browser's developer tools and select the Network panel. The names below are Chrome's. Other browsers have a similar panel.

  2. Step 2: Filter to admin-ajax and wait

    Type admin-ajax in the Filter box. Load a public page and leave it open for two or three minutes. Then visit the pages your visitors use most.

  3. Step 3: Read the action

    Select a request and open the Payload tab, which shows the request's query string parameters and form data. Find action. The Time column shows how long each request took.

  4. Step 4: Do the same while logged in

    Repeat it on the dashboard screens you and your staff keep open.

One request as a page loads is ordinary. A request that repeats on a timer while you do nothing is the one to write down, with its action and the page it came from.

The server: log every action for an hour

Use this when the requests come from other people's browsers and are POSTs. It is a small must-use plugin that writes one line to the debug log for each request to admin-ajax.php.

  1. Step 1: Turn on the debug log, outside the website's folder

    Follow how to turn on WordPress debug mode, and give WP_DEBUG_LOG the full path of a file that is not inside the website's folder. WordPress's documentation notes that the debug log also takes what code writes with PHP's error_log() function, and gives debugging AJAX as a use for it.

  2. Step 2: Create the file

    Create the folder wp-content/mu-plugins if it is not there, and save this in it as log-ajax-actions.php. WordPress loads every PHP file that sits directly in that folder. Such a plugin cannot be switched off in the dashboard, only by removing the file.

    wp-content/mu-plugins/log-ajax-actions.php
    <?php
    /**
     * Plugin Name: Log admin-ajax actions (temporary)
     * Description: Writes one line to the PHP error log for each request to admin-ajax.php. Delete this file when you have your answer.
     */
    
    add_action(
    	'init',
    	function () {
    		if ( ! wp_doing_ajax() ) {
    			return;
    		}
    
    		$action = '(none)';
    		if ( isset( $_REQUEST['action'] ) && is_scalar( $_REQUEST['action'] ) ) {
    			$action = preg_replace( '/[^A-Za-z0-9_.-]/', '?', substr( (string) $_REQUEST['action'], 0, 80 ) );
    		}
    
    		$method = isset( $_SERVER['REQUEST_METHOD'] ) ? $_SERVER['REQUEST_METHOD'] : '';
    		$method = in_array( $method, array( 'GET', 'POST' ), true ) ? $method : 'OTHER';
    
    		$from = '-';
    		if ( ! empty( $_SERVER['HTTP_REFERER'] ) ) {
    			$path = wp_parse_url( wp_unslash( $_SERVER['HTTP_REFERER'] ), PHP_URL_PATH );
    			if ( is_string( $path ) && '' !== $path ) {
    				$from = preg_replace( '/[^A-Za-z0-9_.\/-]/', '?', substr( $path, 0, 120 ) );
    			}
    		}
    
    		error_log(
    			sprintf(
    				'ajax-log action=%s method=%s logged_in=%s from=%s',
    				$action,
    				$method,
    				is_user_logged_in() ? 'yes' : 'no',
    				$from
    			)
    		);
    	},
    	0
    );
  3. Step 3: Let it run through a busy period

    Give it an hour that includes the time of day your host named. A line looks like this:

    text
    [08-Oct-2026 14:05:09 UTC] ajax-log action=heartbeat method=POST logged_in=yes from=/wp-admin/edit.php
  4. Step 4: Count the actions

    Use the path you gave WP_DEBUG_LOG. The action at the top of the list is the one to chase.

    bash
    grep -o 'ajax-log action=[^ ]*' /home/example/logs/wp-errors.log | sort | uniq -c | sort -rn
  5. Step 5: See who sent each one, and from where

    The same count, split by method, by whether the person was logged in and by page.

    bash
    grep -o 'ajax-log .*' /home/example/logs/wp-errors.log | sort | uniq -c | sort -rn | head -n 20
  6. Step 6: Remove the file and turn the log off

    Delete log-ajax-actions.php. Then set WP_DEBUG back to false and delete the log, as the debug mode guide describes.

Query Monitor, for requests you make yourself

Query Monitor is a plugin in the WordPress.org directory. Its listing says the response to any jQuery-initiated AJAX request on the page carries debugging information in its headers, and that by default only administrators see its output. In the Network panel, select an admin-ajax.php request and read its response headers. Query Monitor's begin with x-qm, and they include the time the request took and the memory it used.

It helps with requests your own browser makes while you are logged in as an administrator. It does not see other people's.

The usual callers, and what to do about each

Heartbeat (action=heartbeat)

Heartbeat is WordPress's own. The Plugin Handbook calls it a simple server polling API: a script in the page sends a request on a timer, the server answers, and plugins can add to both. WordPress uses it to keep and check post locks, so two people do not edit one post unknowingly, to carry an autosave, and to notice that your login has expired.

Where it runs. WordPress loads it on dashboard screens. On the public site it runs only if a plugin or a theme loads it.

How often. The handbook describes a tick every 15 to 120 seconds. In the current code:

Where and whenThe interval
A dashboard screen60 seconds
The post editor and the Posts list10 seconds, to keep post locks current
Any tab that is hidden, or five minutes after your last mouse or keyboard activity120 seconds
Ten minutes after your last activityIt stops until you come back. On the Add and Edit Post screens that takes 60 minutes.

One person in one editing tab sends six requests a minute at most. Several people with several editing tabs each multiply that, and a plugin can attach work of its own to every beat.

Slow it. WordPress passes Heartbeat's settings through the heartbeat_settings filter. interval sets the ordinary rate. minimalInterval overrides every shorter interval, the editor's 10 seconds included. WordPress's own notes say it is for hosts that cannot handle frequent requests, where a user may exceed the allocated CPU time. Save this as a must-use plugin, with the same care as the file above.

wp-content/mu-plugins/slow-heartbeat.php
<?php
/**
 * Plugin Name: Slow the Heartbeat
 */

add_filter(
	'heartbeat_settings',
	function ( $settings ) {
		$settings['interval']        = 120;
		$settings['minimalInterval'] = 60;
		return $settings;
	}
);

With these values a dashboard screen asks every 120 seconds, and the editor and the Posts list every 60.

Do not switch it off. The same notes say a minimal interval above 120 seconds limits or disables features such as post locks, and that a post lock expires after 150 seconds. Without Heartbeat, post locks are not refreshed, the autosave it carries does not run, and the notice that your login has expired does not appear.

A plugin that calls from the public site for every visitor

This pattern grows with your traffic. A live visitor counter, a pop-up that checks whether to show itself, a social feed, a view counter, a chat widget: each can register a nopriv action and call it from every page. Your pages may come from a cache, yet every visitor still starts WordPress once, or once every few seconds.

In the log it shows as many addresses with public pages as referrers. Once you have the action's name, find the plugin it belongs to. The name may point at the plugin by itself. Otherwise, search the code, with the action in place of the_action_name:

bash
grep -rl "the_action_name" wp-content/plugins wp-content/themes

If nothing is listed, the name is built in code. Search for its first part instead.

Then, in order:

  1. Use the plugin's own settings. Look for how often it refreshes, whether the live feature can be switched off, and whether it can load only on the pages that use it.
  2. Confirm it. Deactivate the plugin on a staging copy and watch the Network panel again. How to find and fix a plugin conflict has a safe method.
  3. Replace it with a plugin that does the job without calling the server on a timer, and tell the author what you found.

Some plugins use the REST API in place of this file. WordPress's handbook describes it as a more predictable and structured way to interact with a site than admin-ajax. A plugin that calls it shows in the log under /wp-json/, and the same counting finds it.

Cart fragments in a store. WooCommerce's cart fragments script refreshes the cart widget without a page reload. On a current store its requests do not go to admin-ajax.php. WooCommerce builds its own address for them, so the log shows a line such as POST /?wc-ajax=get_refreshed_fragments. Count them with:

bash
awk 'match($7, /[?&]wc-ajax=[^&]*/) { print substr($7, RSTART + 1, RLENGTH - 1) }' access.log | sort | uniq -c | sort -rn

WooCommerce's developers say that since version 7.8 the script is loaded only where the cart widget is rendered, and that the Mini-Cart block does not use it. How to speed up a WooCommerce store covers the rest.

Dashboard tabs left open

Heartbeat mostly looks after itself here. A tab you worked in and then left slows to one request every two minutes, and stops ten minutes after your last activity, or after an hour on an editing screen. A tab that was opened in the background and never looked at has no last activity to count from, and can go on asking every two minutes.

A plugin's own dashboard screen can also have a timer that never slows or stops: live orders, analytics, a log viewer. In the access log either one is one of your own addresses, with a referrer under /wp-admin/, at hours when nobody is working. Close the tabs you are not using. If your own requests are being refused, see how to fix 429 Too Many Requests.

Bots and attacks

The log shows it plainly: one address or a few, no referrer, a steady rate far above any person's, and often a 400 for each request. Look at everything one address did, with the address in place of the example. The test is exact, so 203.0.113.5 does not also match 203.0.113.50.

bash
awk '$1 == "203.0.113.5" { print substr($6, 2), $7, $9 }' access.log | sort | uniq -c | sort -rn | head -n 20

An address that requested admin-ajax.php hundreds of times and never loaded a page is not a visitor.

Each of those requests starts WordPress, whatever the answer. So the fix belongs in front of WordPress.

  • Ask the host to limit the rate for that file by address. On nginx this is the limit_req module, which limits the rate of requests from a single IP address.
  • Use a firewall in front of the site. Cloudflare's rate limiting rules, for example, set a limit for requests that match an expression and an action to take when it is reached.
  • Do not rely on a firewall plugin for load. It runs after the request has reached your server. WordPress firewalls compared explains where each kind sits.

Password guessing is a different attack on different files. How to protect WordPress from brute force attacks covers it.

One slow action

Sometimes the count is modest and each call is expensive: a search, a report, an import, a request that waits on another company's server. The Time column in the Network panel shows it, and so do Query Monitor's headers.

Open the screen that sends the action with Query Monitor active. How to reduce server response time explains how to read which plugin, query or outside request took the time.

The database can also tell you. MySQL and MariaDB keep a slow query log, which is off by default, and by default counts a query as slow at 10 seconds. Ask your host whether they can switch it on for a day with a lower threshold.

What not to do

  • Do not deny the file in .htaccess or a firewall rule. Every action then gets a 403: the forms, searches and filters that plugins run through it for visitors, and Heartbeat and plugin screens in the dashboard. WordPress's hardening guide warns that even a password on the wp-admin folder can break the AJAX handler at wp-admin/admin-ajax.php.
  • Do not rename or move it. The address is built into WordPress and into every plugin that follows the handbook's rule.
  • Do not cache it. WordPress marks every answer from it as not to be kept. An answer belongs to one person at one moment: a form's result, a count, a login check.
  • Do not buy a larger plan before you know the caller. More CPU gives the same requests more room. How to tell whether the fault is the host or the site helps you decide.

Is it a hack?

A busy admin-ajax.php is not a sign of a break-in by itself. Plugins use it by design, and a bot that collects 400s has opened nothing.

These are worth a closer look:

  • Actions nobody on your side recognizes, answered with 200. WordPress answers 400 when no such action is registered. A 200 means some code ran.
  • POSTs from addresses that never loaded a page, answered with 200.
  • A nopriv action that changes something. A plugin that answers visitors has to check for itself who is asking. WordPress's documentation tells developers to protect such functions with a permission check and never to rely on a nonce for that. Update the plugin the action belongs to.

If any of these fits, run the checks in how to check whether your WordPress site has been hacked. If one comes back positive, follow the hacked site runbook.

What to tell your host, and what to ask for

A host's notice may give a file name and little else. Their logs hold the rest. Replace the words in capitals.

text
Subject: CPU usage on DOMAIN: details of the admin-ajax.php requests

You wrote on DATE that DOMAIN is using too much CPU and that
/wp-admin/admin-ajax.php is the busiest file.

So that I can fix the cause, could you send me:
1. The time window in which the limit was reached, with the time zone.
2. The addresses that requested admin-ajax.php most often in that
   window, with a count for each.
3. The referrers of those requests, with a count for each.
4. For GET requests, the action in the address.
5. The raw access log for that window, if I cannot download it.

And could you tell me:
6. Whether you can limit the rate of requests to that file by address.
7. Whether the slow query log can be switched on for a day.

If visitors already see errors when the limit is reached, how to fix 503 Service Unavailable covers what to do first.

When to get help

Get help when the action belongs to a plugin the business cannot do without, when the slow part is a database query, or when you have no access to the logs and the host will not send them. Our speed optimization service is a one-time job on a WordPress site that has become slow: you name the pages, and we measure them before the work and again after it.

For the rest of what makes a site fast, start with how to speed up a slow WordPress site.

Common questions

Is admin-ajax.php a virus, and can I delete it?

No. It is a file that comes with WordPress, and WordPress, plugins and themes send their background requests to it. If you delete it, every one of those requests fails.

Is it safe to block admin-ajax.php?

No. Blocking it stops every feature that uses it, for visitors and in the dashboard. If the trouble is one address sending too many requests, limit the rate for that address in front of WordPress. If it is one plugin, change that plugin's setting or replace it.

Should I disable the Heartbeat API?

Slow it instead. Heartbeat keeps post locks current, saves drafts in the classic editor and tells you when your login has expired. A minimal interval of 60 seconds cuts its requests on editing screens to a sixth and keeps all three working.

Why does admin-ajax.php appear in my speed test?

Because the page made a background request while it loaded. One request in a test is not a CPU problem. If it is slow, its action names the plugin responsible, and the Network panel shows the action.

Will a caching plugin fix it?

Not by itself. A page cache answers for pages, and WordPress marks every answer from admin-ajax.php as not to be cached. With the pages cached, requests to this file can be most of what is left for the server to do.

More on this subject

Speed optimization, done for you

Speed optimization is $149. Before-and-after measurements on named pages. It starts with a free diagnosis.