The WooCommerce checkout carries per-customer financial state: cart contents, shipping selections, tax calculations, coupon validity, and nonces bound to a session. That is why it sits outside Varnish, Nginx FastCGI cache, and CDN page caching, and every request executes in full. Time to First Byte (TTFB) is then the sum of PHP execution and the database queries behind it. On a store with years of accumulated option and transient data, MySQL is the largest term in that sum. When these slow database calls keep PHP processes occupied for too long, concurrent checkout traffic can also lead to PHP worker exhaustion, increasing queue time and TTFB for other uncached requests.
Two wp_options problems account for most of that cost: oversized autoloaded data, and expired transients that are never cleaned up. Both degrade the un-cacheable path first, and both can be measured and fixed from the database and the command line.
The wp_options Autoload Bloat
On every request that bootstraps WordPress, core runs wp_load_alloptions(). It selects every wp_options row flagged for autoload and loads the whole set into memory before most plugin code executes. No lazy loading is involved: if a row is flagged, it is read, transferred, and unserialized on every uncached request.
Poorly built marketing plugins, abandoned-cart scripts, and analytics integrations commonly store large serialized blobs in autoloaded rows: event logs, queued payloads, cached remote responses, and per-session tracking arrays. Individually each looks harmless. Collectively they can reach megabytes, and the checkout pays for them because it cannot hide behind a page cache.
The cost shows up in several places:
- Query and I/O cost. InnoDB stores long values off-page, so scanning autoloaded rows with large
option_valuedata reads overflow pages, not just the main row. - Network and PHP cost. The full payload is transferred and unserialized by PHP on each request.
- Object cache limits. With a persistent object cache, WordPress stores the entire set as a single
alloptionskey. Memcached's default 1 MB item size limit means an oversizedalloptionsmay not cache at all, so every request falls back to the database. Redis accepts it, but each request still moves the whole blob. - Write amplification. Updating any autoloaded option invalidates and rewrites that cache entry, and the cost scales with its size.
Non-expiring transients feed this problem too. set_transient() flags a transient with no expiration as autoload yes, so any plugin caching data permanently through the transient API adds to the startup payload.
The Diagnostic SQL Query
Measure the footprint directly in the database:
SELECT SUM(LENGTH(option_value)) FROM wp_options WHERE autoload = 'yes';
The result is in bytes. As working thresholds, under 1 MB is acceptable, anything over 1 MB is concerning, and anything over 5 MB is a severe architectural bottleneck on the checkout path. For reference, WordPress Site Health starts warning at roughly 800 KB.
Two adjustments for production use. First, replace wp_ with your actual table prefix. Second, since WordPress 6.6 the autoload column can also hold on, auto, and auto-on, and core loads those rows at startup as well. A query matching only 'yes' can undercount, so use this version on current installs:
SELECT ROUND(SUM(LENGTH(option_value)) / 1024 / 1024, 2) AS autoload_mb
FROM wp_options
WHERE autoload IN ('yes', 'on', 'auto-on', 'auto');
Then find the offenders:
SELECT option_name, LENGTH(option_value) AS size_bytes
FROM wp_options
WHERE autoload IN ('yes', 'on', 'auto-on', 'auto')
ORDER BY size_bytes DESC
LIMIT 20;
The same measurements are available from WP-CLI:
# Total size of all autoloaded options
wp option list --autoload=on --format=total_bytes
# Twenty largest autoloaded rows
wp option list --autoload=on --fields=option_name,size_bytes | sort -n -k 2 | tail -20
Option name prefixes usually identify the owning plugin. Look at the top five rows before deciding anything else.
The Transient Timeout Crisis
Without a persistent object cache, every transient is two rows in wp_options: _transient_{key} holds the value, and _transient_timeout_{key} holds a Unix expiry timestamp. Although wp_options is fundamentally a key-value table rather than a true Entity-Attribute-Value (EAV) storage model, the large number of individually keyed rows can create some of the same lookup and table-growth problems seen in EAV-style WordPress metadata tables such as wp_postmeta.
WooCommerce's own history is instructive here. Before version 2.5, released in 2015, customer session data lived in wp_options alongside transients. The core team moved sessions to the dedicated wp_woocommerce_sessions table, citing sessions that were hard to clear and slower queries on a table crowded with sessions and transients. Sessions have been out of wp_options for a long time, but transient accumulation remains. Plugins cache carrier rate responses, tax service lookups, license checks, and query results as transients, and WooCommerce itself uses them for several internal caches.
Expired transients are removed in two ways: lazily, when something reads the key and finds it expired, and in bulk by core's scheduled cleanup, which runs on WP-Cron. Keys that are written once and never read again, such as per-customer or per-query keys, depend entirely on that scheduled cleanup. When WP-Cron stops working, expired rows pile up. Common causes:
DISABLE_WP_CRONis set totruewith no system cron replacing it.- Loopback requests are blocked by basic auth, a firewall, or a CDN challenge.
- Traffic is too low or too bursty to trigger cron reliably.
The damage is gradual. Table size grows, buffer pool pages fill with dead data, and maintenance queries that scan for _transient_% patterns take longer each run. Large serialized values leave free space inside the tablespace that is not returned to the operating system. Eventually checkout queries that touch wp_options compete with this bloat for cache and disk bandwidth, and mass cleanup becomes a lock-contention event of its own. Check how bad it is:
-- Expired timeout rows still sitting in the table
SELECT COUNT(*) AS expired_timeouts
FROM wp_options
WHERE option_name LIKE '\_transient\_timeout\_%'
AND option_value < UNIX_TIMESTAMP();
-- Total transient footprint
SELECT COUNT(*) AS total_rows,
ROUND(SUM(LENGTH(option_value)) / 1024 / 1024, 2) AS size_mb
FROM wp_options
WHERE option_name LIKE '\_transient\_%';
Confirm cron health too:
wp cron event list --fields=hook,next_run_relative
wp cron test
The High-Performance Fixes
Delete expired transients in the database
Running the delete in SQL keeps the rows out of PHP memory entirely, so PHP's memory limit is irrelevant. Take a backup first (wp db export), then run:
DELETE a, b
FROM wp_options a, wp_options b
WHERE a.option_name LIKE '_transient_%'
AND a.option_name NOT LIKE '_transient_timeout_%'
AND b.option_name = CONCAT('_transient_timeout_', SUBSTRING(a.option_name, 12))
AND b.option_value < UNIX_TIMESTAMP();
The join pairs each value row with its timeout row and removes both only when the timeout has passed. Transients with no expiry have no timeout row and are never touched.
Four production notes:
- Escape the underscores. In
LIKE,_matches any single character. A pattern that starts with_has no constant prefix, so MySQL cannot use a range scan on theoption_nameindex and falls back to scanning the table. On a largewp_optionstable, this index scanning overhead increases disk I/O and query execution time, especially during bulk transient cleanup. Writing the patterns as'\_transient\_%'and'\_transient\_timeout\_%'keeps the logic identical and lets the optimizer use the index prefix. On a multi-gigabytewp_options, this can cut the runtime substantially. - Mind the locks. The statement takes row locks on every matched row, and on a very large cleanup it can block concurrent checkout writes to
wp_options. Estimate the match count with aSELECT COUNT(*)using the same joins first, and run it off-peak. - Multi-table
DELETEdoes not acceptLIMIT. For millions of expired rows, check replication lag during the run, or split the work by staging IDs in a temporary table and deleting in chunks. - WP-CLI is the simpler path.
wp transient delete --expiredremoves expired transients without hand-written SQL. After a large purge,OPTIMIZE TABLE wp_options;rebuilds the table and reclaims space, which briefly takes metadata locks, so schedule it accordingly.
Then fix the cause so the table doesn't refill. Replace WP-Cron with a system cron entry and set DISABLE_WP_CRON to true:
*/5 * * * * cd /var/www/html && wp cron event run --due-now --quiet
The structural fix is persistent object caching, commonly implemented with Redis object caching or Memcached. With one in place, transients are stored in the cache instead of wp_options, and the table stops accumulating them. Rows already in the database remain, so run the cleanup once after enabling it.
Related Architecture Guides: Cleaning expired transients in wp_options is only the first phase of scaling an uncached checkout. For flash sales and peak traffic events, you should also configure isolated Redis session locks for high-traffic WooCommerce checkout concurrency and eliminate legacy wp_postmeta table scans by migrating to High-Performance Order Storage (HPOS) and refactoring checkout meta queries.
Remove heavy rows from the memory initialization sequence
To stop an option from being loaded at startup, set its autoload flag to no. The command from the WP-CLI documentation is:
wp option set <option_name> <value> --autoload=no
wp option set is an alias for wp option update, so it requires a value and overwrites the stored one. That is safe for a simple scalar you can restate exactly, and dangerous for a large serialized array you would have to retype. For the common case, where you want to change only the flag and keep the data, use the dedicated command in current WP-CLI releases:
wp option set-autoload <option_name> off
Check the current state with wp option get-autoload <option_name>. If you change the flag with raw SQL instead, flush the object cache afterward with wp cache flush, since the cached alloptions entry otherwise keeps the old value.
A few rules for choosing candidates:
- Leave core and per-request options alone. Rows like
siteurl,home, andactive_pluginsare read on every request, and flipping them only adds individual queries. - Target data read rarely. Logs, queues, cached remote payloads, license and update responses, and analytics arrays are typical candidates. A non-autoloaded option that is read occasionally costs one indexed lookup, which is far cheaper than loading megabytes on every request.
- Remove orphaned data. Rows left behind by uninstalled plugins should be deleted, not just unflagged. Back up first.
- Re-audit after updates. A plugin that deletes and re-adds its option, or adds new ones, can undo the change.
Re-run the measurement queries afterward and compare the autoload total, then check checkout timing in the browser's Network tab on the ?wc-ajax=update_order_review request. The "Waiting for server response" figure is the number to compare before and after.
0 Comments