Why WooCommerce Emails Stopped Sending — Diagnosing Silent WP-Cron Failures
· 8 min read
A client messaged me on a Monday morning: "Customers are saying they're not getting order confirmation emails. Some since Friday." The WooCommerce store was processing orders fine — payments going through, stock decrementing — but no emails were going out. Subscription renewals that should have fired over the weekend were also sitting in limbo.
The culprit was WP-Cron. It had silently stopped working, and nothing in WordPress told anyone.
What WP-Cron actually is (and why it breaks)
WP-Cron is not a real cron system. It doesn't run on a schedule. Instead, every time someone loads a page on your WordPress site, WordPress checks whether any scheduled tasks are overdue and runs them. No page load, no cron. It's a hack that works well enough on small blogs but falls apart on WooCommerce stores where timing matters.
WooCommerce relies heavily on WP-Cron for:
- Sending transactional emails (order confirmations, shipping notifications, renewal reminders)
- Processing subscription renewals via Action Scheduler
- Running scheduled sales and price changes
- Cleaning up expired sessions and transients
- Processing webhook deliveries
When WP-Cron stops, all of this stops with it. And WordPress doesn't log a warning or send an alert. It just silently queues everything up and waits.
Finding the problem
First, I checked whether WP-Cron was explicitly disabled:
wp config get DISABLE_WP_CRON --path=/var/www/client-site
It returned true. Someone — possibly a previous developer, possibly the hosting provider — had disabled WP-Cron but never set up a replacement. This is the single most common cause of silent cron failures I see on client sites.
Next, I checked how many scheduled events were overdue:
wp cron event list --path=/var/www/client-site --fields=hook,next_run_relative,recurrence
The output was grim. Dozens of events showing next_run_relative: 3 days ago, 2 days ago, 1 day ago. WooCommerce action hooks like woocommerce_scheduled_sales, action_scheduler_run_queue, and wc_admin_daily were all stuck.
To see the full damage, I filtered for overdue events:
wp cron event list --path=/var/www/client-site --format=json | \
python3 -c "
import json, sys, time
events = json.load(sys.stdin)
now = time.time()
overdue = [e for e in events if e['time'] < now]
print(f'{len(overdue)} overdue events')
for e in overdue[:10]:
hours = (now - e['time']) / 3600
print(f' {e[\"hook\"]}: {hours:.0f}h overdue')
"
42 overdue events. The oldest was 72 hours behind.
Checking WooCommerce Action Scheduler
WooCommerce Subscriptions and many other WooCommerce extensions use Action Scheduler rather than WP-Cron directly. But Action Scheduler still depends on WP-Cron to trigger its runner. With WP-Cron dead, the action queue was backing up:
wp action-scheduler list --status=pending --per-page=5 \
--path=/var/www/client-site \
--fields=hook,scheduled_date,status
+--------------------------------------------------+---------------------+---------+
| hook | scheduled_date | status |
+--------------------------------------------------+---------------------+---------+
| woocommerce_scheduled_subscription_payment | 2026-03-28 06:00:00 | pending |
| woocommerce_scheduled_subscription_payment | 2026-03-28 09:00:00 | pending |
| woocommerce_scheduled_subscription_payment | 2026-03-29 06:00:00 | pending |
| wc_admin_process_orders_milestone | 2026-03-28 00:00:00 | pending |
| woocommerce_run_product_attribute_lookup_update | 2026-03-28 12:00:00 | pending |
+--------------------------------------------------+---------------------+---------+
Subscription payments that should have renewed days ago were just sitting there. Every hour this went unfixed was lost revenue.
Why it broke in the first place
I checked the site health loopback test:
wp site health check --path=/var/www/client-site --fields=test,status \
| grep -i cron
The loopback was actually passing — but it didn't matter because DISABLE_WP_CRON was set to true. Even if traffic had been hitting the site, WordPress wouldn't have fired any events.
Looking at the server's crontab confirmed there was no system cron replacement:
crontab -l -u www-data
Empty. The previous developer had disabled WP-Cron (probably to fix a performance issue on a high-traffic page) and either forgot to add a system cron or assumed the hosting provider would handle it.
The fix: a proper system cron
Step 1: Set up the system cron
I prefer using WP-CLI over hitting wp-cron.php via HTTP. It's faster, avoids web server overhead, and gives you proper error output if something fails:
sudo crontab -u www-data -e
*/5 * * * * cd /var/www/client-site && /usr/local/bin/wp cron event run --due-now --quiet 2>&1 | logger -t wp-cron
This runs every 5 minutes, executes any overdue events, and pipes errors to syslog where my monitoring can pick them up. The --quiet flag suppresses normal output but still shows errors.
For sites on cPanel where you can't use WP-CLI, the HTTP approach works:
*/5 * * * * wget -q -O /dev/null https://example.com/wp-cron.php?doing_wp_cron >/dev/null 2>&1
But WP-CLI is always the better option when available.
Step 2: Keep DISABLE_WP_CRON enabled
This is important — you want DISABLE_WP_CRON set to true now. It prevents the default behaviour where every page load checks for due events. On a WooCommerce store doing hundreds of requests per minute, that's hundreds of unnecessary cron checks. The system cron handles it instead.
define( 'DISABLE_WP_CRON', true );
Step 3: Flush the backlog carefully
With 42 overdue events and pending subscription renewals, I couldn't just fire everything at once. That would trigger a flood of emails to customers about orders from 3 days ago and could overload the mail server.
First, I ran the Action Scheduler queue manually to process the subscription renewals:
wp action-scheduler run --batch-size=10 --path=/var/www/client-site
I ran this in small batches, monitoring the server load and checking that emails were going out correctly. For the subscription payments specifically, I verified each one in WooCommerce to make sure the payment gateway processed them properly before marking them complete.
For the standard WP-Cron events, I ran them in sequence:
wp cron event run --due-now --path=/var/www/client-site
Step 4: Add monitoring
A system cron can also fail silently — the crontab could get wiped during a server migration, or the WP-CLI binary could break after a PHP upgrade. I added a healthcheck using Healthchecks.io:
*/5 * * * * cd /var/www/client-site && /usr/local/bin/wp cron event run --due-now --quiet 2>&1 | logger -t wp-cron && curl -fsS -m 10 --retry 5 https://hc-ping.com/your-uuid-here > /dev/null
If the cron stops running for more than 10 minutes, I get an alert. This is the part most guides skip, and it's the most important part for a maintenance provider. You need to know before the client does.
Common variations of this problem
After fixing dozens of these across client sites, I've seen a few recurring patterns:
Hosting provider disabled it silently. Some shared hosts disable WP-Cron to reduce server load. They don't always tell you. Check wp-config.php on any new site you take over.
Security plugin blocking loopback. Wordfence, Sucuri, and similar plugins can block the internal HTTP request that WP-Cron uses to spawn. If DISABLE_WP_CRON is false but events still aren't running, check your security plugin's firewall rules.
ALTERNATE_WP_CRON masking the real problem. Some developers add define('ALTERNATE_WP_CRON', true) as a band-aid. This uses a redirect-based approach instead of the async spawn. It sort of works but adds latency to every page load and can confuse caching plugins. A system cron is always the better fix.
BasicAuth or IP restrictions blocking wp-cron.php. Staging sites with HTTP authentication will block the internal cron request. If you're using BasicAuth on a staging site, either use WP-CLI cron or whitelist wp-cron.php in your server config:
location = /wp-cron.php {
satisfy any;
allow all;
fastcgi_pass unix:/run/php/php-fpm.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
What I set up on every maintenance client
Every site I onboard gets the same cron configuration:
DISABLE_WP_CRONset totrueinwp-config.php- System cron running
wp cron event run --due-nowevery 5 minutes - Healthchecks.io monitoring on the cron job
- WP Crontrol plugin installed for visibility into scheduled events
It takes 10 minutes to set up and prevents the exact scenario this client hit — three days of silent failure with real revenue impact.
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.
