
How to Replace WP-Cron with a Real Server Cron Job?
A post scheduled for 9:00 AM goes live at 11:40 AM. A WooCommerce subscription renewal runs a day late. A backup plugin swears it runs nightly, but the last archive is from last week. On a busy site, the opposite happens: every page view triggers a background request to wp-cron.php, and under load those requests pile up and eat PHP workers. Both problems have the same cause. WordPress does not have a real scheduler. It has WP-Cron, a scheduler that only runs when someone visits the site.
This article explains how WP-Cron actually works, when it causes problems, and how to replace it with a real server cron job. It covers disabling the built-in trigger, running due events with WP-CLI or a plain HTTP request, using a systemd timer instead of crontab, handling multisite, scheduling your own events correctly, and verifying that everything still runs.
How WP-Cron Works
WP-Cron is not a daemon. Scheduled events are stored in a single option, cron, in the wp_options table. Each event has a timestamp, a hook name, arguments, and optionally a recurrence such as hourly or daily.
On every page load, WordPress calls wp_cron() during init. It checks whether any event is due. If one is, WordPress fires a non-blocking loopback HTTP request to https://example.com/wp-cron.php?doing_wp_cron=<timestamp>. That separate request loads WordPress, takes a lock, and runs every due event by calling do_action() on its hook.
This design has two consequences:
- No visitors, no cron. If nobody visits the site between 2:00 AM and 8:00 AM, nothing scheduled in that window runs until the first visit after 8:00 AM.
- Visitors pay for cron. Each page load checks the schedule, and due events cost an extra PHP request. On a busy site with many due events, that adds up.
| Problem | Cause in WP-Cron | Effect |
|---|---|---|
| Missed scheduled posts | No traffic at the scheduled time | Posts publish late or show "Missed schedule" |
| Late emails, renewals, and backups | Events run only on the next visit | Business processes drift by hours |
| Extra load under traffic | Cron check on every request, loopback to wp-cron.php | Wasted PHP workers during peaks |
| Cron never runs | Loopback blocked by firewall, basic auth, or DNS | Nothing scheduled runs at all |
| Page caching hides traffic | Full-page cache serves visitors without running PHP | Cron triggers far less often than expected |
The last row is easy to miss. If a CDN or full-page cache serves most visitors, PHP rarely runs, so WP-Cron rarely fires, even on a site with plenty of traffic. Caching is still the right choice for speed, as explained in what is the purpose of a caching plugin in WordPress, but it makes a real cron job even more important.
When You Should Replace WP-Cron
Replace WP-Cron with a server cron job if any of these apply:
- You schedule posts and they sometimes publish late
- You run WooCommerce, memberships, subscriptions, or newsletters
- Backups, imports, or syncs must run at a predictable time
- The site sits behind full-page caching or a CDN
- Site Health reports "A scheduled event is late" or "A scheduled event has failed"
- The server blocks loopback requests
On a small brochure site with steady traffic and no time-sensitive tasks, WP-Cron is usually fine. Everywhere else, a real cron job is cheap insurance.
Step 1: Disable the Built-In Trigger
First, tell WordPress to stop spawning cron on page loads. Add this to wp-config.php above the line that says /* That's all, stop editing! */:
// wp-config.php
define( 'DISABLE_WP_CRON', true );
This does not disable scheduled events. Events are still registered, stored, and run. It only stops page views from triggering wp-cron.php. From now on, something else must run due events, or nothing will.
Do not deploy this line on its own. Set up the replacement trigger in the next step first, then disable WP-Cron, so there is no gap.
Step 2: Choose How to Run Due Events
You have two good options. Both are triggered by the system scheduler, usually crontab.
| Method | How it runs | Best for |
|---|---|---|
WP-CLI cron event run | PHP CLI process on the server | VPS, dedicated servers, managed hosts with SSH and WP-CLI |
HTTP request to wp-cron.php | curl or wget hits the site URL | Shared hosting without WP-CLI, or external cron services |
WP-CLI is the better choice when available. It runs in the PHP CLI, so web server timeouts and PHP-FPM worker limits do not apply, and long tasks such as imports are less likely to be killed halfway through.
Option A: WP-CLI
First check that WP-CLI works for the site and can see the events:
# Terminal
cd /var/www/example.com/public
wp cron event list --fields=hook,next_run_relative,recurrence
wp cron event run --due-now
wp cron event run --due-now runs every event whose time has passed, then exits. That is exactly what a cron job should call.
Now open the crontab for the user that owns the WordPress files. On most servers this is the web server user, or a dedicated site user. Running WP-CLI as root creates files owned by root and breaks uploads and updates later.
# Terminal
sudo crontab -u www-data -e
Add this line to run due events every five minutes:
# crontab for www-data
*/5 * * * * cd /var/www/example.com/public && /usr/local/bin/wp cron event run --due-now --quiet >> /var/log/wp-cron-example.log 2>&1
Notes on this line:
- Use the full path to
wp. Cron runs with a minimalPATH. Find the path withwhich wp. cdinto the WordPress root, or pass--path=/var/www/example.com/public.--quietsuppresses the success line for every event so the log only grows when something goes wrong. Remove it while testing.- Every five minutes is a sensible default. Use every minute for sites that rely on precise timing, such as subscription renewals or flash sales.
Preventing Overlapping Runs
If a run takes longer than the interval, the next run starts while the first is still going. WordPress takes its own lock, but it is cheap to add an OS-level lock with flock:
# crontab for www-data
*/5 * * * * cd /var/www/example.com/public && /usr/bin/flock -n /tmp/wp-cron-example.lock /usr/local/bin/wp cron event run --due-now --quiet >> /var/log/wp-cron-example.log 2>&1
flock -n exits immediately if the previous run still holds the lock, so runs never stack up.
Option B: HTTP Request to wp-cron.php
Without WP-CLI, have cron request wp-cron.php directly. This goes through the web server, exactly like WP-Cron's own loopback, but on a fixed schedule instead of on page views:
# crontab
*/5 * * * * curl -fsS --max-time 300 -o /dev/null "https://example.com/wp-cron.php?doing_wp_cron" >> /var/log/wp-cron-example.log 2>&1
Or with wget:
# crontab
*/5 * * * * wget -q -O /dev/null --timeout=300 "https://example.com/wp-cron.php?doing_wp_cron"
On shared hosting, add the same command in your control panel's cron jobs screen, such as cPanel → Cron Jobs. If your host gives you no cron at all, an external monitoring or cron service that requests that URL on a schedule works the same way.
Things to check with the HTTP method:
- The URL must be reachable from where the request runs. Basic auth on a staging site, a "coming soon" plugin, or a firewall rule can block it.
- Long tasks hit web timeouts. PHP's
max_execution_time, PHP-FPM'srequest_terminate_timeout, and proxy timeouts all apply. If tasks get killed, switch to WP-CLI. - Exclude
wp-cron.phpfrom page caching and bot protection, so the request reaches PHP.
Step 3: Use a systemd Timer Instead of Crontab (Optional)
On modern Linux servers, a systemd timer is an alternative to crontab with better logging and built-in overlap protection. Create a service unit:
# /etc/systemd/system/wp-cron-example.service
[Unit]
Description=Run due WordPress cron events for example.com
[Service]
Type=oneshot
User=www-data
WorkingDirectory=/var/www/example.com/public
ExecStart=/usr/local/bin/wp cron event run --due-now
Then a timer that triggers it:
# /etc/systemd/system/wp-cron-example.timer
[Unit]
Description=Run WordPress cron for example.com every 5 minutes
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
AccuracySec=30s
Persistent=true
[Install]
WantedBy=timers.target
Enable and start it:
# Terminal
sudo systemctl daemon-reload
sudo systemctl enable --now wp-cron-example.timer
systemctl list-timers wp-cron-example.timer
journalctl -u wp-cron-example.service --since "1 hour ago"
A oneshot service never runs twice at the same time, and journalctl keeps the output without extra log files.
Step 4: Handle WordPress Multisite
On a multisite network, each site has its own cron option. A single wp cron event run --due-now only runs events for the main site. Loop over every site instead:
# /usr/local/bin/wp-cron-network.sh
#!/usr/bin/env bash
set -euo pipefail
WP=/usr/local/bin/wp
WP_PATH=/var/www/network.example.com/public
for url in $("$WP" site list --field=url --archived=0 --deleted=0 --spam=0 --path="$WP_PATH"); do
"$WP" cron event run --due-now --quiet --url="$url" --path="$WP_PATH"
done
Make it executable and call it from crontab:
# Terminal
sudo chmod +x /usr/local/bin/wp-cron-network.sh
sudo crontab -u www-data -e
# crontab for www-data
*/5 * * * * /usr/bin/flock -n /tmp/wp-cron-network.lock /usr/local/bin/wp-cron-network.sh >> /var/log/wp-cron-network.log 2>&1
For very large networks, run sites in parallel or spread them across several schedules so a full pass finishes within the interval.
Step 5: Verify That Events Run
Once the cron job is in place, confirm it works before you rely on it:
# Terminal
wp cron test
wp cron event list --fields=hook,next_run_gmt,next_run_relative,recurrence
wp cron event list --fields=hook,next_run_relative --format=table | grep "ago"
wp cron testchecks whether WP-Cron's own spawning works. WithDISABLE_WP_CRONset, it reports that the constant is set and spawning is disabled. That is expected, because your server cron job now does the spawning. The command is most useful before the switch, to confirm loopback requests work.- Events with a
next_run_relativevalue in the past ("5 minutes ago") are overdue. After your cron job runs, they should disappear from that list and reappear with a future time if they recur.
In the admin, Tools → Site Health reports late or failed scheduled events. A clean Site Health screen a day after the switch is a good sign.
To prove the whole chain end to end, schedule a post a few minutes ahead, wait, and confirm it published on time. Do this on a site with no other traffic, such as staging, so a visitor cannot accidentally trigger it.
Scheduling Your Own Events Correctly
A real cron job only runs what is scheduled. If you write plugins or theme code, schedule events in a way that survives deactivation and does not create duplicates.
Registering a Custom Interval
WordPress ships with hourly, twicedaily, daily, and weekly. Add others with the cron_schedules filter:
// wp-content/plugins/wsm-sync/wsm-sync.php
add_filter( 'cron_schedules', function ( array $schedules ): array {
$schedules['every_fifteen_minutes'] = array(
'interval' => 15 * MINUTE_IN_SECONDS,
'display' => __( 'Every 15 Minutes', 'wsm-sync' ),
);
return $schedules;
} );
Scheduling on Activation and Cleaning Up on Deactivation
// wp-content/plugins/wsm-sync/wsm-sync.php
/**
* Plugin Name: WSM Inventory Sync
* Description: Syncs inventory from the warehouse API every 15 minutes.
* Version: 1.0.0
*/
const WSM_SYNC_HOOK = 'wsm_sync_inventory';
register_activation_hook( __FILE__, function (): void {
if ( ! wp_next_scheduled( WSM_SYNC_HOOK ) ) {
wp_schedule_event( time(), 'every_fifteen_minutes', WSM_SYNC_HOOK );
}
} );
register_deactivation_hook( __FILE__, function (): void {
wp_clear_scheduled_hook( WSM_SYNC_HOOK );
} );
add_action( WSM_SYNC_HOOK, 'wsm_sync_inventory' );
function wsm_sync_inventory(): void {
$response = wp_remote_get(
'https://warehouse.example.com/api/stock',
array( 'timeout' => 20 )
);
if ( is_wp_error( $response ) ) {
error_log( 'Inventory sync failed: ' . $response->get_error_message() );
return;
}
$items = json_decode( wp_remote_retrieve_body( $response ), true );
// Update products from $items here.
}
Key rules:
- Always guard with
wp_next_scheduled()beforewp_schedule_event(). Without it, every activation adds another copy of the event. - Clear the hook on deactivation with
wp_clear_scheduled_hook(), or the event stays in thecronoption forever and runs a missing callback. - Arguments matter.
wp_next_scheduled( 'hook', array( 42 ) )andwp_next_scheduled( 'hook' )are different events. Pass the same arguments everywhere. - Keep callbacks idempotent. If a run is missed and two are due, or a run is retried, running the task twice should not double-charge a customer or send an email twice.
For one-off tasks, use wp_schedule_single_event( time() + HOUR_IN_SECONDS, 'hook_name' ).
WP-CLI for Managing Events
# Terminal
wp cron schedule list
wp cron event run wsm_sync_inventory
wp cron event schedule wsm_sync_inventory now every_fifteen_minutes
wp cron event delete wsm_sync_inventory
Running one hook by name with wp cron event run <hook> is the fastest way to debug a task without waiting for its schedule.
ALTERNATE_WP_CRON: A Fallback, Not a Fix
Some servers block the loopback request WP-Cron depends on. WordPress has a fallback constant for that case:
// wp-config.php
define( 'ALTERNATE_WP_CRON', true );
With it, WordPress runs cron by redirecting the visitor's request to a URL with ?doing_wp_cron appended, instead of making a loopback request. The visitor's request does the work, and a query string shows up in their address bar. It is a workaround for broken loopbacks, and it still depends on traffic. If you can add a real cron job, do that and leave this constant out.
Common Problems and Fixes
- Nothing runs after setting
DISABLE_WP_CRON. The cron job is not running or is failing silently. Check the log file you redirected output to, run the exact command by hand as the same user, and confirmwhich wpmatches the path in crontab. - "Error: This does not seem to be a WordPress installation." The command is not running from the WordPress root. Add
cd /path/to/wordpress &&or--path=. - Files created by cron are owned by root. The crontab belongs to root. Move the job to the site user's crontab with
crontab -u www-data -e, and fix ownership withchown -R www-data:www-data wp-content. - PHP version mismatch. The CLI uses a different PHP binary than the web server, so code fails only from cron. Check with
php -vand point WP-CLI to the right binary with theWP_CLI_PHPenvironment variable. - The
curlmethod returns 401 or 403. The site is behind basic auth, a maintenance plugin, or a security firewall. Allowwp-cron.php, or switch to WP-CLI. - Long tasks stop halfway through. Web timeouts kill the HTTP method. Use WP-CLI, which runs without them, and split very large jobs into batches.
- Duplicate events pile up. Code calls
wp_schedule_event()on every request without awp_next_scheduled()check. Fix the code, then clean up withwp cron event delete <hook>and reschedule once. - Multisite sub-sites never run their events. Only the main site is processed. Loop through
wp site listas shown above.
WP-Cron FAQ
Only if nothing replaces it. The constant stops page views from triggering cron, but scheduled posts are still stored as events. Once a server cron job runs due events every few minutes, scheduled posts publish on time, usually more reliably than before.
Every five minutes suits most sites. Use every minute for stores with subscriptions, timed sales, or other tasks where a few minutes matter. Running more often than every minute is not possible with crontab and is rarely needed.
WP-CLI is better when you have SSH access. It runs as a command-line PHP process, avoids web server timeouts and worker limits, and does not depend on the site being reachable over HTTP. The curl method is the right fallback on shared hosting without WP-CLI.
It removes the cron check and the occasional loopback request from visitor page loads, which helps busy sites and sites with many scheduled tasks. The bigger benefit is reliability: tasks run on time regardless of traffic or caching.
Some managed WordPress hosts already disable WP-Cron and run it from the server. Check your host's documentation or look for DISABLE_WP_CRON in wp-config.php before adding your own job, so events are not run by two schedulers.
It is a fallback for servers that block the loopback request WP-Cron normally makes. WordPress redirects a visitor request to run cron instead. It still depends on traffic, so a server cron job is the better fix whenever you can add one.
Conclusion
WP-Cron is a clever compromise for hosting environments without a scheduler, but it ties your scheduled tasks to visitor traffic. On sites behind caching, on low-traffic sites, and on stores that depend on renewals and emails, that means late or missed work. Replacing it takes a few minutes: set DISABLE_WP_CRON, add a crontab entry or systemd timer that runs wp cron event run --due-now every few minutes, loop over sites on multisite, and confirm with wp cron event list and Site Health that nothing is overdue.
If you also write your own scheduled tasks, guard them with wp_next_scheduled(), clear them on deactivation, and make them safe to run twice. The scheduler will then do exactly what you expect, at the time you expect it. For more tools to keep a site in shape, see the ultimate guide to WordPress maintenance and how to use WP-CLI to manage WordPress from the command line.
Here are some useful references for going deeper on WordPress cron:
- WordPress Developer Resources: Hooking WP-Cron Into the System Task Scheduler — the official guide to replacing the built-in trigger.
- WordPress Developer Resources: Scheduling WP Cron Events —
wp_schedule_event(),wp_next_scheduled(), and deactivation cleanup. - WordPress Developer Resources: Understanding WP-Cron Scheduling — intervals and the
cron_schedulesfilter. - WP-CLI Commands: wp cron — every subcommand for listing, running, scheduling, and deleting events.
- WordPress Developer Resources: Editing wp-config.php —
DISABLE_WP_CRON,ALTERNATE_WP_CRON, and related constants.


