Skip to content
Server & hosting

WordPress Slow Despite Caching: WP-Cron and admin-ajax

Page cache on, WordPress still sluggish? WP-Cron, Heartbeat and admin-ajax bypass the cache and tie up PHP workers. How to measure that load and take it off.

19 min read WordPressTTFBServer-OptimierungCaching

In short and in plain language

WordPress can be slow even though a cache is running. The cache only helps with finished pages. Some requests still go straight to the server.

WP-Cron does planned tasks. It only starts when someone opens the site. With a cache, this can happen too late. A fixed plan on the server works better.

Open windows in the editing area check in with the server often. This is called Heartbeat. You can make it check in less often.

Some themes load content on a side route. The cache cannot store these answers. Often the content can go right into the page.

We first measure which requests put load on the server. Then we change things and measure again.

The page cache is running, the home page loads instantly, and yet editors and customers complain about a sluggish WordPress: the cart hangs, search keeps people waiting, the save icon in the editor keeps spinning. The contradiction resolves once you stop looking at the cached pages and look at the requests the cache does not get to see. WordPress runs on roughly 64 percent (Web Almanac 2025) of websites with a detectable content management system, and almost every one of those installations has three sources of such requests: WP-Cron, which checks for due tasks on page loads, the Heartbeat of open editor windows, and admin-ajax calls from themes and extensions on the frontend. This article shows how to measure the share of uncached PHP requests per endpoint in the access log, move WP-Cron to the system cron, throttle Heartbeat without losing post locking, and move frontend calls onto cacheable paths. The statements about core are based on the WordPress developer documentation and the source code of version 7.1.

Key takeaways

  • WP-Cron checks on page loads whether tasks are due. If the page cache serves a page without starting WordPress, that trigger does not happen, and scheduled tasks wait for the next uncached request.
  • Every open editor window polls the server via Heartbeat every 15 to 120 seconds (WordPress Plugin Handbook) through admin-ajax. In the post editor the ticks only stop after 60 minutes (WordPress source code) without activity.
  • admin-ajax.php loads the full core including admin functions for every request and forbids caching. A counter or loader on the frontend thus turns every cached page view back into a PHP request.
  • Measure per endpoint: group the access log by wp-cron.php, admin-ajax.php and /wp-json/, log the response time and record the admin-ajax action separately, because with POST it is not part of the URL.
  • With DISABLE_WP_CRON and a system cron that calls wp cron event run --due-now, due tasks run on time and outside the web server worker pool, including a campaign launch at midnight.
  • Outside the editor, Heartbeat can be stretched to 120 seconds (WordPress source code) via heartbeat_settings. Anything longer weakens post locking, which expires after 150 seconds (WordPress source code) without a signal.

Why the page cache does not relieve the backend

A page cache stores finished HTML and hands it to the next visitors without starting WordPress. That works for everything that looks the same for everyone and is requested via GET: home page, posts, categories. Everything else still ends up at PHP. That includes requests from logged-in users, form and cart actions, REST API calls with a personal context, and three endpoints that run in almost every installation: wp-cron.php, wp-admin/admin-ajax.php, and Heartbeat, which also talks through admin-ajax.php.

Each of these requests occupies a PHP worker while it runs. The pool is limited: the PHP-FPM sample configuration that ships with PHP sets pm.max_children to 5 (PHP source code) and notes that these defaults are meant for a server without much resources. Hosts and distributions set their own values, but the principle stays the same: once all workers are busy, the next request waits in the queue before a single line of PHP runs. How to spot that state on the FPM status page is described in the section on worker saturation.

Visitors notice it exactly where the cache does not help anyway. The home page stays fast, but adding to the cart, searching or submitting a form waits for a free worker. The Time to First Byte of these requests rises while every measurement of the home page stays green. web.dev rates 0.8 seconds (web.dev) or less as a good TTFB; uncached requests are the first to miss that mark under load. How to bring server response time down in a targeted way is covered on the page about TTFB optimization.

Scope

Database bloat, autoload options and the persistent object cache are covered in WordPress performance: fix common slowdowns. Why a single cookie drags down the cache hit rate is explained in Why one cookie wrecks your cache hit rate. This article is about the requests that have to bypass the cache, and how many of them are really needed.

WP-Cron: tasks triggered by a page load

WordPress has no service of its own that keeps an eye on the clock in the background. Instead, core checks on requests whether scheduled tasks are due. The Plugin Handbook describes the mechanism in two sentences:

WP-Cron works by checking, on every page load, a list of scheduled tasks to see what needs to be run. Any tasks due to run will be called during that page load.

WordPress Plugin Handbook, Cron

According to the handbook, these tasks include checking for updates and publishing scheduled posts (WordPress Plugin Handbook). Extensions add their own: newsletter dispatch, feed imports, backups, log cleanup or cache preloading. In core, the process hangs off init, so off every request that fully starts WordPress (WordPress source code). If something is due, spawn_cron() sends a non-blocking loopback request to wp-cron.php, a request from the server to itself (WordPress Code Reference). A lock prevents this from happening more than once every 60 seconds (WordPress Code Reference).

Non-blocking does not mean free. The Code Reference notes that the 0.01 seconds set as a timeout may end up taking 1 second (WordPress Code Reference). As long as the trigger ran at wp_loaded, the HTML had to wait that long and the visitor's TTFB went up. Since version 6.9 (WordPress Code Reference), WordPress fires the trigger at shutdown, when the page is already on its way. Anyone still running an older version may have found one cause of isolated response time outliers that only occur when a task happens to be due and are therefore hard to reproduce.

The actual work is then done by wp-cron.php. The file finishes the HTTP response early when PHP-FPM allows it and only then works through the tasks (WordPress source code). The visitor who triggered the run does not wait for it. The worker, however, stays busy until the last due task has finished, and an import or a newsletter dispatch can take a while. During that time it is unavailable to every other uncached request.

The cache delays scheduled tasks

With a complete page cache, the problem flips. If the cache serves a page before WordPress reaches init, core does not see that request and cannot trigger anything. On a well-cached website with little backend activity, hours may pass before the next uncached request arrives. The handbook names the consequence itself: if you schedule a task for 2:00 PM and no page loads occur until 5:00 PM, scheduling errors could occur (WordPress Plugin Handbook). Site Health then reports a late scheduled event, together with the note that scheduled posts or automatic updates may not work as intended (WordPress source code).

WP-Cron is therefore busy both too often and too rarely: too often because every run ties up a worker in the middle of visitor traffic, and too rarely because cached page views trigger no run at all. Moving to a system cron, described further down, solves both.

Heartbeat: open editor windows as a constant load

Heartbeat is a polling interface in core. The handbook describes it as a simple server polling API that allows near-real-time updates (WordPress Plugin Handbook). When the page loads, the browser sets up an interval that sends data to the server every 15 to 120 seconds (WordPress Plugin Handbook). On the server, an admin-ajax handler takes the data, prepares a response and returns it as JSON.

What it is for is stated at the top of the script file: the ticks carry post locking, login expiration warnings, autosave and related tasks while a user is logged in (WordPress source code). In the admin area it is loaded through the authentication check, which builds on Heartbeat (WordPress source code). Every open admin tab is therefore its own clock: dashboard, post list, editor, order overview.

There is a brake for forgotten windows. When the window loses focus, the interval switches to 120 seconds (WordPress source code), and after 10 minutes (WordPress source code) without activity it suspends entirely. WordPress, however, disables exactly this suspension on the screens for editing and creating posts (WordPress source code). There, the ticks only stop after 60 minutes (WordPress source code) without activity. An editor left open over lunch keeps checking in with the server.

Every tick is a POST to admin-ajax.php. The file loads the full core, plus the admin functions and all core Ajax handlers, and fires admin_init (WordPress source code). Every extension hooked in there runs on every tick. A team with several open tabs per person thus produces a steady base load of uncached requests that has nothing to do with visitor traffic and still occupies the same workers. A similar pattern is known from shop systems whose admin does background work in the browser tab; the article on the slow shop backend describes it.

admin-ajax on the frontend: the detour around the cache

The Plugin Handbook states that all WordPress AJAX requests must be sent to wp-admin/admin-ajax.php (WordPress Plugin Handbook). For extensions and themes this was the easiest way to load data from within a page for a long time. On the frontend it shows up in many places: buttons that load more posts, list filters, live search, view counters, forms, popups with changing content, or a cart counter that is fetched again after every page view.

For the cache, these calls are useless. Most of them go out as POST, and HTTP semantics do not allow a POST request to be satisfied from a stored POST response (RFC 9110). On top of that, admin-ajax.php sends headers that prevent caching (WordPress source code); since version 6.8 (WordPress Code Reference) they include no-store and private regardless of whether anyone is logged in. Even a GET call to this endpoint therefore ends up at PHP.

The math is simple: if every cached page carries an Ajax call that fires on load, every page view causes another PHP request, and one with a full core start at that. The page cache saves the HTML but not the worker. With lists that load more via a button, every follow-up page also runs through PHP individually; which pattern suits long lists is compared in Pagination, load more or infinite scroll.

Heartbeat on the frontend deserves a separate look. Core has dedicated filters for visitors who are not logged in (WordPress source code). If a theme or extension loads Heartbeat on public pages, every open visitor window ticks against admin-ajax.php. Whether that happens shows up in the source of a page viewed in a private window: if heartbeat.min.js or heartbeatSettings appear there, the ticks run for guests too.

Measuring uncached requests per endpoint

Before changing anything, establish how large the share of these requests is. The basis is the web server access log over a typical working day, ideally a day with editorial activity and a running campaign. From it, requests can be grouped by path and compared with the total.

  1. Group paths: /wp-cron.php, /wp-admin/admin-ajax.php, /wp-json/, /wp-login.php and the rest. Cut off query parameters first, otherwise every cron run counts as a separate URL with its timestamp.
  2. Log response time: add the processing time to the log format, $request_time in nginx, %D in Apache. Only then does a count become a load.
  3. Log cache status: if the cache layer emits a header such as X-Cache or an upstream status, it belongs in the log. That makes the share of uncached requests in total traffic directly visible.
  4. Capture the admin-ajax action: with POST, the action parameter sits in the request body and therefore not in the log. A small must-use plugin records action and duration, without any personal data.
  5. Watch the worker pool: the PHP-FPM status page shows active processes, the queue length and whether the maximum number of processes has been reached (PHP manual).
count-endpoints.sh
# Requests per endpoint from an access log in combined format
awk '{ split($7, p, "?"); print p[1] }' access.log \
  | grep -E '^/(wp-cron\.php|wp-admin/admin-ajax\.php|wp-json/|wp-login\.php)' \
  | sed -E 's#^/wp-json/.*#/wp-json/#' \
  | sort | uniq -c | sort -rn
wp-content/mu-plugins/admin-ajax-log.php
<?php
// For measurement only: writes action and duration of every admin-ajax request to the PHP error log.
if ( wp_doing_ajax() ) {
	add_action( 'shutdown', function () {
		$action = isset( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : '-';
		$ms     = (int) round( ( microtime( true ) - $_SERVER['REQUEST_TIME_FLOAT'] ) * 1000 );
		error_log( sprintf( 'admin-ajax action=%s ms=%d', $action, $ms ) );
	} );
}

After a day, the picture is clear: what share of PHP requests goes to cron, Heartbeat and frontend actions, which action fires most often and how long it takes. Remove the logging plugin afterwards. Where the time goes within a single request is made visible by the header described in the article on Server-Timing.

EndpointTriggerIn the page cache?Remedy
/wp-cron.phploopback after an uncached requestnot included system cron with WP-CLI, DISABLE_WP_CRON
admin-ajax.php, action heartbeatopen admin and editor windowsnot included stretch the interval outside the editor, load on the frontend only where needed
admin-ajax.php, custom actionstheme or extension on load or clicknot included render into the HTML or make it cacheable as a GET route
/wp-json/ via GET without loginfrontend scriptspossibleset Cache-Control, add a cache rule for the route
pages for logged-in userseditors, customer accountnot included lower the base load with an object cache and lean templates

Moving WP-Cron to the system cron

The documentation describes the approach itself: set up the system task scheduler and disable WP-Cron in wp-config.php. The note is clear. Without that step, WordPress would continue to run WP-Cron on each page load, which is then no longer necessary and contributes to extra resource usage on the server (WordPress Plugin Handbook).

There are two ways to make the call. The handbook shows fetching wp-cron.php over HTTP, for example with wget. That works everywhere but again runs through the web server and PHP-FPM and occupies a worker there. Where WP-CLI is installed, wp cron event run --due-now is the better fit: the command runs all due events and respects the lock against overlapping runs (WP-CLI documentation). It runs as a separate PHP process on the command line, outside the worker pool, and is not bound by the time limits of web server and proxy.

system-cron.txt
# wp-config.php, above the line "That's all, stop editing!"
define( 'DISABLE_WP_CRON', true );

# crontab of the user WordPress runs as (crontab -e)
* * * * * cd /var/www/example/htdocs && wp cron event run --due-now --quiet >> /var/www/example/logs/wp-cron.log 2>&1

The interval follows the finest scheduling you need. As an example, the handbook shows a call every 15 minutes (WordPress Plugin Handbook). That is enough for maintenance tasks, but for a post meant to go live at a specific minute it can mean a delay of up to a quarter of an hour. The entry above therefore runs every minute. Each run starts WordPress once on the command line and exits when nothing is due; it does not compete with visitor requests for workers, though it does compete for CPU time and the database.

Two things belong to the switch. First, the cron entry writes its output to a log file rather than into the void; only then does a run that keeps failing get noticed before a scheduled post goes missing. Second, check that the switch works: wp cron event list shows upcoming events with their next run, and calls to wp-cron.php should disappear from the access log. If they remain, an external service or an old cron entry is still calling the file.

ALTERNATE_WP_CRON is no substitute

The ALTERNATE_WP_CRON constant redirects the visitor's browser so the run can continue in the dropped connection. It does not relieve any worker, and the documentation itself mentions risks of this method (WordPress Advanced Administration Handbook). For the problem described here, the system cron is the right approach.

Throttling Heartbeat without losing locks

Switching Heartbeat off is the wrong direction. Without it, the editor loses post locking, which keeps two people from overwriting the same post at the same time, and the warning about an expired login. The right lever is the heartbeat_settings filter and, within it, the minimalInterval value. It sets the shortest allowed gap between two ticks and overrides all shorter values.

The limits are in the source code: the minimal interval can be up to 600 seconds, but anything longer than 120 seconds will limit or disable features such as post locking (WordPress source code). The reason is the lifetime of the lock itself, which expires after 150 seconds (WordPress source code) without a signal. On the screens for editing posts, the default therefore stays. On the dashboard, lists and settings pages, the interval can be stretched up to that limit; on the frontend, Heartbeat is loaded for guests only where a feature actually needs it.

wp-content/mu-plugins/heartbeat-interval.php
<?php
add_filter( 'heartbeat_settings', function ( $settings ) {
	global $pagenow;
	// Keep the default in the editor, elsewhere at most one tick every 120 seconds.
	if ( 'post.php' !== $pagenow && 'post-new.php' !== $pagenow ) {
		$settings['minimalInterval'] = 120;
	}
	return $settings;
} );

// Do not load it for guests on the frontend unless a feature needs the ticks.
add_action( 'wp_enqueue_scripts', function () {
	if ( ! is_user_logged_in() ) {
		wp_deregister_script( 'heartbeat' );
	}
}, 100 );

After deploying it, test the features that depend on Heartbeat: the notice about concurrent editing in the editor, the login expiration warning and anything an extension handles through the ticks on the frontend. Then the same measurement applies as before: the number of admin-ajax requests with the action heartbeat goes down. A simple team agreement helps just as much: whoever is not writing closes the editor. Before a campaign launch with many people involved, that is an inexpensive lever.

Moving frontend calls onto cacheable paths

The list of admin-ajax actions from the measurement is the work plan. Each action falls into one of three groups, and for each there is a way to bring the cache back into play.

Same for everyone: into the HTML

If the action returns the same thing for all visitors, such as related posts, filter options or a teaser, the content belongs directly in the page. It is then cached with the HTML and costs no request of its own.

Loading more: as a GET route

If content has to be loaded later, such as the second page of a list, a GET route of the REST API fits. For requests without login, WordPress sends no no-cache headers there by default (WordPress source code); with a suitable Cache-Control, a cache layer can store the response.

Personal: rare and small

Cart counters or login state differ per person and can often be read from a cookie in the browser. Where the server has to be asked, do it on demand, for example when the cart is opened, not on every page view.

One stumbling block on cacheable pages are nonces, the WordPress security tokens against forged requests. According to the handbook, a nonce can be used as many times as necessary within a 24 hour period (WordPress Plugin Handbook), and after 12 hours (WordPress Plugin Handbook) WordPress generates a new number. If a nonce sits in cached HTML, the calls fail as soon as the page stays in the cache longer than the token is valid. Either the cache lifetime of such pages stays below that, or the token is fetched on demand together with the action that needs it.

For view or click counters, ask whether they really have to be written at the moment of the page view. Often it is enough to take the numbers from the access log or collect them at longer intervals. Every action dropped this way gives the page cache its effect back. How the layers from browser cache via page cache to object cache work together is explained on the page about caching strategies.

Measuring before and after

The effect does not show in the Lighthouse score of the home page, which comes from the cache anyway. Measure where the load was: share of uncached PHP requests per endpoint, number and duration of admin-ajax actions, active processes and queue on the FPM status page, and the TTFB of pages that cannot come from the cache, such as cart, search and customer account. The comparison needs two time windows with the same profile: same weekday, same time of day, comparable editorial activity.

One counter on the status page deserves particular attention: max children reached shows whether the maximum number of processes has been reached (PHP manual). If it stops growing after the switch, the worker pool has room again. Whether that room holds under peak load is settled by a load test before peak season; how to read TTFB values is covered in the basics article on server response time. If the base load stays high despite all this, the cause often sits one layer deeper, in PHP configuration, database or hosting; that layer is covered by server optimization.

Before Black Week: releasing scheduled campaigns on time

At the end of November, both sides of the problem meet. Campaign pages and posts are created days in advance and scheduled for midnight, while more people than usual work in the admin area. With WP-Cron, publication depends on the first uncached request after midnight. On a website whose home page and categories come entirely from the cache, that can take a while; the scheduling errors the handbook describes then hit exactly the night everything has been working toward.

With a system cron running every minute, the post goes live on time. After that, the affected pages have to drop out of the cache and be warmed up again, otherwise visitors see the old home page. How to plan that for the night is described in Sale launch at midnight: warm the cache.

  • System cron set up, DISABLE_WP_CRON set, and the cron log free of errors for a week
  • A test post scheduled for a fixed time one week before launch, and published on time
  • Heartbeat stretched outside the editor and loaded for guests on the frontend only where a feature needs it
  • Frontend actions from the measurement rebuilt or kept with a stated reason
  • FPM status page monitored for the night, max children reached noted before launch
  • Team agreement: close editor windows when nobody is writing

If you would like this list worked through for your own installation, the sequence of measurement, switch and follow-up measurement is on the page about WordPress performance; the measurement alone is part of the performance analysis.

Sources and documentation

This article is based on data from: WordPress Plugin Handbook (Cron; Hooking WP-Cron Into the System Task Scheduler; Heartbeat API; AJAX); WordPress Code Reference (wp_cron(), spawn_cron(), wp_get_nocache_headers()); WordPress source code, branch 7.1 (heartbeat.js, admin-ajax.php, wp-cron.php, cron.php, misc.php, admin-filters.php, functions.php, default-filters.php, class-wp-site-health.php, class-wp-rest-server.php); WordPress Advanced Administration Handbook (wp-config.php); WP-CLI documentation (wp cron event run); PHP manual (FPM status page); PHP source code, branch PHP-8.4 (www.conf.in); HTTP Archive Web Almanac 2025 (CMS chapter); web.dev (Time to First Byte); RFC 9110 (HTTP Semantics).

Related Articles

Server & hosting

PHP Runtime Tuning: OPcache, JIT and FPM Workers

Between web server and database sits the PHP runtime. How OPcache, preloading, JIT and FPM workers shape your response time under load, and how to measure it.

12 min read
Server & hosting

Early Hints 103: Put Server Think Time to Work

Status code 103 per RFC 8297: how the server sends preconnect and preload hints during think time, how the nginx rollout looks and where the honest limits run.

13 min read
Server & hosting

AI Crawler Load: When Bots Slow Down Your Server

Bots generate over half of all HTML requests. Why AI crawlers hit uncacheable long-tail URLs, how to measure the load and throttle it instead of blocking.

14 min read