Why a deleted plugin's cron events stay alive
wp_options table, named cron. The value is a serialized PHP array grouped by timestamp, then by hook name, then by a signature of the event's arguments. Each entry also records the recurrence, such as hourly, twicedaily, or daily.
When an event comes due, WordPress fires the hook with do_action. If no plugin has attached a function to that hook, nothing happens and no error is logged. For a recurring event, WordPress then schedules the next occurrence anyway. Rescheduling happens regardless of whether any callback ran, so a recurring event from a deleted plugin repeats indefinitely.
A well-behaved plugin clears its events on deactivation or in its uninstall routine. Many plugins don't, or only clear some of them. Plugins removed by deleting their folder over FTP or the file manager never run any cleanup code at all.
One orphaned daily event costs almost nothing. The trouble comes from accumulation, especially plugins that schedule events per post, per order, or per user with unique arguments. Each argument set is a separate entry. The cron option is normally autoloaded, which means WordPress loads and unserializes it on every request, admin pages included. A cron option that has grown large adds work to every single page load. The broader picture of autoloaded options slowing the dashboard is covered in Why WordPress Admin Is Slow Even When the Frontend Loads Fast.
There is a second effect. If events are constantly overdue, for example because the site has little traffic and relies on visitors to trigger WP-Cron, page loads keep trying to spawn a cron run. Core limits this with a lock (the doing_cron transient and WP_CRON_LOCK_TIMEOUT), but a long overdue list keeps the scheduler busy doing nothing useful.
First check: read the event list
BASH
wp cron event list --fields=hook,next_run_relative,recurrence --format=tablecd into public_html or wherever WordPress lives. On multisite, add --url= with the specific site's address, because each site in the network keeps its own cron option.
There are three things to look for in the output.
The first is hook names you don't recognize. Plugins usually prefix their hooks with their own slug or an abbreviation of it. A prefix that matches nothing in your current plugin list is the first candidate.
The second is next_run_relative showing now or a time in the past for many events. A few overdue events are normal on a quiet site. A long run of them points to either a broken cron setup or a lot of dead weight. If you suspect the cron trigger itself, How to Verify Your WordPress System Cron Job Is Actually Running walks through that separately.
The third is the same hook repeated many times. That usually means per-object scheduling with different arguments. It is the pattern most likely to bloat the option.
If the list is long, --format=csv piped through sort and uniq -c makes repeated hooks easier to count. For a quick view, the table is enough.
Second check: which hooks have nobody listening
has_action() returns false when a hook has no callbacks registered.
This one-liner loads WordPress once, walks the cron array, and prints each hook that has no callback, along with a count of how many scheduled entries it has. It only reads data.
BASH
wp eval 'foreach ( _get_cron_array() as ts =>hooks ) { foreach ( hooks ashook => events ) { if ( ! has_action(hook ) ) { echo $hook . PHP_EOL; } } }' | sort | uniq -c | sort -rnDOING_CRON is defined, only inside is_admin(), or only after a specific class is loaded. Those hooks look orphaned here but work fine in practice. A few core hooks can show up for the same reason, depending on version and context.
Don't add --skip-plugins or --skip-themes to this command. Skipping them hides every callback those plugins register, and almost everything would look orphaned.
Third check: confirm where each hook came from
wp-content/plugins, wp-content/mu-plugins, and the active theme. Use grep -r over SSH or the search in your control panel's file manager. There are three likely outcomes.
No match anywhere: the code that scheduled the event is gone. This is the typical leftover from a deleted plugin, and it is safe to remove.
A match inside an inactive plugin that is still installed: the plugin was deactivated without cleaning up. Decide whether the plugin is coming back. If it isn't, remove the plugin properly first, then clear its events.
A match inside an active plugin, theme, or mu-plugin: this is a false positive from the second check. The callback is registered in a context WP-CLI didn't hit. Leave it alone. If the event looks wrong, take it up with that plugin's settings or developer.
From the support side, the hooks that most often confuse people are the ones with generic names and no obvious prefix. When a search turns up nothing and the name gives no hint, the database is the only place left to look. A backup or old export from before the plugin was removed sometimes shows which plugin's options sat next to it.
Removing orphaned events safely
wp db export to a file outside the web root. Removing a cron event can't be undone from the dashboard, and restoring the cron row by hand is unpleasant.
WP-CLI provides wp cron event delete followed by the hook name. Depending on your WP-CLI version, it removes every scheduled instance of that hook, or it needs an extra flag to do so. Run wp help cron event delete first to see what your installed version supports. Work one hook at a time and re-run the list after each one, rather than scripting a bulk delete from the candidate list. The false positives from the second check are exactly the kind of mistake a loop will make for you.
After cleanup, wait at least a day and list the events again. An event that reappears was not orphaned. Something is still scheduling it, usually on init or admin_init with a wp_next_scheduled() check, and it will keep coming back until you deal with that code. This recheck is the most honest test you have that a hook was truly dead.
If the problem was per-object events with thousands of entries, check the size of the cron option after deletion. It should shrink noticeably, and admin pages load less data on every request. If the option is still large, the remaining weight is coming from a plugin that is still active, and that becomes a configuration question rather than cleanup.
The practical rule is to treat the cron list like the autoloaded options list: review it whenever plugins are removed, not only when the dashboard slows down. Read the list, check which hooks have no callback, confirm by searching the code, and delete one hook at a time with a backup in hand. Most of the time it's a few minutes of work, and it saves you from slowly accumulating overhead that nobody can explain later.