— Article — № 057

057 —WordPress

Slow admin-ajax.php: tracing 30 seconds back to one plugin

A WooCommerce backend hung for 30 seconds on every page load. The slow request was always admin-ajax.php. Here is how we traced it to one Heartbeat plugin.

Overhead bone linen with printed admin-ajax request trace, stapled log printout, manila folder tab, brass plate, ruler, wax seal.
Hero · staged still№ 057

At 23:41 on a Tuesday a Loom landed in our shared inbox. A 22-person Dutch agency was running a five-year-old WooCommerce shop for a regional retailer. Their content editor had gone to bed angry. Every save in the post editor stalled for half a minute, then snapped back to life as if nothing had happened. The slow request was always the same path: admin-ajax.php. Always around thirty seconds. By morning the agency had logged a ticket with the host. By 10:00 the host had blamed "plugin bloat" and closed it. The agency lead recorded the Loom asking the question every freelancer eventually asks: where do you actually start.

Reading the access log first

Before opening WordPress we opened the access log. SSH in, tail the combined log, watch what an editor refresh looks like from the server's point of view.

tail -f /var/log/nginx/access.log | grep admin-ajax

The pattern showed up within a minute.

"POST /wp-admin/admin-ajax.php HTTP/2" 200 412 30.014
"POST /wp-admin/admin-ajax.php HTTP/2" 200 412 29.987
"POST /wp-admin/admin-ajax.php HTTP/2" 200 412 30.122

Every fifteen seconds, while an admin tab was open, a request to admin-ajax.php sat for thirty seconds before returning a tiny payload. The size of the response (412 bytes) was the giveaway. This was not a heavy editor save. This was the WordPress Heartbeat API ticking, and something attached to it was waiting on something else.

Heartbeat is a small JavaScript poll that runs in the browser whenever an admin is logged in. By default it pings admin-ajax.php every fifteen to sixty seconds with the action heartbeat. Plugins hook into it to deliver autosave, lock indicators, notifications, anything that wants near-real-time admin data without WebSockets. The intent is benign. The blast radius, when something attaches an expensive callback, is the entire admin.

Tracing the heartbeat receivers

WordPress exposes two filters on every heartbeat tick: heartbeat_received and heartbeat_send. Any plugin can attach work to either. To find which plugin was eating thirty seconds per tick we wrote a short tracer and dropped it into wp-content/mu-plugins/.

<?php
// wp-content/mu-plugins/heartbeat-trace.php
add_filter('heartbeat_received', function ($response, $data, $screen_id) {
    $bound = $GLOBALS['wp_filter']['heartbeat_received']->callbacks ?? [];
    $names = [];
    foreach ($bound as $priority => $set) {
        foreach ($set as $cb) {
            $fn = $cb['function'];
            if (is_array($fn)) {
                $names[] = (is_object($fn[0]) ? get_class($fn[0]) : $fn[0]) . '::' . $fn[1];
            } elseif (is_string($fn)) {
                $names[] = $fn;
            } else {
                $names[] = 'closure@' . $priority;
            }
        }
    }
    error_log('[heartbeat] receivers: ' . implode(', ', $names));
    return $response;
}, 1, 3);

This runs at priority 1, before any other callback. It walks the global filter array, builds a list of bound function names, writes them to error_log, then steps out of the way. mu-plugins load on every request and cannot be toggled in wp-admin, which makes them ideal for incident tooling. No one can accidentally deactivate your tracer mid-investigation.

Reload the editor, wait one tick, tail the PHP error log:

tail -f /var/log/php/error.log

[heartbeat] receivers: WP_Auto_Updates::on_heartbeat, NinjaFormsLive\Listener::sync, MainWP_Child::heartbeat

Three callbacks. Auto-updates is core. MainWP is the agency's remote-management bridge, which we knew about. The middle one was a forms plugin running a "live" sync of submissions to a third-party CRM. The author had assumed Heartbeat was a free clock. It was not.

Confirming the cost before pulling the plug

Knowing the suspect is not the same as proving it. We added a pair of timing filters: one at priority 0 to stamp the start time, one at PHP_INT_MAX to log the total.

<?php
add_filter('heartbeat_received', function ($r) {
    $GLOBALS['hb_t0'] = microtime(true);
    return $r;
}, 0, 1);

add_filter('heartbeat_received', function ($r) {
    $dt = microtime(true) - ($GLOBALS['hb_t0'] ?? microtime(true));
    error_log(sprintf('[heartbeat] total %.2fs', $dt));
    return $r;
}, PHP_INT_MAX, 1);

Reload, wait, watch:

[heartbeat] total 29.84s

The MySQL slow query log told the rest of the story. The plugin's sync method was firing a wp_remote_post against an HTTPS endpoint with the default thirty-second timeout. The CRM had been migrated behind a new Cloudflare rule six weeks earlier that returned 522 on its old subdomain. Every fifteen seconds, in every admin tab, every editor session, WordPress was politely waiting for a host that no longer answered. The host's "plugin bloat" theory was technically correct and operationally useless.

The patch that kept Heartbeat alive

Three options were on the table.

The first was to disable the forms plugin entirely. That would have broken the embeds the agency had been using for two years on the retailer's contact and quote pages. Not acceptable.

The second was to disable Heartbeat globally with wp_deregister_script('heartbeat'). That stops autosave, post locking, and the warning that fires when another editor opens the same post. Tolerable for a single-person site, painful for a team of four editors working the same product catalogue at once.

The third was the right one. Unhook the offending callback, leave Heartbeat alive for everything else. One file in mu-plugins:

<?php
// wp-content/mu-plugins/throttle-forms-live-sync.php
add_action('init', function () {
    if (class_exists('NinjaFormsLive\\Listener')) {
        remove_filter(
            'heartbeat_received',
            ['NinjaFormsLive\\Listener', 'sync'],
            10
        );
    }
}, 99);

The priority argument matters. remove_filter only removes a callback if the priority you pass matches the priority it was originally added at. We confirmed that by reading the plugin's source: add_filter('heartbeat_received', [...], 10, 3). If we had passed the default 10 without checking, we would have had a fifty-fifty chance of a silent miss and an angry editor.

On the next reload, the access log went quiet. admin-ajax.php returned in roughly 60 milliseconds instead of thirty seconds. The editor was usable again. The forms still worked. Heartbeat still ticked. Autosave still saved.

We then opened a small pull request against the forms plugin. The author accepted it within a week. The sync now runs as a wp-cron event once every five minutes with a five-second timeout, rather than on every heartbeat with a thirty-second one. That is the shape any near-real-time integration should take: bounded, scheduled, and isolated from the editor's request path.

An audit you can run on every site you inherit

Heartbeat is not the problem. It is one of the most useful built-ins WordPress ships. The problem is that any plugin can attach unbounded work to it, and nothing in core forces a timeout, a queue, or a circuit breaker. When you inherit a five-year-old install and the admin feels heavy, the first place to look is which filters have grown attendees.

A short audit you can run today, on any admin page, as any user with manage_options:

<?php
// wp-content/mu-plugins/heartbeat-audit.php
add_action('admin_footer', function () {
    if (!current_user_can('manage_options')) return;
    $hooks = ['heartbeat_received', 'heartbeat_send', 'heartbeat_tick'];
    foreach ($hooks as $hook) {
        $cb = $GLOBALS['wp_filter'][$hook]->callbacks ?? [];
        $count = 0;
        foreach ($cb as $set) { $count += count($set); }
        printf("<!-- %s: %d callbacks -->\n", $hook, $count);
    }
});

Drop it in. Open any admin page. View source. You will see a comment near the bottom of the HTML listing how many callbacks each Heartbeat hook carries. Anything above three is worth investigating. Anything above five is almost certainly a regression waiting to happen.

What this kind of work usually looks like

This was a small incident with a clear culprit. Most of the slow-admin tickets we see follow the same shape. A plugin attaches to a tick (Heartbeat, shutdown, wp_loaded) without thinking about the failure mode of whatever it calls out to. The endpoint goes away. The plugin keeps waiting. The admin gets slow. The host gets blamed.

When we built Pier to dock with legacy sites like this one, this exact pattern was one of the first investigation workflows we wired in: tail the access log, drop a tracer mu-plugin, refresh one editor page, read the error log back. The tracer plugin and the eventual remove_filter both land in the version history as discrete edits, so either can be rolled back from a single keystroke.

If you have an admin that feels heavy and you do not yet know why, the smallest move tonight is to paste the audit snippet above into mu-plugins, refresh one wp-admin page, and read the HTML comments at the bottom. Five minutes, no plugin activation, no service restart. Half the diagnosis is already done.

— Questions —

What is the WordPress Heartbeat API?

A built-in JavaScript poll that calls admin-ajax.php every 15 to 60 seconds while an admin is logged in. It powers autosave, post locking, and the lock indicator. Any plugin can attach callbacks to it.

Why does admin-ajax.php hang for exactly 30 seconds?

Thirty seconds is the default timeout PHP gives wp_remote_post and wp_remote_get. A plugin is calling an external host that never responds, and the request waits the full timeout before returning.

Can I just disable Heartbeat to fix a slow admin?

You can, with wp_deregister_script('heartbeat'), but you will lose autosave, post locking, and concurrent-edit warnings. Unhooking the specific slow callback is almost always the better fix.

Where should diagnostic scripts like the heartbeat tracer live?

In wp-content/mu-plugins/. mu-plugins load on every request and cannot be deactivated from wp-admin, which is exactly what you want for incident tooling that must not get switched off mid-investigation.