Clean expired transients and Action Scheduler logs in WooCommerce: a plugin you can build today

· 6 min read

WooCommerce processes orders, runs background jobs and caches data across dozens of database tables. Two of those tables grow without bound unless something actively trims them. The wp_options table fills with expired transients that WordPress never cleaned up. The Action Scheduler tables accumulate rows recording jobs that finished years ago. Neither cleanup happens automatically. Both slow the WooCommerce admin over time.

The conventional answer is a general-purpose database plugin like WP-Optimize. It handles expired transients, post revisions, spam comments and table defragmentation in a single scheduled sweep. For many stores that is the right tool. For stores where the only acute problem is transient bloat and Action Scheduler clutter, it is more surface area than the job requires.

The plugin here does two things: it deletes expired transients from wp_options, and it removes completed and failed Action Scheduler records older than a configurable number of days. It runs on a daily cron. An admin page under WooCommerce > DB Cleanup shows the current counts and a button to run the cleanup immediately. Nothing else. No table optimization, no revision cleanup, no subscription required.

What one build gives you

  • Daily scheduled deletion of expired transients from wp_options, removing both the timeout and value rows for each expired key
  • Deletion of completed, failed and canceled Action Scheduler records older than a configurable number of days
  • Deletion of associated Action Scheduler log rows in the same pass to avoid orphaned records
  • Admin page under WooCommerce > DB Cleanup with current row counts, a manual run button and a retention day setting

What it does not do

  • OPTIMIZE TABLE after row deletion: reclaiming disk space and defragmenting indexes requires a table lock this plugin does not run
  • Autoloaded options audit: large or incorrectly autoloaded options slow every page load and need a separate tool to identify
  • Post revisions, trashed posts, spam comments and orphaned postmeta rows
  • Transients stored in an external object cache: if Redis or Memcached is active, transients bypass wp_options and there are no rows to delete
  • Session rows, user metadata or tables created by third-party plugins

WP-Optimize does these. This covers the part most sites use.

The prompt

Loads into the composer so you can edit it first. Nothing is built, and nothing is charged, until you send it.

Build a WordPress plugin called WC DB Cleanup. Plugin Name: WC DB Cleanup. Version: 1.0.0. Description: Daily cleanup of expired transients and completed Action Scheduler records, with a manual run page under WooCommerce > DB Cleanup. Wrap all code in a class WC_DB_Cleanup with a public init() method. Instantiate the class and call init() on plugins_loaded. Stored option: wc_db_cleanup_as_days (integer, default 7). Number of days to retain completed, failed and canceled Action Scheduler records before deleting them. Cleanup method run_cleanup(): Step one, expired transients. global $wpdb; $keys = $wpdb->get_col("SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE _transient_timeout_% AND option_value + 0 < UNIX_TIMESTAMP()"); foreach ($keys as $k) { $wpdb->delete($wpdb->options, ['option_name' => $k]); $wpdb->delete($wpdb->options, ['option_name' => str_replace('_transient_timeout_', '_transient_', $k)]); } Step two, Action Scheduler. $days = max(1, (int) get_option('wc_db_cleanup_as_days', 7)); $cutoff = gmdate('Y-m-d H:i:s', strtotime("-{$days} days")); $at = $wpdb->prefix . 'actionscheduler_actions'; $lt = $wpdb->prefix . 'actionscheduler_logs'; if ($wpdb->get_var("SHOW TABLES LIKE '{$at}'")=== $at) { $ids = $wpdb->get_col($wpdb->prepare("SELECT action_id FROM {$at} WHERE status IN ('complete','failed','canceled') AND scheduled_date_gmt < %s", $cutoff)); if ($ids) { $ids = array_map('intval', $ids); $ph = implode(',', $ids); $wpdb->query("DELETE FROM {$lt} WHERE action_id IN ($ph)"); $wpdb->query("DELETE FROM {$at} WHERE action_id IN ($ph)"); } } update_option('wc_db_cleanup_last_run', current_time('mysql')); Cron: In init(), add_action('wc_db_cleanup_daily', [$this, 'run_cleanup']); if (!wp_next_scheduled('wc_db_cleanup_daily')) { wp_schedule_event(time(), 'daily', 'wc_db_cleanup_daily'); } register_deactivation_hook(__FILE__, function() { wp_clear_scheduled_hook('wc_db_cleanup_daily'); }); Admin page: In init(), add_action('admin_menu', [$this, 'add_admin_page']). In add_admin_page(): add_submenu_page('woocommerce', 'DB Cleanup', 'DB Cleanup', 'manage_woocommerce', 'wc-db-cleanup', [$this, 'render_admin_page']). In render_admin_page(): handle two POST submissions. POST action wc_db_cleanup_run with nonce wc_db_cleanup_action: call run_cleanup() and show a success admin notice. POST action wc_db_cleanup_save with nonce wc_db_cleanup_settings: update_option('wc_db_cleanup_as_days', max(1, intval($_POST['as_days'] ?? 7))). Then display: count of expired transient rows, count of eligible Action Scheduler records if the table exists using the current saved days, last run time from option wc_db_cleanup_last_run. Then a form with a submit button 'Run cleanup now' (hidden action=wc_db_cleanup_run, nonce wc_db_cleanup_action). Then a form with a number input name=as_days min=1 and a submit button 'Save' (hidden action=wc_db_cleanup_save, nonce wc_db_cleanup_settings). Use esc_html and esc_attr throughout. No stylesheet, no script.

Build this pluginAbout 55 credits · the free plan includes enough for one

What accumulates in a WooCommerce database over time

Transients are WordPress's temporary cache. A plugin stores a value for a set number of seconds, and WordPress saves two rows in wp_options: one with the value under the key _transient_X, and one with the expiry timestamp under _transient_timeout_X. When the transient expires, WordPress is supposed to delete it the next time that key is requested. Under load that cleanup step is skipped: the request that would have triggered it has something else to do, or no request ever reads the expired key again. The rows stay in the table indefinitely. On a busy WooCommerce store, tens of thousands of expired transient rows sitting there for months is not unusual.

Action Scheduler is the job queue built into WooCommerce. Every background task routes through it: order confirmation emails, subscription renewals, stock syncs, anything a plugin does not want to handle inline. Each task creates a row in wp_actionscheduler_actions. When the job completes the row is marked complete. When it fails the row is marked failed. These records are never removed unless something explicitly deletes them. On a store that has been running for a few years with active plugins, several hundred thousand rows in that table is ordinary, and most of them record jobs that finished long ago and will never be read again.

Why table size affects WooCommerce admin performance

Expired transient rows are not autoloaded, so they do not add weight to every page request directly. What they do is bloat the wp_options table to the point where queries that scan it take longer. WordPress fetches all autoloaded options in a single query on every request. On a table with fifty thousand rows, that query is still navigating a large index even if it only returns two hundred rows.

The Action Scheduler problem is more direct. WooCommerce and its extensions query the actions table continuously to schedule and claim jobs. As the table grows past a few hundred thousand rows, those queries slow even with correct indexes in place. Admin pages that list scheduled actions, backup plugins that read the whole table, and cron runners firing at high frequency all become noticeably slower. The effect shows up most visibly in the WooCommerce dashboard and in WP-Cron status reports.

How the plugin works

The plugin registers a daily WP-Cron event and runs two cleanup passes each time it fires. The first pass queries wp_options for any row whose name begins with transient_timeout and whose stored value is less than the current Unix timestamp. For each expired timeout row, it deletes that row and the matching transient row. Live transients are untouched; only rows past their expiry are removed.

The second pass reads a retention setting you configure on the admin page (default seven days) and collects all action IDs from wp_actionscheduler_actions where the status is complete, failed or canceled and the scheduled date is older than the threshold. It deletes the corresponding rows from wp_actionscheduler_logs first, then removes the action rows. Clearing the logs table first avoids orphaned records.

The admin page under WooCommerce > DB Cleanup shows three numbers: the current count of expired transient rows, the current count of eligible Action Scheduler records given the saved retention setting, and the time of the last cleanup run. A button runs the cleanup without waiting for the cron event. A number input lets you raise or lower the Action Scheduler retention period.

What the plugin does not cover

Deleting rows compacts the table logically but does not reclaim disk space or defragment indexes. To do that you run OPTIMIZE TABLE, which acquires a full table lock and can pause queries for several seconds on large tables. WP-Optimize and similar tools run that step during a low-traffic window. This plugin does not.

A separate performance drain this plugin ignores is autoloaded options that are large or should not be autoloaded. Some plugins store large serialized values under autoloaded keys, adding weight to every page request regardless of whether the value is used. Identifying those rows requires a tool that shows options by size and autoload status. This plugin has no such view.

Post revisions, trashed posts, spam comments, orphaned postmeta and session rows from third-party plugins are also outside scope. If any of those are contributing to the bloat, a general-purpose cleanup tool is the right call.

When WP-Optimize is the better choice

If you need to clean post revisions, trash, spam and orphaned postmeta in addition to transients and Action Scheduler records, WP-Optimize handles all of them in a scheduled sweep. If you want to run OPTIMIZE TABLE after each cleanup pass, WP-Optimize has a button for it. If you need to audit which options are autoloaded and how large they are, Advanced Database Cleaner breaks them down by row.

This plugin suits the store where expired transients and a swollen Action Scheduler table are the specific problem. It handles those two things, runs daily without configuration beyond the retention period, and adds nothing else to the stack. Paste the prompt below into Steem to build it. To add a per-run cleanup log and a weekly summary email, the follow-up is: Add a cleanup log to WC DB Cleanup: after each run, append a row to a custom table recording the date, expired transient count deleted and Action Scheduler count deleted. On the admin page, show the last ten runs in a table. Add a weekly summary email to the admin address with the totals for the past seven days.

Questions

Does this affect transients if Redis or Memcached is installed?

No. When an object cache plugin is active, WordPress stores transients in the cache rather than in wp_options, so there are no rows in the database to delete. The plugin only touches wp_options rows. If you have an object cache, the expired transient count on the admin page will be zero or close to it.

What retention period should I set for Action Scheduler records?

Seven days covers most debugging needs. If your store uses subscriptions or you regularly investigate failed jobs, fourteen days gives more history. There is no reason to keep records older than thirty days for a typical store, and keeping them longer than necessary means the table stays large.

My Action Scheduler table still has thousands of rows after running the cleanup. Why?

The cleanup only removes records with status complete, failed or canceled. Pending and in-progress records are left untouched. If the table is large after cleanup and most remaining rows show pending, a plugin is likely scheduling jobs faster than they run, or the WP-Cron runner is not being called reliably.

Will the first cleanup run lock the database or slow the site?

The transient deletion runs row by row. The Action Scheduler deletion uses a single query per run. On a table with hundreds of thousands of eligible rows, the first manual run may take a few seconds and could cause a brief lock on shared hosting. Run it once manually to drain the backlog, then let the daily schedule handle smaller increments.

Do I still need WP-Optimize after installing this?

If expired transients and Action Scheduler records are your only concern, no. If you also need to remove post revisions, trash, spam, orphaned postmeta or run OPTIMIZE TABLE after cleanup, WP-Optimize or Advanced Database Cleaner covers those. This plugin does two things and nothing else.

The prompt

Loads into the composer so you can edit it first. Nothing is built, and nothing is charged, until you send it.

Build a WordPress plugin called WC DB Cleanup. Plugin Name: WC DB Cleanup. Version: 1.0.0. Description: Daily cleanup of expired transients and completed Action Scheduler records, with a manual run page under WooCommerce > DB Cleanup. Wrap all code in a class WC_DB_Cleanup with a public init() method. Instantiate the class and call init() on plugins_loaded. Stored option: wc_db_cleanup_as_days (integer, default 7). Number of days to retain completed, failed and canceled Action Scheduler records before deleting them. Cleanup method run_cleanup(): Step one, expired transients. global $wpdb; $keys = $wpdb->get_col("SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE _transient_timeout_% AND option_value + 0 < UNIX_TIMESTAMP()"); foreach ($keys as $k) { $wpdb->delete($wpdb->options, ['option_name' => $k]); $wpdb->delete($wpdb->options, ['option_name' => str_replace('_transient_timeout_', '_transient_', $k)]); } Step two, Action Scheduler. $days = max(1, (int) get_option('wc_db_cleanup_as_days', 7)); $cutoff = gmdate('Y-m-d H:i:s', strtotime("-{$days} days")); $at = $wpdb->prefix . 'actionscheduler_actions'; $lt = $wpdb->prefix . 'actionscheduler_logs'; if ($wpdb->get_var("SHOW TABLES LIKE '{$at}'")=== $at) { $ids = $wpdb->get_col($wpdb->prepare("SELECT action_id FROM {$at} WHERE status IN ('complete','failed','canceled') AND scheduled_date_gmt < %s", $cutoff)); if ($ids) { $ids = array_map('intval', $ids); $ph = implode(',', $ids); $wpdb->query("DELETE FROM {$lt} WHERE action_id IN ($ph)"); $wpdb->query("DELETE FROM {$at} WHERE action_id IN ($ph)"); } } update_option('wc_db_cleanup_last_run', current_time('mysql')); Cron: In init(), add_action('wc_db_cleanup_daily', [$this, 'run_cleanup']); if (!wp_next_scheduled('wc_db_cleanup_daily')) { wp_schedule_event(time(), 'daily', 'wc_db_cleanup_daily'); } register_deactivation_hook(__FILE__, function() { wp_clear_scheduled_hook('wc_db_cleanup_daily'); }); Admin page: In init(), add_action('admin_menu', [$this, 'add_admin_page']). In add_admin_page(): add_submenu_page('woocommerce', 'DB Cleanup', 'DB Cleanup', 'manage_woocommerce', 'wc-db-cleanup', [$this, 'render_admin_page']). In render_admin_page(): handle two POST submissions. POST action wc_db_cleanup_run with nonce wc_db_cleanup_action: call run_cleanup() and show a success admin notice. POST action wc_db_cleanup_save with nonce wc_db_cleanup_settings: update_option('wc_db_cleanup_as_days', max(1, intval($_POST['as_days'] ?? 7))). Then display: count of expired transient rows, count of eligible Action Scheduler records if the table exists using the current saved days, last run time from option wc_db_cleanup_last_run. Then a form with a submit button 'Run cleanup now' (hidden action=wc_db_cleanup_run, nonce wc_db_cleanup_action). Then a form with a number input name=as_days min=1 and a submit button 'Save' (hidden action=wc_db_cleanup_save, nonce wc_db_cleanup_settings). Use esc_html and esc_attr throughout. No stylesheet, no script.

Build this pluginAbout 55 credits · the free plan includes enough for one

Read next

Other plugins you can build this way

Each loads into the composer, ready to edit.