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 asking | The hook WordPress runs |
|---|---|
| Someone who is logged in | wp_ajax_{action} |
| A visitor who is not logged in | wp_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.
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.bashawk '$7 ~ /^\/wp-admin\/admin-ajax\.php(\?|$)/ { print substr($6, 2), $9 }' access.log | sort | uniq -c | sort -rnStep 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.
bashawk '$7 ~ /^\/wp-admin\/admin-ajax\.php(\?|$)/ { print $1 }' access.log | sort | uniq -c | sort -rn | head -n 20Step 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.bashawk '$7 ~ /^\/wp-admin\/admin-ajax\.php(\?|$)/ { print $11 }' access.log | sort | uniq -c | sort -rn | head -n 20Step 4: Group them by hour
One line for each hour in the log. Compare the busy hours with the times your host gave you.
bashawk '$7 ~ /^\/wp-admin\/admin-ajax\.php(\?|$)/ { print substr($4, 2, 14) }' access.log | uniq -cStep 5: List the actions sent by GET
A GET request carries its action in the address, so the log holds it.
bashawk '$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 shows | What it points to |
|---|---|
| Many addresses with a few requests each, referrers that are your public pages, a count that rises and falls with visitors | A 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 400 | A bot or an attack |
| Few requests, and the host still names the file | One slow action |
The browser: which action, on which page
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.
Step 2: Filter to admin-ajax and wait
Type
admin-ajaxin the Filter box. Load a public page and leave it open for two or three minutes. Then visit the pages your visitors use most.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.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.
Step 1: Turn on the debug log, outside the website's folder
Follow how to turn on WordPress debug mode, and give
WP_DEBUG_LOGthe 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'serror_log()function, and gives debugging AJAX as a use for it.Step 2: Create the file
Create the folder
wp-content/mu-pluginsif it is not there, and save this in it aslog-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 );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.phpStep 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.bashgrep -o 'ajax-log action=[^ ]*' /home/example/logs/wp-errors.log | sort | uniq -c | sort -rnStep 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.
bashgrep -o 'ajax-log .*' /home/example/logs/wp-errors.log | sort | uniq -c | sort -rn | head -n 20Step 6: Remove the file and turn the log off
Delete
log-ajax-actions.php. Then setWP_DEBUGback tofalseand 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 when | The interval |
|---|---|
| A dashboard screen | 60 seconds |
| The post editor and the Posts list | 10 seconds, to keep post locks current |
| Any tab that is hidden, or five minutes after your last mouse or keyboard activity | 120 seconds |
| Ten minutes after your last activity | It 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.
<?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:
grep -rl "the_action_name" wp-content/plugins wp-content/themesIf nothing is listed, the name is built in code. Search for its first part instead.
Then, in order:
- 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.
- 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.
- 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:
awk 'match($7, /[?&]wc-ajax=[^&]*/) { print substr($7, RSTART + 1, RLENGTH - 1) }' access.log | sort | uniq -c | sort -rnWooCommerce'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.
awk '$1 == "203.0.113.5" { print substr($6, 2), $7, $9 }' access.log | sort | uniq -c | sort -rn | head -n 20An 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_reqmodule, 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
.htaccessor 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 thewp-adminfolder can break the AJAX handler atwp-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
noprivaction 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.
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.
- Cost guideWordPress speed optimization cost: what the job is, what it should include and what moves the price
- GuideHow to speed up a WooCommerce store
- GuideSlow WordPress admin: how to find the cause and fix it
- GuideWhy the WooCommerce dashboard is slow, and how to find the cause
- GuideAI bots slowing down your website: how to confirm it on WordPress and what to do, mildest first
- GuideHow to clean up and optimize a WordPress database, and what it does for speed

