Skip to main content

Finding the 30-second WooCommerce bottleneck that Query Monitor missed

·1403 words·7 mins
Tech WordPress Performance Debugging WooCommerce

We inherited a high-volume WooCommerce store running on a dedicated cloud instance. The client’s operations team had a simple but maddening complaint: editing a product took a few seconds, but clicking Update turned into a 30-second coffee break. For a team running 100s of inventory adjustments a day, that was a structural bottleneck slowing down their entire supply chain.

We did what any experienced WordPress operator would do. We installed Query Monitor, loaded a product edit page, clicked Update, and waited to read the performance bar.

Query Monitor reported a completely acceptable 4.9 seconds.

Yet the browser spinner had been grinding for over 30 seconds. This is the story of why our standard tool lied to us, how we traced the real problem to the PHP shutdown phase, and how we got those update times back down to 6 seconds.

The illusion of a clean profile
#

Illustration for The illusion of a clean profile Query Monitor is a solid tool, but it has a fundamental blind spot that caught us off guard.

When you click Update on a product, the browser sends a POST request to /wp-admin/post.php. WordPress processes the update, saves the metadata, and then issues a 302 Redirect back to the edit page as a GET request. Because Query Monitor runs inside the WordPress lifecycle, the execution context of the original POST is completely lost once that redirect fires. When the page reloads, Query Monitor proudly shows the profile for the subsequent GET request. In our case, that second request was a perfectly reasonable 4.9 seconds.

To find the actual bottleneck, we had to stop looking at the rendered page and open Chrome DevTools.

Under the Network tab, we checked Preserve Log, clicked Update, and watched the waterfall. The culprit was right there:

POST /wp-admin/post.php  302 Redirect  34.4s

Nearly 30 seconds of execution time was happening inside that initial POST request, completely invisible to standard frontend profiling tools.

Uncovering the shutdown phase
#

Illustration for Uncovering the shutdown phase To figure out what was stalling the POST, we wrote a lightweight temporary mu-plugin to hook into key save events and log execution times directly to the system log.

Our initial assumption was that a bloated database or heavy custom fields were dragging down the WooCommerce save actions. We hooked into woocommerce_process_product_meta and save_post to measure their duration.

The timestamps made it clear we were wrong:

woocommerce_process_product_meta = ~0.097s
save_post                        = ~0.121s
woocommerce_update_product       = ~0.034s

The actual WooCommerce save mechanism finished in under a quarter of a second. The real surprise came when we measured the gap between the last product save hook and the global PHP shutdown hook:

shutdown       = ~28.9s
request total  = ~34.4s

In WordPress, shutdown runs after the response has been sent—or in this case, after the redirect headers are prepared. But because the server is still executing PHP to finalize tasks before closing the connection, the browser has to wait for it to finish before it can follow the redirect. Something massive was running during shutdown, entirely outside the normal rendering loop.

Finding the smoking gun
#

Illustration for Finding the smoking gun We modified our profiling mu-plugin to wrap callbacks attached to the shutdown action, logging class names and elapsed times for each.

The output pointed to a single culprit:

[PROFILER] RankMath\Sitemap\Cache_Watcher::clear_queued
elapsed = ~22.0216s
source  = wp-content/plugins/seo-plugin/includes/modules/sitemap/class-cache-watcher.php:310

Every time a product was saved, Rank Math’s sitemap cache watcher fired. Instead of offloading the work to an async background job, it ran synchronously in the POST shutdown phase: deleting, rebuilding, and writing new sitemap XML files directly to disk. Because the store had 1000s of products, categories, and tags, regenerating those sitemaps on the fly cost 22 seconds of raw disk I/O.

On top of that, we caught several outbound HTTP calls during the save. The admin panel was checking in with api.wordpress.org and various plugin licensing servers on every single update. They only added 2 to 3 seconds, but they were completely unnecessary during an editor’s active work session.

Our mitigation strategy
#

Illustration for Our mitigation strategy We wrote a series of surgical mu-plugins to decouple these heavy operations from the user’s save request.

Step 1: Disable the synchronous sitemap cache
#

Rank Math provides a filter to disable sitemap caching entirely. We dropped a mu-plugin at wp-content/mu-plugins/disable-rankmath-sitemap-cache.php to kill the disk-write operation before it could block the admin worker:

<?php
/**
 * Plugin Name: Disable Rank Math Sitemap Cache
 * Description: Keeps Rank Math sitemap enabled but disables sitemap caching to prevent slow cache invalidation during product saves.
 */

defined('ABSPATH') || exit;

add_filter('rank_math/sitemap/enable_caching', '__return_false', PHP_INT_MAX);

Step 2: Bypass invalidation specifically during product saves
#

As an extra layer, we added a conditional filter that detects whether the current execution is a product POST request and, if so, skips Rank Math’s sitemap storage invalidation entirely. This way, even if caching gets re-enabled somewhere else, a product save won’t trigger the rebuild:

<?php
/**
 * Plugin Name: Disable Rank Math Sitemap Cache Invalidation on Product Save
 * Description: Prevents slow Rank Math sitemap cache invalidation during WooCommerce product saves.
 */

defined('ABSPATH') || exit;

function ssd_is_product_save_post_request(): bool {
    if (!is_admin()) {
        return false;
    }

    if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
        return false;
    }

    if (!empty($_POST['post_ID'])) {
        $post_id = absint($_POST['post_ID']);
        return $post_id && get_post_type($post_id) === 'product';
    }

    return !empty($_POST['post_type']) && $_POST['post_type'] === 'product';
}

add_filter('rank_math/sitemap/invalidate_storage', function ($allow = true, ...$args) {
    if (ssd_is_product_save_post_request()) {
        error_log('[RM_SITEMAP_INVALIDATION_BYPASS] Skipped Rank Math sitemap cache invalidation during product save.');
        return false;
    }

    return $allow;
}, 10, 10);

Step 3: Move sitemap rebuilding to a scheduled task
#

We didn’t want to lose sitemap updates entirely, so we scheduled a WP-Cron event to invalidate and rebuild the sitemap once an hour, when no one is waiting on a browser screen to load:

<?php
/**
 * Plugin Name: Schedule Rank Math Sitemap Cache Clear
 * Description: Clears Rank Math sitemap cache periodically instead of during every product save.
 */

defined('ABABSPATH') || exit;

add_action('init', function () {
    if (!wp_next_scheduled('ssd_rankmath_sitemap_cache_clear')) {
        wp_schedule_event(time() + 300, 'hourly', 'ssd_rankmath_sitemap_cache_clear');
    }
});

add_action('ssd_rankmath_sitemap_cache_clear', function () {
    if (class_exists('RankMath\Sitemap\Cache')) {
        RankMath\Sitemap\Cache::invalidate_storage();
        error_log('[RM_SITEMAP_SCHEDULED_CLEAR] Rank Math sitemap cache cleared by scheduled task.');
    }
});

Step 4: Silence the background chatter
#

Finally, we addressed the outbound HTTP calls. We blocked update checks to WordPress.org, Rank Math, and WP Mail SMTP on regular page loads, restricting them to the native core update screens where they actually belong:

<?php
/**
 * Plugin Name: Limit Admin Update Checks
 * Description: Blocks WordPress.org, Rank Math, and SMTP plugin update/version checks on normal admin pages.
 */

defined('ABSPATH') || exit;

function ssd_allow_update_checks_now(): bool {
    if (defined('WP_CLI') && WP_CLI) {
        return true;
    }
    if (defined('DOING_CRON') && DOING_CRON) {
        return true;
    }

    $script = basename($_SERVER['PHP_SELF'] ?? '');
    $allowed_scripts = [
        'update-core.php', 'plugins.php', 'themes.php',
        'update.php', 'plugin-install.php', 'theme-install.php'
    ];

    return in_array($script, $allowed_scripts, true);
}

function ssd_should_block_admin_update_checks(): bool {
    if (!is_admin()) {
        return false;
    }
    return !ssd_allow_update_checks_now();
}

add_action('admin_init', function () {
    if (!ssd_should_block_admin_update_checks()) {
        return;
    }
    remove_action('admin_init', '_maybe_update_core');
    remove_action('admin_init', '_maybe_update_plugins');
    remove_action('admin_init', '_maybe_update_themes');
}, 1);

add_filter('pre_http_request', function ($preempt, $parsed_args, $url) {
    if (!ssd_should_block_admin_update_checks()) {
        return $preempt;
    }

    $blocked = [
        'api.wordpress.org/core/version-check',
        'api.wordpress.org/plugins/update-check',
        'api.wordpress.org/themes/update-check',
        'rankmath.com/wp-json/rankmath/v1/versionCheck',
        'wpmailsmtpapi.com/license/v1/get-plugin-update'
    ];

    foreach ($blocked as $needle) {
        if (stripos($url, $needle) !== false) {
            return new WP_Error('ssd_admin_update_check_blocked', 'Update check blocked on normal admin page.');
        }
    }

    return $preempt;
}, 10, 3);

What changed after the deploy
#

We validated with Chrome DevTools open, running a series of mock product updates against production:

  • Product Update POST request: dropped from 34.4 seconds to 5.8 seconds
  • Admin dashboard load: settled around 1.2 seconds, eliminating the previous API lag entirely
  • Core update checks: still working correctly on /wp-admin/update-core.php

The broader problem this exposed
#

This wasn’t really a Rank Math bug; it was a symptom of a wider architectural habit in the WordPress plugin ecosystem: running heavy, background-level tasks synchronously during critical user actions. Sitemap invalidation, license checks, and cache clearing have no business blocking a product save. They should always be deferred.

I’ll be honest: I assumed Query Monitor would surface something obvious. It didn’t, and I spent 2 hours chasing ghosts because I forgot how the POST-redirect-GET pattern clears the profiler’s state. The shutdown phase is a real blind spot for tool-assisted debugging. I hadn’t thought carefully enough about what happens between the POST response and the redirect that the browser actually follows.

If you’re chasing a performance problem that Query Monitor refuses to show you, open DevTools, turn on Preserve Log, and watch the raw POST request time. The 30 seconds you can’t explain are almost certainly hiding in the shutdown phase—not the page render.

Related

The silent memory leak: debugging intermittent 500s in WordPress Redis caching
·1026 words·5 mins
Tech WordPress Redis Performance Debugging
A WordPress site was throwing intermittent 500 errors every ten days. The cache flushed clean each time, which masked the real problem: a single Redis key growing to 4MB and occasionally tipping PHP memory over the edge.
WooCommerce slows down under concurrency, not under load
·1092 words·6 mins
Tech WooCommerce WordPress Performance Scaling
The WooCommerce performance failures that actually hurt at scale don’t show up in standard audits. They live in plugins doing unbounded per-request work that looks harmless at five requests per second and falls apart at twenty.
When bumping PHP memory isn't enough: tracing serialized cache bloat in a page builder
·750 words·4 mins
Tech WordPress Performance PHP Caching
A WordPress 500 traced to PHP memory looked like a simple limit bump — until the real cause turned out to be a page builder serializing large CSS blobs into the object cache on every request.