# wp-cron and scheduled tasks: why your site is slow at random moments

> WordPress runs its scheduled jobs on the back of visitor requests, which is why the same page is fast one minute and slow the next. Replace WP-Cron with a real cron job in ten minutes and find the plugin that runs every minute.

Updated 2026-09-25 · WordPress & WooCommerce · HTML version: https://getreport.app/guides/wp-cron-and-scheduled-tasks

A WordPress site that is fast most of the time and slow for no visible reason is usually paying for its own housekeeping. WordPress has no clock of its own; it checks for overdue jobs when someone loads a page and runs them then, on the same server, at the same moment. This guide explains how that mechanism works, how to see it in your Time to First Byte, and how to move the jobs to a real schedule so no visitor ever waits for a backup again.

## Quick answer

- WP-Cron is a pseudo-cron: a page load triggers `wp-cron.php`, which runs every scheduled event that is due (publishing, backups, sitemap rebuilds, WooCommerce's Action Scheduler, update checks).
- Turn the trigger off in `wp-config.php` with `define('DISABLE_WP_CRON', true);`.
- Run the queue from a system cron every 5 minutes instead: `*/5 * * * * curl -s "https://example.com/wp-cron.php?doing_wp_cron" > /dev/null`, or `wp cron event run --due-now` with WP-CLI.
- Install WP Crontrol once to see what is scheduled; a plugin with an every-minute event or a hook that never completes is the usual surprise.
- Measure before and after with the [TTFB test](https://getreport.app/tools/ttfb-test), three runs each, and watch the spread shrink.

## Why WP-Cron matters

Every dynamic site has jobs that run on a schedule: publish the post at 9:00, send the abandoned-cart email after an hour, rebuild the sitemap, check for plugin updates, purge old logs. A normal server runs these with cron, a service that fires jobs at fixed times independently of visitors. WordPress cannot assume cron exists on the host it lands on, so it invented its own.

On every page load, WordPress looks at the list of scheduled events. If any is due, it sends a request to its own `wp-cron.php` and lets that request do the work. The visitor's page does not usually wait for the job to finish, but the job runs on the same PHP workers, the same CPU and the same database at the same moment. On a small hosting plan with two or three PHP workers, one worker doing a backup is one fewer answering visitors, and the next requests queue behind it. That is the "slow at random moments" pattern: the slowdown follows the schedule, not the traffic.

The same design breaks in both directions. A site with few visitors never runs its jobs on time, because nothing triggers them at 3 a.m.; the scheduled post goes out when the first reader arrives at 8. A site with many visitors triggers the check on every one of thousands of page loads; WordPress uses a lock to prevent overlapping runs, but the check itself, and the occasional double run when the lock expires, still costs.

The second cost is what the jobs are. Every plugin can register events, and many register generous ones: a "check licence" every hour, a "sync feed" every 15 minutes, an analytics roll-up every minute. WooCommerce brings the Action Scheduler, a job queue that runs subscription renewals, webhook deliveries and the emails your store sends, with a runner that wakes every minute through WP-Cron. None of this is wrong; it is just invisible until you look.

## How getReport checks it

> **Free tool:** [TTFB test — server response time](https://getreport.app/tools/ttfb-test): Time to First Byte for your page from a European test location and from real users, with redirect hops and the server response time Lighthouse measured.

The tool runs the speed module of a report and shows the first-byte findings. The measured number that matters for WP-Cron is in two places: the report header carries the Time to First Byte of our own fetch from Frankfurt, and the speed panel carries Lighthouse's measurement from the PageSpeed run, graded against the Core Web Vitals thresholds of 800 ms and 1.8 s:

> **Check: Time to First Byte.** TTFB is how long the server takes to start answering. Everything else waits for it, so a slow first byte makes every other metric worse. Google's 800 ms target is for real visitors, network included; Lighthouse flags the server's own share above 600 ms (see server response time).
>
> 1. Add page caching (WordPress: WP Rocket, LiteSpeed Cache, or your host's cache; Shopify does this for you).
> 2. Put a CDN in front (Cloudflare, Bunny) so the first byte comes from a nearby edge.
> 3. Check database-heavy plugins and slow hosting; a plan with more CPU often fixes this outright.

![The TTFB result on a slow WordPress site: the Time to First Byte finding with its measured value above 800 ms, the server response time finding beneath it, and the fix steps naming caching and hosting](https://getreport.app/guides/img/wp-cron-and-scheduled-tasks/ttfb.webp "Two measurements of the same wait: the Lighthouse metric and the audit that grades it against 600 ms.")

> **Check: Server response time.** Lighthouse flags a first byte slower than 600 ms. Caching and hosting fix this for most CMS sites.
>
> 1. Enable full-page caching (WordPress: WP Rocket, LiteSpeed Cache, WP Super Cache; or your host's server cache).
> 2. Use a CDN and keep database queries out of the request path.

A WP-Cron problem does not show as a consistently slow TTFB. It shows as a fast TTFB on one run and a slow one on the next, on a page that is otherwise cached. Run the test three times a few minutes apart and compare; a spread of more than a few hundred milliseconds between runs of the same page is the signature. The [TTFB guide](https://getreport.app/guides/ttfb-what-a-slow-server-looks-like) covers the consistently slow case.

The WordPress Doctor's plugin cost table is the other half of the picture. It lists what each plugin loads in the browser, which is not the same as what it schedules on the server, but the plugins that are heavy on one side are often heavy on the other:

> **Check: WordPress plugin weight.** Every active plugin can add scripts and styles to every page, whether the page uses them or not. A slider or a form builder can weigh more than the rest of the site together.
>
> 1. Deactivate plugins the site does not use; for the heavy ones in the table, check whether a lighter alternative exists.
> 2. Load plugin assets only where needed with a performance plugin (Perfmatters, Asset CleanUp) or the plugin's own "load on demand" setting.

## Step by step

### 1. See what is scheduled

Install [WP Crontrol](https://wordpress.org/plugins/wp-crontrol/) and open Tools → Cron Events. Each row is one scheduled hook with its next run time, its recurrence and the plugin or core file that registered it. Sort by recurrence and look for three things:

- **Events that run every minute or every few minutes.** `action_scheduler_run_queue` (WooCommerce) is expected; a marketing plugin polling an API every minute is not.
- **Events whose next run is in the past.** Overdue means WP-Cron has not managed to run them: the loopback request is blocked, or the run keeps timing out.
- **Events from plugins you removed.** Deactivated plugins leave their events behind; the hook fires with no function attached, which is harmless but noisy. Delete them.

WordPress's own Site Health (Tools → Site Health) has a "scheduled events" test that reports when the queue is late or a loopback request fails; it is a good first look without installing anything.

With WP-CLI, the same list on the command line:

```bash
wp cron event list --fields=hook,next_run_relative,recurrence
wp cron test          # checks that WP-Cron can be triggered at all
```

### 2. Turn off the visitor-triggered run

In `wp-config.php`, above the line that says "That's all, stop editing":

```php
<?php
// wp-config.php
define('DISABLE_WP_CRON', true);
```

This stops WordPress from spawning `wp-cron.php` on page loads. It does not delete the events and it does not stop `wp-cron.php` from running when something else requests it, which is what the next step does. Do not leave the site in this state for long without step 3, or nothing scheduled will ever run.

### 3. Run the queue from a real cron job

Pick one of the two forms. The HTTP one works on any host that lets you add a cron job:

```text
# crontab -e, or cPanel → Advanced → Cron Jobs, or Plesk → Scheduled Tasks
*/5 * * * * curl -s "https://example.com/wp-cron.php?doing_wp_cron" > /dev/null 2>&1
```

The WP-CLI one skips the web server, runs as the site's user and is the better choice when you have shell access:

```text
*/5 * * * * cd /var/www/example.com && /usr/local/bin/wp cron event run --due-now --quiet
```

Five minutes is a good default. Scheduled posts publish at most five minutes late, which nobody notices; jobs that need a tighter clock (the Action Scheduler, below) get their own line. Going to one minute makes the queue check itself sixty times an hour for no benefit on most sites.

If the host's cron runs as a different user, or if `curl` is missing, `wget -q -O - "https://example.com/wp-cron.php?doing_wp_cron" > /dev/null` does the same job.

> **Note:**
> `?doing_wp_cron` on the URL is the convention every host's documentation uses; with no value after it, `wp-cron.php` treats the request as an external job, sets its own lock and exits if another run started less than `WP_CRON_LOCK_TIMEOUT` (60 s by default) ago. Two overlapping runs therefore do not process the same events twice.

### 4. Give the Action Scheduler its own runner

On a WooCommerce store, most scheduled work goes through the Action Scheduler (WooCommerce → Status → Scheduled Actions shows the queue). By default it runs a batch every minute via WP-Cron and also starts an async runner at the end of some requests when actions are pending. Once you have a real cron, add a second line so the queue is processed on a clock and not by visitors:

```text
* * * * * cd /var/www/example.com && /usr/local/bin/wp action-scheduler run --quiet
```

The runner processes a batch of pending actions (25 by default) and exits; running it every minute keeps abandoned-cart emails and subscription renewals on time while WP-Cron handles the rest. The Scheduled Actions screen shows the past actions it keeps for a month; a "Pending" count that grows every day means the runner is not keeping up, and a "Failed" list that keeps growing names the plugin whose action fails.

### 5. Fix the events that are wrong, not just late

Moving the trigger to cron stops visitors paying for the jobs; it does not make a bad job cheaper. Back in WP Crontrol:

- An event every minute from a plugin that does not need it: check the plugin's settings for a sync interval (most have one) and set it to hourly or daily.
- A job that runs for minutes (a full-site backup, an image bulk-optimiser, a "regenerate all thumbnails"): move it to a time with no traffic, usually the plugin's own scheduling setting, and make sure the cron line runs at that time too.
- A job that fails every time (the Failed column in the Action Scheduler, or PHP errors in the log at the same minute): it retries on the next run, forever. Fix or remove the plugin.

Query Monitor's "Logs" and the host's slow-query log both show the exact minute a job ran; match it against the moment the TTFB spiked and you have the culprit.

### 6. Re-measure

Run the TTFB test three times, a few minutes apart, on the same cached page. The individual values should be close to each other, and the slowest run should be within a couple of hundred milliseconds of the fastest. The absolute number is a separate question, covered in [Server response time on shared hosting](https://getreport.app/guides/server-response-time-on-shared-hosting).

## Platform notes

### Managed WordPress hosts

Many managed hosts already run WP-Cron at the server level and set `DISABLE_WP_CRON` for you. Kinsta, for example, runs it every 15 minutes by default and can shorten the interval on request. Check the host's documentation and Site Health before adding a cron line, or you get two triggers.

### Hosts that block loopback requests

Some hosts, and some firewall plugins, refuse the request WordPress makes to its own domain. Site Health reports it as "Your site could not complete a loopback request", and every event shows as overdue. The real cron in step 3 is the fix, because it comes from outside PHP. The alternative WordPress offers, `define('ALTERNATE_WP_CRON', true);`, works by redirecting a visitor to the same page with `?doing_wp_cron` in the address, which is visible to the visitor and adds a redirect to the measured page load. Use it only when no cron is available at all.

### Multisite

`wp-cron.php` runs per site. One cron line per site is the simple way (`wp cron event run --due-now --url=https://site2.example.com`); on a network with dozens of sites, loop over `wp site list --field=url`.

### Docker and containers

Cron is not running inside a typical PHP container. Add a small sidecar container with cron that calls `wp-cron.php` over HTTP, or a host-level cron that runs `docker exec`. A cron in the same image as PHP-FPM needs a process supervisor; the sidecar is simpler.

## Verify

- Tools → Site Health shows no "scheduled event is late" or loopback warnings.
- `wp cron event list` shows every next run in the future, and after five minutes the times have advanced (the cron line is working).
- The server's cron log (`grep CRON /var/log/syslog`, or the host panel's job history) shows the job running every five minutes with exit status 0.
- Three TTFB tests of the same page agree with each other, and the report header's first-byte time no longer jumps between runs.

## Common mistakes

- **Disabling WP-Cron and forgetting the cron job.** Scheduled posts stop publishing and the store stops sending emails. Symptom: every event in WP Crontrol is overdue. Add the cron line or remove the `DISABLE_WP_CRON` constant.
- **Two triggers.** The host runs WP-Cron and you added a cron line, or `DISABLE_WP_CRON` is missing so visitors still trigger it too. Jobs run twice. Keep one.
- **Every minute for everything.** A `* * * * *` line for `wp-cron.php` runs the whole queue check sixty times an hour. Use five minutes for WP-Cron and reserve the every-minute line for the Action Scheduler if you need it.
- **Leaving the cron URL on http.** The request hits the redirect to https and, depending on the host, never reaches `wp-cron.php`. Use the final https URL.
- **Blaming the host for a job you cannot see.** A backup plugin running a full export at 10:00 every day looks like a hosting problem at 10:00 every day. Open WP Crontrol before opening a support ticket.
