Skip to content
Back to Articles

The WordPress Cron Event That Came Back 3 Seconds After Delete

Share:

During a WordPress cleanup we had one item left on the list: twelve scheduled events in WP-Cron that ran every minute under a harmless-sounding name, check_for_safety. They were not part of the site. Removing them should have taken one command.

WP-CLI answered Success: Deleted a total of 12 cron events. Three seconds later there were twelve again. We repeated the delete six times, 75 seconds apart. Every round ended at twelve.

Nothing was writing them back. No file, no plugin hook, no database trigger. WordPress itself was recreating them, every few seconds, because of one detail in how it stores cron events: each event is filed under a key that must equal md5(serialize($args)). Six of these events had a key that did not match. To WordPress they were impossible to delete and impossible to finish, so it rescheduled them over and over.

This article explains the mechanism, shows how to detect it with read-only commands, and walks through the cleanup we used to remove only the bad events without touching anything else. Everything is sanitized. No client names, domains or IPs.

🧩 The short version

  • What we saw: 12 events under the hook crontrol_cron_job, schedule "every minute", argument name check_for_safety. The hook belongs to WP Crontrol, a legitimate cron manager plugin that can store PHP code as a cron event.
  • The anomaly: 6 of the 12 were dated June 2025, 17 months in the past, and their array keys did not equal md5(serialize($args)).
  • The effect: no WordPress function could remove them. Each cron run left them in place and added a fresh, correctly keyed copy of each one. That is where the other 6 came from.
  • The side effect: because there was always an overdue event, wp-cron.php was requested about every 5 seconds instead of about once a minute.
  • The fix: lock WP-Cron, back up the cron option, remove the bad events from an offline copy, write it back with an update that only succeeds if the option has not changed since the backup, then unlock.
  • Result: 0 bad events for the whole watch period, the site's 34 other events byte-identical, cron traffic back to normal, the site up the whole time.

⏰ How WordPress stores cron events

WP-Cron is not a real cron daemon. All scheduled events live in a single row of wp_options, named cron, as one serialized PHP array. Its shape is:

array(
  1750268241 => array(                          // timestamp: when the event is due
    'some_hook' => array(                        // the action to fire
      '44bdb8...caf1' => array(                  // KEY: must be md5(serialize($args))
        'schedule' => 'minute',
        'args'     => array( /* arguments passed to the hook */ ),
        'interval' => 120,
      ),
    ),
  ),
  'version' => 2,
)

The key in the third level is the important part. WordPress never searches for an event. It computes where the event should be. When core wants to remove an event, wp_unschedule_event() does this:

$key = md5( serialize( $args ) );
unset( $crons[ $timestamp ][ $hook ][ $key ] );

If the stored key is anything else, the unset() hits an entry that does not exist. PHP does not complain, nothing is removed, and the function moves on. The event stays exactly where it was.

♻️ Why the copies kept coming back

Here is what wp-cron.php does for every overdue recurring event, simplified from core:

foreach ( $crons as $timestamp => $cronhooks ) {
    if ( $timestamp > $now ) break;                      // only overdue events
    foreach ( $cronhooks as $hook => $keys ) {
        foreach ( $keys as $k => $v ) {
            if ( $v['schedule'] ) {
                wp_reschedule_event( $timestamp, $v['schedule'], $hook, $v['args'] );   // 1. add the next run
            }
            wp_unschedule_event( $timestamp, $hook, $v['args'] );                       // 2. remove this run
            do_action_ref_array( $hook, $v['args'] );                                   // 3. fire it
        }
    }
}

Now follow one of the six bad events through that loop:

  1. Step 1 works. wp_reschedule_event() adds the next run at a future time, under a freshly computed, correct key. That is a new, healthy-looking event.
  2. Step 2 fails silently. wp_unschedule_event() computes the correct key, does not find it at the old timestamp, and removes nothing. The June 2025 entry stays.
  3. The entry is still overdue. The next request to the site sees an overdue event and starts WP-Cron again. And again.

So the site always had 6 "stuck" events plus 6 correctly keyed copies: 12. When we deleted the hook with WP-CLI, the delete worked the same way as step 2. It removed the 6 copies, because their keys were correct, and silently skipped the 6 stuck ones. A few seconds later the next cron run rescheduled 6 new copies. Back to 12.

The access log showed the side effect clearly. With a permanently overdue event, nearly every page view started a new cron run:

17:41:06  POST /wp-cron.php?doing_wp_cron=...   200
17:41:12  POST /wp-cron.php?doing_wp_cron=...   200
17:41:17  POST /wp-cron.php?doing_wp_cron=...   200
17:41:19  POST /wp-cron.php?doing_wp_cron=...   200
17:41:23  POST /wp-cron.php?doing_wp_cron=...   200

That is a self-request roughly every 5 seconds. After the cleanup it dropped back to about one per minute.

🕵️ What the events looked like

Decoded from the serialized option, each event had the same arguments (hash values shortened):

'crontrol_cron_job' => array(
  '44bdb8...caf1' => array(              // stored key: does NOT match the args below
    'schedule' => 'minute',
    'args'     => array(
      0 => array(
        'code' => '',                     // empty when we found it
        'name' => 'check_for_safety',     // display name, chosen to look harmless
        'hash' => 'd5d3a2...13a3',
      ),
    ),
    'interval' => 120,
  ),
),

A few honest notes about what we can and cannot say:

  • The code field was already empty when we found the events, so we cannot tell what PHP they ran in the past. Nothing on the site created them, and nobody on the site's team knew about them, so we treated them as attacker persistence and removed them.
  • An empty event still matters. It keeps rescheduling, it keeps WP-Cron busy, and as long as the plugin that runs this hook is active, whoever can write to that row can put PHP code back into it.
  • We do not know how the keys became wrong. One way it can happen is that someone edits the arguments straight in the database (for example, blanks the code field) without recomputing the key. The key is derived from the arguments, so any change to them breaks the match. We have no evidence of who did it or when.

🔍 Read-only detection

None of these commands change anything. Replace wp_ with your table prefix and DBNAME with the database from wp-config.php, and run them as that database user.

1. Count a suspicious hook in the cron option. This works even when the option is too large to read by eye. 17 is the length of crontrol_cron_job. Use the length of whatever hook you are counting.

SELECT ROUND( (CHAR_LENGTH(option_value)
       - CHAR_LENGTH(REPLACE(option_value, 'crontrol_cron_job', ''))) / 17 ) AS events
FROM wp_options WHERE option_name = 'cron';

2. Watch whether the count comes back after a delete. Sample every 3 seconds for a minute:

for i in $(seq 1 20); do
printf '%s ' "$(date +%T)"
mysql -N DBNAME -e "SELECT ROUND((CHAR_LENGTH(option_value)-CHAR_LENGTH(REPLACE(option_value,'crontrol_cron_job','')))/17) FROM wp_options WHERE option_name='cron'"
sleep 3
done

3. Measure how often WP-Cron runs. Requests per minute, from the site's access log:

grep 'POST /wp-cron.php' /path/to/access.log | awk '{print substr($4, 2, 17)}' | uniq -c | tail -20

More than a few per minute on a normal site usually means an event that is always overdue.

4. Export the option and check every key offline. Export the raw value, copy it off the server, and check it with the script below. The script only reads the file and prints a report.

mysql -N --raw DBNAME -e "SELECT option_value FROM wp_options WHERE option_name='cron'" > cron-option.txt
<?php
// check-cron-keys.php - read-only: reports cron events whose key != md5(serialize(args))
// usage: php check-cron-keys.php cron-option.txt
$raw  = trim(file_get_contents($argv[1]));
$cron = unserialize($raw, ['allowed_classes' => false]);   // never instantiate objects
if (!is_array($cron)) { fwrite(STDERR, "not a serialized array\n"); exit(1); }

$now = time(); $bad = 0; $total = 0;
foreach ($cron as $ts => $hooks) {
    if (!is_array($hooks)) continue;                         // skips 'version' => 2
    foreach ($hooks as $hook => $events) {
        foreach ($events as $key => $ev) {
            $total++;
            $ok = ($key === md5(serialize($ev['args'] ?? [])));
            if (!$ok) $bad++;
            printf("%-8s %s %-7s %-10s %s\n",
                $ok ? 'ok' : 'MISMATCH',
                gmdate('Y-m-d H:i', (int)$ts),
                $ts < $now ? 'overdue' : 'future',
                $ev['schedule'] ?: 'single',
                $hook);
        }
    }
}
echo "events: $total, mismatched keys: $bad\n";

Here is the real output for the site in this article, run on the exported copy a few hours after the export (that is why even the healthy copies show as overdue). Trimmed:

MISMATCH 2025-06-18 17:37 overdue minute     crontrol_cron_job
MISMATCH 2025-06-18 17:38 overdue minute     crontrol_cron_job
MISMATCH 2025-06-19 23:26 overdue minute     crontrol_cron_job
MISMATCH 2025-06-19 23:26 overdue minute     crontrol_cron_job
MISMATCH 2025-06-20 17:43 overdue minute     crontrol_cron_job
MISMATCH 2025-06-20 17:45 overdue minute     crontrol_cron_job
ok       2026-11-13 17:53 overdue minute     crontrol_cron_job
ok       2026-11-13 17:53 overdue minute     crontrol_cron_job
...
ok       2026-11-14 04:39 future  twicedaily wp_version_check
ok       2026-11-14 16:56 future  daily      wp_scheduled_delete
...
events: 46, mismatched keys: 6

Two lines in that output give it away. The event is overdue by more than a year while the site's cron runs every few seconds, and its key is a MISMATCH. A healthy recurring event is never overdue for long, because each run moves it forward.

Why offline and not with wp cron event list? WP-CLI loads the site's own code, including mu-plugins, even with --skip-plugins. On a site that may still be infected, reading the raw option with SQL and parsing it somewhere else is the safer habit.

🧹 The safe cleanup

The obvious fix, deleting the events, does not work. The next obvious fix, editing the serialized option by hand, is how many sites end up with a broken cron array or lost events. This is the order we used. Every step that writes is marked.

  1. Full database backup. Check that the file is not empty before you go on.
  2. [WRITE] Lock WP-Cron. WP-Cron uses the doing_cron transient as its lock. A value in the future makes both spawn_cron() and wp-cron.php back off, so nothing rewrites the option while you work.
    -- lock until now + 550 seconds (same float format WordPress uses)
    INSERT INTO wp_options (option_name, option_value, autoload)
    VALUES ('_transient_doing_cron', '1794592928.0000000000000000000000', 'yes')
    ON DUPLICATE KEY UPDATE option_value = VALUES(option_value);
    Keep the value less than 10 minutes in the future: spawn_cron() treats a lock further ahead than that as invalid and ignores it. If the site uses a persistent object cache (Redis, Memcached), transients live there and not in the database, so the lock has to be set there instead.
  3. Wait about 90 seconds so any cron run that already started can finish.
  4. [WRITE, to your evidence folder] Back up the cron row on its own, and record its SHA-256:
    mysqldump DBNAME wp_options --where="option_name='cron'" --no-create-info --complete-insert > options_cron_before.sql
    mysql -N DBNAME -e "SELECT SHA2(option_value, 256) FROM wp_options WHERE option_name='cron'"
  5. Build the new value offline. Unserialize the exported copy, remove only the bad hook from every timestamp, drop timestamps that become empty, and serialize it again. Then compare: every other event must be byte-identical to the original. In our case that was 46 events before and 34 after, with all 34 unchanged.
  6. [WRITE] Write it back only if nothing changed in between. The WHERE clause uses the hash from step 4, so if WordPress or anything else touched the option since your backup, the update matches 0 rows and does nothing:
    UPDATE wp_options
    SET option_value = '<new serialized value>'
    WHERE option_name = 'cron'
      AND SHA2(option_value, 256) = '<sha256 from step 4>';
    Use a prepared statement or a properly escaped value. A serialized string that is one byte off is unreadable to WordPress, and it will treat the whole cron array as empty.
  7. Check that it affected 1 row, then run the count from detection step 1. It should be 0.
  8. [WRITE] Unlock: delete the _transient_doing_cron row, but only if it still holds your value.
  9. Watch for at least 5 minutes: the count stays at 0, the other events are still there, wp-cron.php requests drop back to normal, and the site answers 200.

Our watch after unlocking: 0 bad events in every sample, the same 34 legitimate hooks, and wp-cron.php requests about one minute apart instead of every 5 seconds.

🧪 Why the lock matters: what happened without it

We tested the order before the final fix, and the numbers show why each step is there:

  • Delete without a lock: 12 → 6 → 12 within 3 seconds. Six rounds of delete, all ended at 12.
  • Delete with the lock held: 12 → 6, and it stayed at 6 for two minutes. Nothing could reschedule while cron was locked. The 6 left over were the stuck events that no delete can reach.
  • Unlock after that: back to 12 within 30 seconds. The stuck events were still overdue, so the first cron run rescheduled them.
  • Lock, then remove the stuck events from the raw option: 0, and still 0 after unlocking.

The lock alone fixes nothing. It just stops WordPress from rewriting the option while you replace it.

🚩 Indicators

  • Hook crontrol_cron_job with the name check_for_safety, schedule minute, on a site where nobody created such an event
  • Any cron event whose key is not md5(serialize($args))
  • Recurring events overdue by days or months while WP-Cron runs normally
  • POST /wp-cron.php every few seconds in the access log
  • WP-CLI reports a cron delete as successful, and the count is back within seconds

💡 Takeaways

  • WordPress finds cron events by computing md5(serialize($args)), not by searching. An event filed under any other key cannot be deleted by any WordPress function.
  • A stuck recurring event creates a correct copy of itself on every cron run, so a delete looks like it worked and then "comes back".
  • "Success" from a delete command is not proof. Count again a few seconds later, and once more after the next cron run.
  • Lock WP-Cron, back up the row, edit it offline, and write it back with a hash check. Never hand-edit the live serialized value.
  • Cron events with a PHP code field are persistence even when the field is empty. Remove the ones nobody on the site created.

❓ FAQ

Why not just disable WP-Cron with DISABLE_WP_CRON?

It only stops page views from starting cron. The stuck events stay in the option and come back the moment cron runs again, from a system cron or from the next person who re-enables it. Removing them from the option is the fix.

Can I delete the whole cron option instead?

WordPress will recreate its own core events, but every event added by plugins (backups, shop tasks, email queues) is lost until each plugin happens to schedule it again. Removing only the bad hook keeps the site's own schedule intact.

Is the cron manager plugin itself the problem?

No. It is a legitimate tool, and its PHP cron events are a documented feature. The problem is that anything able to write to the cron option can use that feature. If the site does not need PHP cron events, that is a decision for the site owner.

Does this only affect one plugin's hook?

No. Any hook can end up with a mismatched key. The detection script above checks every event in the option, not just one hook.