How Replacing WP-Cron Fixed a WooCommerce Store's Missing Emails, Failed Renewals, and 4,200 Stuck Scheduled Actions

· 9 min read

The Symptoms: Customers Complaining About Missing Order Emails

A UK-based subscription box company running WooCommerce contacted me after their customers started complaining. Order confirmation emails were arriving hours late — sometimes not at all. Subscription renewals were failing silently. And when I checked the Action Scheduler queue, there were over 4,200 pending actions, some dating back three weeks.

The store was processing around 150 orders per day, running WooCommerce Subscriptions, and hosted on a managed VPS with CloudPanel. On paper, the server was fine — 4 vCPUs, 8GB RAM, PHP 8.2, MariaDB 10.11. No resource bottleneck.

The problem was WP-Cron.

What WP-Cron Actually Does (And Why It Fails)

WordPress doesn't have a real task scheduler. Instead, it uses a pseudo-cron system called WP-Cron that only fires when someone visits the site. Every page load, WordPress checks if any scheduled tasks are overdue and runs them if so.

This works fine for a small blog. It falls apart for WooCommerce stores because:

  1. Full-page caching prevents triggers. If your caching layer serves a static page, PHP never executes, and WP-Cron never fires. This store was running LiteSpeed Cache with Guest Optimization enabled — the very feature I wrote about in a previous post on LiteSpeed FOUC. Most page loads never hit PHP at all.

  2. Traffic patterns create gaps. Between 11pm and 7am, this site had almost no visitors. Any scheduled task due during those hours — like a midnight subscription renewal batch — simply didn't run until the next morning.

  3. WP-Cron is single-threaded per request. When it does fire, it processes one batch of tasks per page load. If you have 4,200 pending actions, that's going to take a very long time to clear at one batch per visitor.

Diagnosing the Problem

The first thing I did was SSH into the server and check the Action Scheduler status via WP-CLI:

wp action-scheduler list --status=pending --per-page=20 --orderby=scheduled --order=asc

The oldest pending action was 22 days old. That's 22 days of subscription renewals, email dispatches, and webhook deliveries sitting in a queue.

I then checked for failed actions:

wp action-scheduler list --status=failed --per-page=20

Over 300 failed actions, mostly woocommerce_scheduled_subscription_payment and wc_admin_process_orders_milestone. The failure reason on most was a timeout — the action started but WP-Cron's execution window closed before it could finish.

Next, I checked whether WP-Cron was being disabled or misconfigured:

wp config get DISABLE_WP_CRON 2>/dev/null || echo "Not set"

Output: Not set. So WP-Cron was theoretically active — it just wasn't being triggered often enough thanks to the caching layer.

I verified this by checking when WP-Cron last ran:

wp option get cron --format=json | python3 -c "
import json, sys, datetime
data = json.load(sys.stdin)
timestamps = [int(k) for k in data.keys() if k.isdigit()]
if timestamps:
    latest = max(timestamps)
    dt = datetime.datetime.fromtimestamp(latest)
    print(f'Next scheduled cron event: {dt}')
"

The next scheduled event was hours overdue. WP-Cron was effectively dead.

The Fix: A Proper System Cron

The fix has three parts: disable WP-Cron's page-load trigger, set up a real system cron job, and clear the backlog.

Step 1: Disable WP-Cron in wp-config.php

wp config set DISABLE_WP_CRON true --raw

This adds define('DISABLE_WP_CRON', true); to wp-config.php. It stops WordPress from trying to run cron on every page load — which also removes a small performance overhead from each request.

Step 2: Create a System Cron Job

On a Linux server, you create a crontab entry that calls wp-cron.php at a fixed interval. There are two approaches — I prefer WP-CLI over curl because it doesn't depend on the web server or loopback requests.

crontab -e

Add this line:

*/5 * * * * cd /home/cloudpanel/htdocs/example.com && /usr/local/bin/wp cron event run --due-now --quiet >> /var/log/wp-cron.log 2>&1

This runs every 5 minutes, regardless of site traffic or caching. The --due-now flag processes only events that are currently due, and --quiet suppresses output unless there's an error.

For the Action Scheduler specifically, I added a second cron job to process its queue directly:

*/5 * * * * cd /home/cloudpanel/htdocs/example.com && /usr/local/bin/wp action-scheduler run --batch-size=50 --quiet >> /var/log/wp-action-scheduler.log 2>&1

This is important because Action Scheduler has its own queue runner that's more efficient than relying on WP-Cron events to trigger it.

A note on timing: Every 5 minutes is right for most WooCommerce stores. If you're running a high-volume store (500+ orders/day) or time-sensitive subscriptions, you might want every 2 minutes. For low-traffic brochure sites, every 15 minutes is fine.

Step 3: Clear the Backlog

With 4,200 pending actions, I didn't want to wait for the cron job to chew through them at 50 per batch. I ran the queue manually:

wp action-scheduler run --batch-size=200 --batches=0 --force

The --batches=0 flag tells it to keep going until the queue is empty, and --force overrides the concurrent batch limiter. On this server, it took about 12 minutes to process all 4,200 actions.

I also cleaned up the failed actions that were no longer recoverable:

wp action-scheduler clean --status=failed --before="2026-03-15 00:00:00"

For the actions that had failed within the last few days — particularly subscription renewals — I identified the affected subscriptions and triggered manual renewals:

wp wc shop_subscription list --status=on-hold --format=ids | xargs -I {} wp wc shop_subscription update {} --status=active

Verifying It Works

After setting up the system cron, I monitored the Action Scheduler queue over the next 24 hours:

# Check pending count every hour
wp action-scheduler list --status=pending --format=count

Within the first hour, the pending count dropped from the residual backlog to under 20 — all of which were legitimately scheduled for the future. The queue was healthy.

I also set up a simple monitoring check in the site's existing Uptime Kuma instance. A cron job on the server pings a Healthchecks.io endpoint after each WP-CLI cron run:

*/5 * * * * cd /home/cloudpanel/htdocs/example.com && /usr/local/bin/wp cron event run --due-now --quiet && curl -fsS -m 10 --retry 3 https://hc-ping.com/your-uuid-here > /dev/null 2>&1

If the ping stops arriving, Healthchecks.io alerts me via Slack. This catches scenarios like the server running out of disk space, PHP-FPM crashing, or the WP-CLI binary being removed during a server update.

Why This Matters More Than You Think

On a basic WordPress blog, a missed cron event means a scheduled post publishes an hour late. Annoying, not catastrophic.

On a WooCommerce store with subscriptions, missed cron events mean:

  • Failed subscription renewals — customers don't get charged, you lose revenue, and WooCommerce puts subscriptions on hold
  • Delayed order emails — customers think their order didn't go through and either contact support or place a duplicate order
  • Action Scheduler backlog — webhook deliveries to fulfilment partners pile up, causing shipping delays
  • Stale analytics — WooCommerce Admin background processing (order stats, revenue calculations) falls behind, making your dashboard unreliable
  • Failed scheduled sales — WooCommerce sale prices that should start/end at specific times don't update on schedule

This particular store was losing an estimated 8-12 subscription renewals per week to timing failures. At their average subscription value, that was over GBP 400/month in quietly lost revenue.

The Formula for Your Server

Here's my standard setup for WooCommerce stores on VPS or dedicated servers:

Server type Cron interval Action Scheduler batch size
Low-traffic (< 50 orders/day) Every 15 minutes 25
Medium-traffic (50-200 orders/day) Every 5 minutes 50
High-traffic (200+ orders/day) Every 2 minutes 100

Always use WP-CLI over curl/wget for the cron trigger. The WP-CLI approach runs in the PHP CLI context, which means:

  • No dependency on the web server or loopback requests
  • No interference from security plugins that block wp-cron.php
  • Longer execution time limits (CLI vs web request timeouts)
  • No overhead from loading the full WordPress front-end stack

One More Thing: Managed Hosting

If you're on managed WordPress hosting (WP Engine, Kinsta, Cloudways), the good news is they usually handle this for you. WP Engine and Kinsta both run system-level cron jobs automatically. Cloudways requires manual configuration through their dashboard.

If you're on shared hosting with cPanel, you can add a cron job through cPanel's Cron Jobs interface — but use the curl method instead, since WP-CLI isn't always available:

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

This is less reliable than WP-CLI (it depends on the web server responding), but it's significantly better than relying on visitor traffic to trigger WP-Cron.


Stop Firefighting. Start Maintaining.

I manage 70+ WordPress sites for UK agencies and businesses. WP-Cron misconfiguration is one of the most common issues I find during onboarding — and one of the easiest to fix permanently. If you're running a WooCommerce store and haven't set up a proper system cron, you're almost certainly losing revenue to missed scheduled events.

Whether you need ongoing maintenance, emergency support, or a one-off performance fix — I can help.

View Maintenance Plans → Get in Touch →

Stop Firefighting. Start Maintaining.

I manage 70+ WordPress sites for UK agencies and businesses. Whether you need ongoing maintenance, emergency support, or a one-off performance fix — I can help.

View Maintenance Plans Get in Touch

Get in Touch to Discuss Your Needs