Why a WooCommerce Scheduled Sale Didn't Fire at Midnight — and How I Fixed It

· 11 min read

A client messaged me at 06:40 on Black Friday morning. They had scheduled sale prices on 120 products the week before — 20% off across their entire winter range, set to go live at midnight. It was now nearly seven hours past midnight and every product was still showing full price. The "Sale!" badge was missing from the shop page. Customers were landing on the site from email campaigns advertising the discount, seeing regular prices, and bouncing.

By the time I finished, the root cause turned out to be three things working together: a disabled WP-Cron trigger, a timezone mismatch that had been silently wrong for months, and a WooCommerce lookup table that was out of sync with the actual product data. Any one of those alone might not have caused the problem. All three together meant the sale never had a chance.

How WooCommerce scheduled sales actually work

Before I walk through the investigation, it helps to understand the mechanism. WooCommerce stores scheduled sale dates in two meta fields per product: _sale_price_dates_from and _sale_price_dates_to, both as Unix timestamps. When those dates arrive, WooCommerce needs to do three things:

  1. Copy the value from _sale_price into _price (the field that actually controls what the customer sees)
  2. Clear the date meta so the product does not get re-processed on the next run
  3. Update the wc_product_meta_lookup table, which is the fast lookup layer that powers "on sale" filters, widgets, and the shop page sale badge

In older WooCommerce versions, all of this was handled by a single daily WP-Cron event called woocommerce_scheduled_sales. It ran wc_scheduled_sales() once every 24 hours, checked which products had passed their start or end dates, and flipped the prices. The problem with that approach was obvious: if the cron ran at 03:00 and your sale started at 09:00, customers saw regular prices until the next day's cron run.

Modern WooCommerce (9.x and later) fixes this with per-product Action Scheduler events. When you save a product with a scheduled sale, WooCommerce creates an Action Scheduler job that fires at the exact start timestamp. The daily wc_scheduled_sales() cron still exists as a safety net, but the precise timing comes from Action Scheduler.

Both mechanisms depend on the same thing: WP-Cron actually running.

Step 1: Check whether WP-Cron is running at all

I SSH'd into the server and checked the WP-Cron constant first:

wp config get DISABLE_WP_CRON --path=/home/client/htdocs

Output: true.

That was expected. This site runs on a VPS with a proper system cron calling WP-Cron on a schedule — which is the recommended setup for any WooCommerce store. I checked the system crontab:

crontab -l -u www-data | grep wp-cron

Nothing. No cron entry. The previous developer had set DISABLE_WP_CRON to true in wp-config.php but had never created the system cron job to replace it. WP-Cron had been completely dead on this site for an unknown period.

I checked the Action Scheduler table to confirm:

SELECT hook, status, scheduled_date_gmt, scheduled_date_local
FROM wp_actionscheduler_actions
WHERE hook LIKE '%scheduled_sale%'
  AND status = 'pending'
ORDER BY scheduled_date_gmt
LIMIT 10;

There were 240 pending actions — 120 sale-start events and 120 sale-end events — all with scheduled_date_gmt values in the past. They were sitting there waiting to be processed, but Action Scheduler never got a chance to run because its queue processor depends on WP-Cron firing.

Step 2: Fire the cron manually and check the result

I ran WP-Cron manually to unblock the queue:

wp cron event run --due-now --path=/home/client/htdocs

Then triggered Action Scheduler directly:

wp action-scheduler run --path=/home/client/htdocs

This processed the pending sale actions. I checked one of the products to see if the price had updated:

wp post meta get 4821 _price --path=/home/client/htdocs
wp post meta get 4821 _sale_price --path=/home/client/htdocs
wp post meta get 4821 _regular_price --path=/home/client/htdocs

The _price field now matched _sale_price. The meta was correct. But when I loaded the shop page in a browser, the product was still not showing the sale badge and the "On Sale" filter returned zero results.

Step 3: The lookup table was stale

The shop page sale filter and the sale badge both query wc_product_meta_lookup.onsale, not the raw postmeta. I checked the lookup table directly:

SELECT product_id, min_price, max_price, onsale
FROM wp_wc_product_meta_lookup
WHERE product_id = 4821;
+------------+-----------+-----------+--------+
| product_id | min_price | max_price | onsale |
+------------+-----------+-----------+--------+
|       4821 |     89.00 |     89.00 |      0 |
+------------+-----------+-----------+--------+

The onsale flag was still 0 and the prices were stale — they reflected the regular price, not the sale price. The Action Scheduler had updated the postmeta correctly but the lookup table had not caught up.

This is a known edge case. When Action Scheduler processes a large batch of sale events after a long delay, the lookup table regeneration can lag behind because each product schedules its own deferred update via the WooCommerce queue. If the queue is also backed up — which it was, because cron had been dead — the lookup table stays out of sync even after prices are correct in postmeta.

The fix was to force a full lookup table regeneration:

wp wc tool run regenerate_product_lookup_tables --user=1 --path=/home/client/htdocs

After that completed (about 90 seconds for 800 products), the sale badges appeared on the shop page, the "On Sale" filter worked, and prices displayed correctly. Crisis resolved for the customer-facing side.

Step 4: The timezone trap that would have bitten them again

While I was in the WooCommerce status page verifying the fix, I noticed something in the environment info. The WordPress timezone was set to UTC+0 — a manual offset — rather than a named timezone like Europe/London.

wp option get timezone_string --path=/home/client/htdocs

Empty string. That meant WordPress was using the gmt_offset option instead:

wp option get gmt_offset --path=/home/client/htdocs

Output: 0.

This site serves UK customers. The client is based in London. They set up their sale to start at midnight — but midnight in their head meant midnight GMT, which is only correct during winter. During British Summer Time (BST), the UK is UTC+1. When you use a manual UTC offset in WordPress instead of a timezone string like Europe/London, WordPress does not adjust for daylight saving time. The sale start timestamp was stored as midnight UTC, which during BST is actually 01:00 local time.

For this particular incident it did not matter because cron was completely dead anyway. But if cron had been running, the sale would have started an hour late during summer — and ended an hour late too. On a time-sensitive promotion, that is lost revenue.

I fixed the timezone setting:

wp option update timezone_string 'Europe/London' --path=/home/client/htdocs
wp option delete gmt_offset --path=/home/client/htdocs

Always use a named timezone, never a manual UTC offset. The named timezone handles DST transitions automatically.

Step 5: Set up the system cron properly

The missing cron job was the root cause of everything. I created it:

echo '*/2 * * * * www-data /usr/bin/php /home/client/htdocs/wp-cron.php > /dev/null 2>&1' \
  | sudo tee /etc/cron.d/wp-cron-client

Every two minutes is a good interval for a WooCommerce store. It is frequent enough that Action Scheduler jobs fire within minutes of their scheduled time, and light enough that the overhead is negligible — each invocation takes under 200ms when there is nothing to process.

I verified it was working:

# Wait 3 minutes, then check
wp cron event list --path=/home/client/htdocs --fields=hook,next_run_relative | head -10

All events showed reasonable next_run_relative values. Action Scheduler was processing its queue. The system was healthy.

Step 6: Add monitoring so this never goes silent again

The real failure here was not the missing cron job — it was that nobody noticed for weeks or months that cron was dead. Scheduled emails were not sending. Abandoned cart reminders were not firing. Subscription renewals were delayed. The sale prices were just the most visible symptom of a much larger problem.

I added a simple cron health check using Healthchecks.io. The idea is that the cron job pings an external endpoint every time it runs. If the ping stops arriving, the monitoring service alerts me:

# /etc/cron.d/wp-cron-client
*/2 * * * * www-data /usr/bin/php /home/client/htdocs/wp-cron.php > /dev/null 2>&1 && curl -fsS -m 10 --retry 3 https://hc-ping.com/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx > /dev/null 2>&1

If two consecutive pings are missed (four minutes), I get a notification. That is early enough to investigate before anything customer-facing breaks.

The diagnostic checklist

If you are dealing with WooCommerce sale prices that refuse to activate, work through this in order:

1. Is WP-Cron running?

wp config get DISABLE_WP_CRON

If true, check for a system cron job. If there is no system cron job, nothing time-based in WordPress works.

2. Is Action Scheduler processing its queue?

wp action-scheduler list --status=pending --per-page=5

If pending actions are piling up with timestamps in the past, the queue is stuck.

3. Are the sale dates stored correctly?

wp post meta get <product_id> _sale_price_dates_from
wp post meta get <product_id> _sale_price_dates_to

These are Unix timestamps. Convert them and check they match what you expected:

date -d @1732838400

4. Is the timezone correct?

wp option get timezone_string

If this returns an empty string, you are using a manual UTC offset and DST will not be handled. Switch to a named timezone.

5. Has the price meta actually updated?

wp post meta get <product_id> _price
wp post meta get <product_id> _sale_price

If _price still equals _regular_price, the sale event has not been processed yet.

6. Is the lookup table in sync?

SELECT product_id, onsale, min_price
FROM wp_wc_product_meta_lookup
WHERE product_id = <product_id>;

If the onsale flag is 0 but the postmeta shows a sale price, regenerate the lookup tables:

wp wc tool run regenerate_product_lookup_tables --user=1

7. Is a page cache serving stale HTML?

Even after all the database-level fixes, a full-page cache (Nginx FastCGI cache, LiteSpeed, Cloudflare, WP Rocket) might still serve the old price to visitors. Purge it:

# Nginx FastCGI cache
rm -rf /var/cache/nginx/fastcgi/*
# Or via WP-CLI if using a cache plugin
wp cache flush

What I changed in my maintenance process after this

This incident exposed a gap in how I onboard new sites. I now check three things during every new site audit:

  1. Cron health: Is DISABLE_WP_CRON set? If so, does a working system cron job exist? Is it pinging an external monitor?
  2. Timezone setting: Is timezone_string set to a named timezone, or is the site using a bare UTC offset?
  3. Action Scheduler queue depth: Are there pending actions with past-due timestamps? A small number is normal. Hundreds is a sign that cron is not firing.

These take sixty seconds to check via WP-CLI and they catch exactly the kind of silent failure that costs a store real money on the worst possible day.


If your WooCommerce store has ever missed a sale launch, dropped a subscription renewal, or silently stopped sending order emails, the problem is almost certainly in the cron chain. I deal with this on WooCommerce maintenance plans — it is one of the first things I audit on every new site. If you want someone watching this for you, take a look at the plans.

Stop Firefighting. Start Maintaining.

I manage 70+ WordPress sites for 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