How to Diagnose Why wp-cron Events Queue but Never Execute on Shared Hosting

If you run WordPress on shared hosting, you’ve probably seen it: a scheduled post that never publishes, a backup that never runs, a plugin that says its next event is overdue. The wp-cron system is WordPress’s built-in task scheduler, but on shared hosting it often queues events without ever executing them. This article is a field guide to diagnosing that failure mode. We’ll cover the difference between WP-Cron and a real system cron, the database rows that hold queued events, the HTTP request chain that triggers execution, and the specific shared-hosting conditions that break that chain. Every claim here is tied to a reproducible WP-CLI command, SQL query, or code snippet you can run on your own production install.

This matters for small-to-mid publishing teams because wp-cron is not just a convenience. It drives scheduled post transitions, editorial workflow reminders, comment moderation checks, and plugin housekeeping. When events queue but never run, the symptom is often silent: a missed publish time, a stale cache, a failed email digest. By the end of this article, you’ll be able to trace a queued event from the wp_options table to the HTTP request that should have run it, and you’ll know which shared-hosting settings to check first.

Server rack with network cables in a data center
Shared hosting environments often restrict the outbound HTTP requests that wp-cron depends on.

What wp-cron Actually Is

WordPress does not have a background daemon. Instead, it uses a web-triggered scheduler. On every page load, WordPress checks whether any scheduled events are due. If so, it sends an HTTP request to wp-cron.php in the WordPress root. That request runs the due events. The key file is wp-cron.php, and the scheduling logic lives in wp-includes/cron.php.

The queue itself is stored in the wp_options table under the option name cron. The value is a serialized PHP array. Each event has a timestamp, a hook name, and arguments. When an event is due, WordPress spawns a non-blocking HTTP request to wp-cron.php?doing_wp_cron=. That request runs the hook callbacks.

This design has a known failure mode: if no one visits the site, no page load occurs, and no cron runs. But on shared hosting, the more common failure is that page loads happen, the event is due, and the HTTP request to wp-cron.php still never completes. That’s the failure mode this article focuses on.

First Evidence: Check the Queue Directly

Before touching any configuration, look at the actual queued events. The fastest way is WP-CLI:

wp cron event list --fields=hook,next_run_relative,next_run

If you don’t have WP-CLI on the shared host, run this SQL query against the WordPress database:

SELECT option_value FROM wp_options WHERE option_name = 'cron';

The output is a serialized array. You can unserialize it with PHP:

php -r '$cron = get_option("cron"); print_r($cron);'

Or use a one-off script in a mu-plugin to dump the queue to the error log. The point is to confirm two things: the event exists, and its timestamp is in the past. If the timestamp is in the future, the event is simply not due yet. If it’s in the past and still listed, you have a queue-but-not-execute problem.

What a Stuck Queue Looks Like

A healthy queue shows events with next_run_relative values like now or 1 minute. A stuck queue shows events with next_run_relative values like 2 hours ago or 1 day ago. The event is due, but the hook never fired. This is the signature of a broken execution path, not a missing schedule.

Person typing on a laptop with code on the screen
WP-CLI gives you a direct view of the cron queue without waiting for a page load.

The Execution Path: From Page Load to wp-cron.php

When a visitor loads any page on your site, WordPress runs wp_cron() during the shutdown sequence. That function checks the cron option for due events. If it finds any, it calls spawn_cron(), which sends an HTTP request to wp-cron.php. The request is non-blocking: WordPress uses wp_remote_post() with a very short timeout, typically 0.01 seconds. The idea is to fire the request and let the server handle it in the background.

On shared hosting, this is where things break. The non-blocking request depends on the server being able to make an outbound HTTP connection to itself. Many shared hosts block loopback requests, or they restrict the PHP functions that wp_remote_post() uses, such as fsockopen() or curl. If the loopback request fails silently, the event stays queued.

Test the Loopback Request

You can test whether your server can make a loopback request with a small mu-plugin:

add_action('init', function() {
    if (isset($_GET['loopback_test'])) {
        $response = wp_remote_post(home_url('/wp-cron.php'), array(
            'timeout' => 5,
            'blocking' => true,
        ));
        if (is_wp_error($response)) {
            error_log('Loopback test failed: ' . $response->get_error_message());
        } else {
            error_log('Loopback test succeeded: ' . wp_remote_retrieve_response_code($response));
        }
        exit;
    }
});

Then visit https://yourdomain.com/?loopback_test=1 and check the PHP error log. If you see a timeout, a connection refused error, or a DNS failure, the loopback request is the problem. This is the single most common cause of queued-but-never-executed cron events on shared hosting.

Shared Hosting Failure Modes

Shared hosting environments introduce several specific failure modes that don’t appear on a VPS or dedicated server. Here are the ones I’ve seen most often in production installs.

1. Loopback Requests Are Blocked

Some hosts block outbound HTTP requests from PHP scripts as a security measure. This prevents a compromised script from sending spam or participating in a botnet. The side effect is that wp_remote_post() to your own domain fails. The fix is usually to disable WP-Cron and use a real system cron job, which we’ll cover below.

2. The Server Cannot Resolve Its Own Domain

On some shared hosts, the server’s DNS resolver cannot resolve the site’s own domain. The loopback request to https://yourdomain.com/wp-cron.php fails because the server cannot find the IP address. This is more common on hosts that use a CDN or a proxy in front of the origin server. You can test this by running wp eval 'echo wp_remote_retrieve_response_code(wp_remote_get(home_url("/")));' via WP-CLI. If it returns 0 or an error, DNS resolution is likely the issue.

3. PHP Execution Time Limits

Shared hosts often set max_execution_time to 30 seconds or less. The non-blocking cron request is designed to return immediately, but if the server is slow, the request can take longer than the timeout. When the timeout is hit, the request is aborted, and the event never runs. This is more common on hosts with oversold CPU resources.

4. The ALTERNATE_WP_CRON Fallback Is Not Set

WordPress has a fallback mechanism for hosts that block loopback requests. If you define ALTERNATE_WP_CRON as true in wp-config.php, WordPress will redirect the visitor’s browser to wp-cron.php instead of making a server-side loopback request. This works, but it has a cost: the visitor’s page load is delayed while the cron runs. For a publishing site with low traffic, this is often an acceptable tradeoff.

define('ALTERNATE_WP_CRON', true);

Add that line to wp-config.php and test again. If events start running, the loopback request was the problem.

Close-up of server status lights
Server-side loopback restrictions are a common culprit on shared hosting.

The System Cron Fix

The most reliable fix on shared hosting is to disable WP-Cron entirely and run the scheduler from a real system cron job. Most shared hosts provide a cron manager in their control panel, such as cPanel’s Cron Jobs tool. The steps are:

  1. Add define('DISABLE_WP_CRON', true); to wp-config.php.
  2. Create a system cron job that hits wp-cron.php directly on a schedule.

The cron job command depends on your host. For cPanel, it’s typically:

wget -q -O - https://yourdomain.com/wp-cron.php?doing_wp_cron > /dev/null 2>&1

Or if wget isn’t available:

curl -s https://yourdomain.com/wp-cron.php?doing_wp_cron > /dev/null 2>&1

Set the schedule to every 5 or 10 minutes. This bypasses the loopback request entirely because the system cron job runs from the server’s own scheduler, not from a PHP script. It also means cron runs even when no one visits the site.

One caveat: some shared hosts restrict the use of wget or curl in cron jobs. If that happens, you can use a PHP CLI command instead:

php /home/username/public_html/wp-cron.php

But this requires knowing the absolute path to your WordPress install, and it may not work if the host’s PHP CLI is configured differently from the web server’s PHP.

Diagnosing with WP-CLI

WP-CLI is the fastest way to test the cron system without waiting for a page load. Here are the commands I use most often.

List All Events

wp cron event list

Run a Specific Event Immediately

wp cron event run 

This runs the event synchronously, bypassing the HTTP request entirely. If the event runs successfully via WP-CLI but not via page load, the problem is in the HTTP execution path, not in the event callback itself.

Run All Due Events

wp cron event run --due-now

This is useful for clearing a backlog after you’ve fixed the underlying issue.

Check the Cron Option Directly

wp option get cron --format=json

This shows the raw serialized queue. If the option is missing or empty, WordPress will rebuild it on the next page load, but any custom schedules from plugins will be lost until those plugins re-register them.

Common Plugin Interactions

Some plugins add their own cron handlers and can mask or worsen the problem. For example, a backup plugin might schedule a daily event, but if the event never runs, the plugin shows a “next backup: overdue” notice. The fix is the same: diagnose the execution path, not the plugin.

One specific interaction to watch for: object caching plugins. If you use a persistent object cache like Redis or Memcached on shared hosting, the cron option can be cached. When WordPress updates the queue, the cache may not be invalidated, so the page load sees a stale queue and never spawns the cron request. If you suspect this, flush the object cache and test again.

When the Queue Itself Is Corrupt

Occasionally, the cron option becomes corrupt. This can happen if a plugin writes a malformed value, or if the database row is truncated. The symptom is a PHP warning about an invalid cron array, or events that appear and disappear unpredictably.

To check for corruption, run:

wp eval 'var_dump(_get_cron_array());'

If the output is false or contains unexpected types, the option is corrupt. The fix is to delete the option and let WordPress rebuild it:

wp option delete cron

Then visit the site once to trigger a rebuild. Note that this removes all scheduled events, including plugin events. Plugins will re-register their events on the next page load, but any one-off events will be lost.

Editorial Workflow Implications

For a publishing team, a stuck cron queue has direct editorial consequences. Scheduled posts don’t publish. Editorial reminder emails don’t send. Comment moderation queues don’t refresh. The fix isn’t to manually publish posts; it’s to fix the scheduler so the automated workflow works.

One practical step is to add a cron health check to your editorial dashboard. A simple mu-plugin can log the number of overdue events to the error log on every admin page load:

add_action('admin_init', function() {
    $cron = _get_cron_array();
    $overdue = 0;
    foreach ($cron as $timestamp => $events) {
        if ($timestamp < time()) {
            $overdue += count($events);
        }
    }
    if ($overdue > 0) {
        error_log('Overdue cron events: ' . $overdue);
    }
});

This gives you an early warning before a scheduled post misses its publish time. For a deeper look at how scheduled posts interact with the database, see What to Fix First When a New WordPress Site Says Nothing Found.

FAQ

Why do my scheduled posts sometimes publish late on shared hosting?

Scheduled posts rely on wp-cron. If the loopback request to wp-cron.php fails, the event stays queued until a page load successfully triggers it. On shared hosting, loopback restrictions or DNS resolution failures are the most common causes. Test the loopback request with the mu-plugin snippet above, and if it fails, switch to a system cron job.

Can I just disable wp-cron and run everything manually?

You can disable wp-cron with define('DISABLE_WP_CRON', true);, but you must replace it with a system cron job that hits wp-cron.php on a regular schedule. Otherwise, no scheduled events will run at all. The system cron approach is more reliable on shared hosting because it doesn’t depend on a page load or a loopback request.

How do I know if the cron queue is corrupt?

Run wp eval 'var_dump(_get_cron_array());'. If the output is false or contains unexpected types, the cron option is corrupt. Delete it with wp option delete cron and visit the site once to rebuild the queue. Plugins will re-register their events, but one-off events will be lost.

What is the difference between wp-cron and a real system cron?

wp-cron is a web-triggered scheduler: it runs only when someone visits the site, and it depends on an HTTP loopback request. A real system cron runs from the server’s scheduler at fixed intervals, independent of site traffic. On shared hosting, a system cron is more reliable because it bypasses the loopback request and runs even when no one visits the site.

Next Steps for Your Install

Start with the queue. Run wp cron event list and look for overdue events. Then test the loopback request. If it fails, either set ALTERNATE_WP_CRON or switch to a system cron job. Document the fix in your team’s runbook so the next person doesn’t have to rediscover it. And if you’re maintaining multiple production installs, consider a recurring column on this site for shared-hosting failure modes. The next topic worth covering is how to audit plugin cron registrations so you know exactly which events each plugin adds to the queue.