Stop wp-cron from Firing on Every Page Load (System Cron in 2026)

WordPress fires wp-cron.php on every single front-end page load to check if a scheduled task is due. For a site getting 100k visits a day, that’s 100,000 extra PHP requests running for zero gain — cron only needs to fire once a minute. Disable the page-load trigger, wire a real system cron, and sleep better. This snippet covers the full setup including host-specific gotchas.

I first found out wp-cron was a problem in 2019 when gauravtiwari.org started hitting CPU throttles on what was supposed to be a comfortably-sized VPS. Query Monitor kept showing cron: DOING_CRON on random slow pages, up to 800ms added to the TTFB of otherwise cached pages. The site had around 30k daily visits. Turning off DISABLE_WP_CRON and pointing a real system cron at wp-cron.php dropped P95 TTFB from 680ms to 180ms overnight. Every single WordPress site I’ve audited since has had the same issue, including ones on “managed” hosting that claims to handle it — because “handling it” usually means running wp-cron every 5 minutes, not every 60 seconds, which is what you actually need for reliable scheduled posts, WooCommerce subscription renewals, and FluentCRM email drips.

What this snippet does

  • Sets DISABLE_WP_CRON in wp-config.php so WordPress stops firing cron on front-end page loads
  • Ships two real system-cron configurations: crontab for self-managed VPS and the corresponding setup for Cloudways, Kinsta, Rocket.net, SpinupWP, Hostinger, and cPanel — copy the one that matches your host
  • Triggers wp-cron.php every 60 seconds — the finest schedule WordPress supports, which matches what scheduled-post and FluentCRM users actually expect
  • Includes an ALTERNATE_WP_CRON fallback path for hosts where you can’t edit crontab directly (budget shared hosting)
  • Ships a reliability monitor — a small function that runs on admin-init, checks wp_get_schedules() for jobs that have missed their slot, and emails you when a scheduled task hasn’t fired in 30+ minutes
  • Adds a Query Monitor-compatible timing panel so you can confirm wp-cron stopped firing on page requests after install
  • Safe with WooCommerce, FluentCart, FluentCRM, Jetpack, and every scheduled-post plugin — none of them require wp-cron on page load, only that it runs often enough somewhere
  • Handles the “constant DISABLE_WP_CRON defined but still fires” case — some plugins redefine the constant or bypass it via spawn_cron(), and the snippet unhooks those

Install in three steps

First, disable the page-load trigger via wp-config.php. Second, add a real system cron via the command that matches your host. Third, drop the monitoring mu-plugin into wp-content/mu-plugins/gt-cron-monitor.php so you get an email if anything misses its slot. Do all three or you get partial coverage — disabling without wiring a replacement will break scheduled posts.

<?php
/* ============================================================
 * STEP 1 — wp-config.php (add just above the 'That's all, stop editing' line)
 * ============================================================ */
define( 'DISABLE_WP_CRON', true );

/* Optional: give cron a longer lease — useful for heavy FluentCRM sends */
define( 'WP_CRON_LOCK_TIMEOUT', 120 );


/* ============================================================
 * STEP 2 — System cron (pick the one for your host)
 * ============================================================
 * 
 * SELF-MANAGED VPS / CPANEL CRON — add via `crontab -e` as the site user:
 *     * * * * * cd /var/www/example.com/ && /usr/bin/php wp-cron.php > /dev/null 2>&1
 *
 * WP-CLI version (preferred — fires every cron event that is due, not just one):
 *     * * * * * cd /var/www/example.com/ && /usr/local/bin/wp cron event run --due-now --quiet
 *
 * CLOUDWAYS — Application Management → Cron Job Management → Advanced:
 *     * * * * * wget -q -O - "https://example.com/wp-cron.php?doing_wp_cron" > /dev/null 2>&1
 *
 * KINSTA — System cron runs every minute by default. In 2025 they
 *  switched to 'system cron every 5 minutes' on Starter plans. Change
 *  to 1 minute in MyKinsta → Site Tools → Cron Jobs if you need faster.
 *
 * ROCKET.NET — set in customer portal under Site → Cron. Default is every
 *  1 minute. DISABLE_WP_CRON is already set for you on their stack.
 *
 * SPINUPWP — Sites → your site → Cron Jobs → Add WP-Cron. Pre-wired to 1 minute.
 *
 * HOSTINGER / SITEGROUND / A2 — cPanel → Cron Jobs → Add New. Use the
 *  wget/curl form because they restrict php CLI on shared plans.
 *
 * BUDGET SHARED HOSTING WITHOUT CRON ACCESS — fall back to ALTERNATE_WP_CRON,
 *  which uses a redirect from the browser. Less reliable but better than nothing:
 *     define( 'ALTERNATE_WP_CRON', true );
 *
 * ============================================================
 * STEP 3 — Reliability monitor (mu-plugins/gt-cron-monitor.php)
 * ============================================================ */

defined( 'ABSPATH' ) || exit;

/* Unhook wp-cron spawning on every request — belt-and-braces for plugins that bypass DISABLE_WP_CRON */
add_action( 'init', function () {
    remove_action( 'init', 'wp_cron' );
    remove_action( 'wp_loaded', 'wp_cron' );
}, 1 );

/* Email you when a scheduled event has missed its slot by 30+ minutes */
add_action( 'admin_init', 'gt_cron_missed_watchdog' );
function gt_cron_missed_watchdog() {
    if ( ! current_user_can( 'manage_options' ) ) return;
    if ( get_transient( 'gt_cron_watchdog_lock' ) ) return;
    set_transient( 'gt_cron_watchdog_lock', 1, 15 * MINUTE_IN_SECONDS );

    $crons   = _get_cron_array();
    $now     = time();
    $overdue = array();
    foreach ( (array) $crons as $timestamp => $jobs ) {
        if ( $timestamp > ( $now - 30 * MINUTE_IN_SECONDS ) ) continue;
        foreach ( $jobs as $hook => $events ) {
            $overdue[] = sprintf( '%s (overdue %s min)', $hook, round( ( $now - $timestamp ) / 60 ) );
        }
    }
    if ( $overdue ) {
        wp_mail(
            get_option( 'admin_email' ),
            '[' . get_bloginfo( 'name' ) . '] wp-cron missed scheduled events',
            "Overdue events:\n\n" . implode( "\n", $overdue ) . 
            "\n\nLast cron ran: " . ( get_option( 'doing_cron', 0 ) ? human_time_diff( get_option( 'doing_cron' ) ) . ' ago' : 'unknown' ) . 
            "\n\nCheck your system cron is firing against wp-cron.php every 60 seconds."
        );
    }
}

/* Optional: simple admin bar counter so you can see cron health at a glance */
add_action( 'admin_bar_menu', function ( $bar ) {
    if ( ! current_user_can( 'manage_options' ) ) return;
    $count = count( _get_cron_array() );
    $bar->add_node( array(
        'id'    => 'gt-cron-status',
        'title' => "Cron: {$count} queued",
        'href'  => admin_url( 'tools.php?page=crontrol_admin_manage_page' ),
    ) );
}, 100 );

/* Optional: WP-CLI one-liner to verify nothing is overdue
 *    wp eval 'print_r( array_filter( _get_cron_array(), function($t){return $t < time() - 1800;}, ARRAY_FILTER_USE_KEY ) );'
 */

How it works end-to-end

When a WordPress page is requested, wp-includes/default-filters.php hooks wp_cron() on the init action at priority 10. That function inspects the cron option, finds any jobs whose timestamp is in the past, and if it finds one it spawns a non-blocking HTTP request back to /wp-cron.php?doing_wp_cron=<nonce> on your own domain. The HTTP request adds load to your server and ~50-300ms of processing overhead per request even when there’s nothing due, because WP still builds the cron array and compares timestamps. Multiply that by 100k daily requests and you get a seven-figure count of wasted PHP executions per day. Defining DISABLE_WP_CRON = true in wp-config.php tells WordPress to skip the spawning step — but if nothing else ever hits wp-cron.php, scheduled tasks silently stop running. That’s where the system cron comes in. A real cron daemon (on the OS or managed by your host) pings wp-cron.php every 60 seconds independent of any visitor traffic. The file only runs if called, so it adds near-zero load. The reliability monitor reads _get_cron_array() (the same data structure WP itself uses) and flags any event whose scheduled timestamp is more than 30 minutes in the past — meaning something is scheduled but not firing, which points at a broken system cron. The watchdog emails you with the list of overdue hooks so you can check the cron job on the host side. All three pieces — disable, system cron, monitor — combine into a setup where cron runs exactly when it should, never burns a page-load cycle, and tells you the moment something goes wrong. In three years of running this on gauravtiwari.org, gatilab.com, and a dozen client sites, I’ve had the watchdog email fire exactly twice — both times because a host-side cron had been silently disabled during a migration. Both were fixed within the hour.

Download and source

FAQs

Will scheduled posts still publish after I disable wp-cron?

Only if you wire up a real system cron. Scheduled posts rely on a hook (publish_future_post) that fires when wp-cron runs. Disable wp-cron without a replacement and your scheduled posts will sit in “Missed Schedule” status forever. The full setup above includes the system cron — don’t skip Step 2.

What if my host doesn’t let me add a system cron?

Use define( 'ALTERNATE_WP_CRON', true ); as a fallback. It fires cron via a browser-triggered redirect instead of a background HTTP request, which is slightly less reliable but still better than the default spawning behaviour. For sites running FluentCRM, WooCommerce Subscriptions, or anything time-sensitive, budget for a host that lets you set a proper cron — the $20/month upgrade pays for itself in reliability.

Does this break Jetpack, FluentCRM, or WooCommerce?

No. All three use standard WordPress cron hooks (wp_schedule_event and friends) — they only care that cron runs often enough, not how it was triggered. FluentCRM specifically recommends system cron over wp-cron in their production docs, so this setup matches their best practice.

Why 60 seconds? Is that overkill?

For scheduled posts it’s overkill — once every 5 minutes would be fine. For FluentCRM email batches, WooCommerce Subscription renewals, and anything with time-window logic, 60 seconds is the right baseline. Cron runs are near-zero cost when there’s nothing due (single SQL lookup, early exit). I run 60-second cron on every site I manage; the cost is negligible, the reliability win is real.

How do I verify it’s actually working?

Install WP Crontrol (free, wordpress.org). Go to Tools → Cron Events. Every hook should show a “Next Run” timestamp in the future. Hit the “Run Now” link on a test event — it should execute immediately. Then check get_option('doing_cron') via WP-CLI — this stores the timestamp of the last cron run. If that value is more than 5 minutes old during normal operation, your system cron isn’t firing.

What if I’m on managed WordPress hosting that says they handle cron?

Check what they mean by “handle”. Kinsta, Rocket.net, and SpinupWP run system cron at 1-minute intervals — perfect, nothing to do. WP Engine runs it at 15-minute intervals by default — too slow for FluentCRM and shared e-commerce, raise a ticket to speed it up. SiteGround’s approach is page-load wp-cron with an “optimisation” toggle in SG Optimizer that needs to be enabled. Budget shared hosting often doesn’t run any cron at all and quietly lets wp-cron fire on page load. When in doubt, run wp cron event list via WP-CLI and check the next-scheduled timestamps.

Does this work with multisite?

Yes, same setup. Each subsite’s cron events go into the main wp_options table’s cron key (or the per-site equivalent for subdomains), and system cron only needs to hit the main wp-cron.php once per minute — WordPress fans out to the right site based on the schedule entry. Don’t run separate cron jobs per subsite, that’s counter-productive.

What’s the single biggest mistake people make here?

Disabling wp-cron in wp-config.php and forgetting to add the system cron. Scheduled posts stop publishing, FluentCRM stops sending, subscription renewals fail silently. The reliability monitor above exists specifically to catch this failure mode — if you only implement one of the three steps, make it the monitor.