Bluetooth thermal printer connection drop on Android sleep mode: Keep-alive fix

Expired transients accumulate in the wp_options table when they are never read after expiry (WordPress only deletes an expired transient on access, plus a scheduled cleanup that does not always keep up), and the resulting bloat slows the autoloaded-options query and the admin-ajax.php requests that depend on it until PHP-FPM or the proxy hits its timeout and returns 504.

Root Cause Diagnosis

Without a persistent object cache, set_transient() writes two rows to wp_options: _transient_{key} and _transient_timeout_{key}. Plugins that generate unique keys per request (sessions, API responses, license checks, feed caches) can create hundreds of thousands of rows. WordPress deletes an expired transient only when get_transient() is called for that exact key, so keys that are never requested again persist indefinitely.

The slowdown has two compounding causes. Transients written with autoload = yes (common with older plugins) are loaded into memory on every request, inflating the alloptions query. Separately, delete_expired_transients() and plugin cleanup routines run DELETE ... LIKE '_transient_%' against an unindexed pattern on a very large table, which holds locks long enough for concurrent admin-ajax.php calls (Heartbeat, autosave, plugin AJAX) to queue behind it. When a request exceeds request_terminate_timeout, fastcgi_read_timeout, or proxy_read_timeout, the web server returns 504.

Quick Fix

Delete all expired transients with WP-CLI, then flush the object cache:

wp transient delete --expired --allow-root
wp cache flush --allow-root

If WP-CLI is unavailable, run this in MySQL/MariaDB (back up first, replace wp_ with your table prefix):

DELETE a, b FROM wp_options a
LEFT JOIN wp_options b
  ON b.option_name = CONCAT('_transient_timeout_', SUBSTRING(a.option_name, 12))
WHERE a.option_name LIKE '\_transient\_%'
  AND a.option_name NOT LIKE '\_transient\_timeout\_%'
  AND b.option_value < UNIX_TIMESTAMP();

DELETE FROM wp_options
WHERE option_name LIKE '\_transient\_timeout\_%'
  AND option_value < UNIX_TIMESTAMP();

Step-by-Step Resolution

1. Back up the database

wp db export /root/backup-$(date +%F).sql --allow-root

2. Measure the problem

Count transient rows and total autoloaded size:

SELECT COUNT(*) AS transient_rows
FROM wp_options
WHERE option_name LIKE '\_transient\_%' OR option_name LIKE '\_site\_transient\_%';

SELECT ROUND(SUM(LENGTH(option_value))/1024/1024, 2) AS autoload_mb
FROM wp_options
WHERE autoload IN ('yes', 'on', 'auto-on', 'auto');

Autoload above roughly 1 MB, or transient rows in the tens of thousands, confirms the diagnosis. List the largest offenders:

SELECT option_name, LENGTH(option_value) AS bytes
FROM wp_options
ORDER BY bytes DESC
LIMIT 20;

3. Identify the plugin generating the keys

Group transient names by prefix:

SELECT SUBSTRING_INDEX(option_name, '_', 4) AS prefix, COUNT(*) AS n
FROM wp_options
WHERE option_name LIKE '\_transient\_%'
GROUP BY prefix
ORDER BY n DESC
LIMIT 15;

The dominant prefix usually maps directly to a plugin slug. Update, reconfigure, or replace that plugin; otherwise the table refills.

See also  ESC/POS receipt printer cutting paper before invoice print completes

4. Delete transients in batches (large tables)

A single DELETE against hundreds of thousands of rows can itself trigger 504 or lock wait timeouts. Batch it:

while true; do
  ROWS=$(mysql -N -e "DELETE FROM wp_options WHERE option_name LIKE '\_transient\_%' OR option_name LIKE '\_site\_transient\_%' LIMIT 5000; SELECT ROW_COUNT();" your_database)
  [ "$ROWS" -eq 0 ] && break
  sleep 1
done

Deleting unexpired transients is safe: they are regenerated on demand. Skip _site_transient_update_* rows if you want to avoid an immediate update check.

5. Convert autoloaded transients to non-autoload

UPDATE wp_options SET autoload = 'no'
WHERE option_name LIKE '\_transient\_%' OR option_name LIKE '\_site\_transient\_%';

On WordPress 6.6+, autoload values are normalized to on/off-style variants (auto, auto-on, auto-off, on, off); use 'off' in the statement above if your version stores them that way.

6. Move transients out of the database with a persistent object cache

With Redis or Memcached, set_transient() writes to the cache instead of wp_options, which eliminates the buildup at its source.

Install Redis and the PHP extension (Debian/Ubuntu):

apt install redis-server php-redis
systemctl enable --now redis-server

Add to wp-config.php, above the line /* That's all, stop editing! */:

define('WP_REDIS_HOST', '127.0.0.1');
define('WP_REDIS_PORT', 6379);
define('WP_CACHE_KEY_SALT', 'example.com:');

Install and enable the drop-in:

wp plugin install redis-cache --activate --allow-root
wp redis enable --allow-root
wp redis status --allow-root

Confirm wp-content/object-cache.php exists and status reports Connected.

7. Schedule recurring cleanup

Disable WP-Cron’s page-load trigger in wp-config.php:

define('DISABLE_WP_CRON', true);

Run it from system cron (/etc/cron.d/wp-cron):

*/5 * * * * www-data cd /var/www/html && /usr/local/bin/wp cron event run --due-now --quiet
15 3 * * * www-data cd /var/www/html && /usr/local/bin/wp transient delete --expired --quiet

Adjust the user, path, and WP-CLI location to your server.

See also  Wi-Fi direct thermal printer losing static IP after router lease time expiration

8. Raise timeouts as a stopgap, not a fix

Increase limits only to keep the admin usable during cleanup.

/etc/php/8.x/fpm/pool.d/www.conf:

request_terminate_timeout = 120

php.ini:

max_execution_time = 120

Nginx (location ~ \.php$ block):

fastcgi_read_timeout 120s;

Apache with mod_proxy_fcgi:

ProxyTimeout 120

Reload services afterward:

systemctl reload php8.x-fpm nginx

9. Reduce Heartbeat load on admin-ajax.php

Add to the active theme’s functions.php or a must-use plugin (wp-content/mu-plugins/heartbeat.php):

<?php
add_filter('heartbeat_settings', function ($settings) {
    $settings['interval'] = 60;
    return $settings;
});

add_action('init', function () {
    global $pagenow;
    if ($pagenow === 'index.php' || $pagenow === 'admin.php') {
        wp_deregister_script('heartbeat');
    }
});

Fewer concurrent AJAX requests reduce queue pressure on PHP-FPM workers.

10. Verify

wp db query "SELECT COUNT(*) FROM wp_options WHERE option_name LIKE '\_transient\_%';" --allow-root
time curl -s -o /dev/null -w "%{http_code}\n" -X POST https://example.com/wp-admin/admin-ajax.php -d "action=heartbeat"

An unauthenticated heartbeat returns 0 or 400 quickly; a response in under one second with no 504 confirms the query path is healthy.

Why This Fails for Some Users

  • Cleanup fixes nothing when the source plugin remains active. The table refills within hours or days. Step 3 is mandatory; deleting rows alone is temporary.
  • wp_options is not the bottleneck. Slow queries in wp_postmeta, wp_usermeta, or a plugin’s custom table produce identical 504 symptoms. Enable the slow query log to confirm:SET GLOBAL slow_query_log = 1; SET GLOBAL long_query_time = 2; Logs go to the path in SHOW VARIABLES LIKE 'slow_query_log_file';.
  • PHP-FPM worker exhaustion. If pm.max_children in /etc/php/8.x/fpm/pool.d/www.conf is too low, long-running AJAX calls occupy all workers and new requests queue until the proxy times out. Check /var/log/php8.x-fpm.log for server reached pm.max_children.
  • Multiple timeout layers. Nginx, Apache, PHP-FPM, a load balancer, and a CDN each have independent limits. Cloudflare returns its own 504 (error 524 after 100 seconds on non-Enterprise plans) regardless of origin settings. Test directly against the origin IP to isolate the layer.
  • Persistent object cache misconfiguration. A Redis instance with no maxmemory and no eviction policy can exhaust RAM; set maxmemory 256mb and maxmemory-policy allkeys-lru in /etc/redis/redis.conf. A stale or mismatched object-cache.php drop-in from a removed plugin can break all requests.
  • Shared hosting restrictions. Many shared hosts block Redis, WP-CLI, DISABLE_WP_CRON with system cron, and mysql batch scripts. Use the host’s phpMyAdmin with LIMIT-batched deletes, or a plugin such as Transients Manager, and request Redis from the provider if offered.
  • Multisite. Network-wide transients live in wp_sitemeta (_site_transient_%), and each subsite has its own wp_{blog_id}_options table. Run the cleanup per site:wp site list --field=url --allow-root | xargs -I % wp --url=% transient delete --expired --allow-root
  • Table engine and locking. MyISAM tables lock the entire table during DELETE, blocking all reads. Convert to InnoDB:ALTER TABLE wp_options ENGINE=InnoDB;
  • Fragmentation after mass deletion. Disk space and query performance do not recover until the table is rebuilt:OPTIMIZE TABLE wp_options; On large InnoDB tables, this locks writes for the duration; run it during low traffic.
  • Low-memory VPS (1 GB RAM or less). An oversized InnoDB buffer pool or Redis instance causes swapping, which resembles a timeout problem. Set innodb_buffer_pool_size to roughly 50-60% of available RAM in /etc/mysql/mysql.conf.d/mysqld.cnf and verify with free -m.
  • Security plugins and WAF rules. Rate limiting or ModSecurity rules on admin-ajax.php can hold requests and return 504 independently of database state. Check /var/log/apache2/modsec_audit.log or the plugin’s firewall log.
See also  Resolving USB HID Device Permission Denied on Ubuntu Without Root Access (udev rule)

Leave a Comment