Three plugins, no culprit: from 13 to 1.6 seconds

The admin of a client’s WooCommerce site had felt slow for months. Not the front end, because visitors get a ready-made page from the cache there. It was the work behind the scenes: editing products, opening the dashboard, checking how a page looks while logged in. Every click took a few seconds, sometimes much more.

Cover: Three plugins, no culprit

The dashboard turned out to need 13.6 seconds. After one small change it was 1.6 seconds. The cause was not one bad plugin, but three plugins that each did something reasonable and together ended up going round in circles.

First the usual suspects, and they were all cleared. The automatically loaded settings in the database were tidy (317 KB, well below the point where WordPress warns). The database itself took 0.3 seconds. And a fast memory cache (Redis) was running, which according to its plugin was “connected”, without errors.

The Query Monitor plugin showed where the time did go. Every time the dashboard loaded, WordPress made 22 requests to other servers: checking for updates, for translations, whether a licence is still valid. Together more than 7 seconds of waiting. WordPress normally does such checks a few times a day at most, because it remembers the result. Here the site apparently remembered nothing.

The proof was simple. From the command line I put a test value in the cache and asked for it straight back. Gone. With all plugins disabled, it did stay. So something was emptying the cache, every time WordPress started.

Then I followed the trail to the moment it happened, with a small piece of code that only watches. That revealed the loop:

1. The theme (Botiga Pro) wants the site’s address rules recalculated once. To remember that it has done so, it leaves itself a note, in the cache. 2. While recalculating, a plugin for product addresses (Premmerce Permalink Manager) empties the entire cache. 3. That also removes the theme’s note. On the next request the theme thinks: I haven’t done that yet. And it starts all over again.

Without that fast cache nothing would have been wrong: the note would then have been in the database, and the plugin doesn’t touch that. The cache that should have made things faster is what made the loop possible in the first place.

The fix is one line of code, in a small file of its own, without changing anything in the theme, the plugin or the cache. That line tells the theme: your note is there. Which is exactly what the theme itself intends, because it only wants to recalculate the address rules once.

The result: the dashboard went from 13.6 to 1.6 seconds, with zero requests to the outside. An ordinary product page built fresh went from 4.9 to 1.8 seconds.

What I learn from it

“Connected, no errors” does not mean a cache works. It means it can be reached. Whether it remembers anything, you measure by putting something in and asking for it back.

And: with a problem between plugins there isn’t always a culprit. The theme and the address plugin each do something defensible. Only the combination with a cache turns it into a loop. Someone else had already reported the same problem to the makers of the address plugin, without the cause being found.

I also went wrong once along the way. My first attempt to find the culprit (skip one plugin at a time and see whether the test value stayed) first pointed at the address plugin, and in a second round at ten random others. On a live site, visitors, bots and scheduled tasks simply come by in between, and they emptied the cache too. A test that depends on chance points at nobody. The watching piece of code does.

For the techies: what exactly happens

Environment: WordPress 7.1.2 multisite, WooCommerce, Botiga theme with Botiga Pro, Premmerce Permalink Manager for WooCommerce 2.3.13, Redis Object Cache 3.0.0 (PhpRedis), WP Rocket for the page cache.

1. Botiga Pro (inc/modules/templates-builder/v3/src/Frontend/Setup.php):

add_action( 'init', array( $this, 'flush_rewrite_rules' ), 999 );

public function flush_rewrite_rules() {
    if ( ! get_transient( 'botiga_templates_flushed_rules') ) {
        flush_rewrite_rules();
        set_transient( 'botiga_templates_flushed_rules', true, 0 );
    }
}

A “run once” flag as a transient without expiry. With a persistent object cache, that transient only lives in the cache, not in wp_options.

2. WordPress postpones a flush requested before wp_loaded: WP_Rewrite::flush_rules() hooks itself onto wp_loaded (since 6.4, refresh_rewrite_rules()). As a result, a backtrace from rewrite_rules_array starts at wp_loaded and the requester is not visible.

3. Premmerce (src/PermalinkListener.php):

add_filter( 'rewrite_rules_array', array( $this, 'addRewriteRules' ), 99 );

public function addRewriteRules( $rules ) {
    // ...
    wp_cache_flush();

The entire object cache is emptied, and with it the transient from step 1.

Measuring. After a dashboard load, wp transient get update_themes --network returned *”not set”*. wp cache set and wp cache get in two separate processes: gone; with --skip-plugins --skip-themes: it stayed. The tracer was a file for --require using WP_CLI::add_wp_hook( 'all', … ), reporting the moment [ $wp_rewrite, 'flush_rules' ] gets hooked onto wp_loaded, with the plugin and theme files in the backtrace. Result: botiga-pro/…/Frontend/Setup.php:43 set_transient, between the hooks transient_botiga_templates_flushed_rules and pre_set_transient_….

Fix (mu-plugin):

add_filter( 'pre_transient_botiga_templates_flushed_rules', '__return_true' );

If the address rules ever do need recalculating: Settings → Permalinks → Save. Note: if Botiga renames the transient, this silently stops working. Check: in Query Monitor, the HTTP calls on a second dashboard load should be (nearly) empty.

The real fix belongs with the makers: a run-once flag belongs in an option, not in a transient. And wp_cache_flush() in a filter on the address rules is very heavy-handed.