— Article — № 033

033 —WordPress

WooCommerce checkout audit: hunting the 2.3s plugin

A Loom from a Dutch agency: WooCommerce checkout hanging three seconds on every postcode change. Here is the twenty-minute audit that finds the plugin.

Overhead flat-lay: paper waterfall chart with bars, brass stopwatch at 2.3s, manila CHECKOUT tab, red wax seal on linen.
Hero · staged still№ 033

A Dutch agency we work with sent over a Loom at 23:41 on a Sunday. Their client's WooCommerce checkout was hanging for roughly three seconds every time a customer changed the postcode field. The site had been live for six years, had picked up a stack of plugins along the way, and nobody on the team knew which one was the offender. The brief was simple. Find the plugin adding the latency. Don't break checkout in the process.

This is a WooCommerce checkout audit anyone competent can run in twenty minutes if they know where to look. The trick is doing the steps in the right order so you don't burn fifteen of those minutes ruling out things that were never the cause.

Set a baseline before you touch anything

Open the live checkout in an incognito window. Add the same product you will test with, get to /checkout/, open DevTools, switch to the Network tab, and filter for wc-ajax. Now change the postcode field. Watch the request to ?wc-ajax=update_order_review. Note three numbers: total time, time to first byte, and response size. Write them down. You need a before number or the rest of the audit is theatre.

If TTFB is 2.5 seconds and total is 2.6, the problem is server-side, in PHP or MySQL. If TTFB is 200ms and total is 2.6 seconds, the bottleneck is in the response payload (giant cart fragments) or in the JS that runs against the response. On a legacy site the villain is almost always TTFB.

Read the waterfall, then the query log

WordPress ships with a constant called SAVEQUERIES. Drop this into wp-config.php on staging, never on production:

define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true );
define( 'SAVEQUERIES', true );

Install Query Monitor. Replay the postcode change. Open the Query Monitor panel and sort by query time. You are looking for one of three patterns: a single query taking 800ms or more, a hook running fifty queries in a loop, or a remote HTTP request blocking the response.

That third one is the most common and the most missed. Currency converters, tax-rate APIs, and shipping plugins routinely make synchronous calls to address validators or rate services on every update_order_review tick. Query Monitor's HTTP API panel lists every blocking outbound request with its timing. If you see a 1.4-second call to an address-lookup endpoint, you have already found the plugin.

Bisect the plugin list on staging

If Query Monitor does not immediately point at one plugin, bisect. Use WP-CLI on a staging copy:

wp plugin list --status=active --field=name > /tmp/active.txt
# disable the top half
wp plugin deactivate $(head -n 20 /tmp/active.txt)
# re-time the checkout, write the number down
# now flip: re-activate the top, deactivate the bottom
wp plugin activate $(head -n 20 /tmp/active.txt)
wp plugin deactivate $(tail -n +21 /tmp/active.txt)

Five rounds of bisection on forty plugins gets you to a single suspect. Don't disable WooCommerce itself, obviously. Don't disable the gateway plugin unless you have time to re-test the full purchase flow. Keep a running note of which set you tested and the timing each round so you don't lose the thread when a meeting interrupts you halfway through.

The usual suspects

After enough audits the same names come up. Multi-currency switchers that re-query exchange rates per request. Smart-coupon plugins that walk the entire cart on every fragment update. Translation plugins hooking into every shortcode. Cart-abandonment plugins writing a row to wp_postmeta on every keystroke. Real-time shipping calculators talking to UPS or DHL synchronously inside the AJAX response.

The specific filter to inspect is woocommerce_update_order_review_fragments. Anything hooked there runs on every postcode change, every quantity change, every coupon entry. Use Query Monitor's Hooks panel to list everything attached to that filter and sort by execution time. The offender is almost always sitting there.

The autoloaded-options trap

Don't ignore the database side. The wp_options table on a six-year-old WooCommerce site often carries thousands of autoloaded rows from plugins that were uninstalled but never cleaned up. Run this and see what you are paying for on every request:

SELECT option_name, LENGTH(option_value) AS size_bytes
FROM wp_options
WHERE autoload = 'yes'
ORDER BY size_bytes DESC
LIMIT 20;

A single 2MB autoloaded option is enough to add a couple of hundred milliseconds to every page load on the site, checkout included. The Dutch agency's site had a defunct analytics plugin sitting on 4.1MB of autoloaded JSON. Removing one row clawed back 380ms before we even started on the plugin that was the original suspect.

What to do once you have found it

You have three pragmatic options. Replace the plugin with a lighter alternative. Patch the plugin's specific hook to defer or cache its work. Or, if it is a third-party plugin you cannot fork, wrap the slow hook in your own caching layer using a transient. On a client site option three usually wins, because option one means a migration and option two means maintaining a fork through every future update.

The smallest possible wrapper, for an outbound HTTP call inside update_order_review, looks like this:

add_filter( 'pre_http_request', function( $pre, $args, $url ) {
    if ( strpos( $url, 'slow-address-validator.example' ) === false ) {
        return $pre;
    }
    $key = 'addr_' . md5( $url . serialize( $args['body'] ?? '' ) );
    $hit = get_transient( $key );
    if ( $hit !== false ) {
        return $hit;
    }
    $res = wp_remote_request( $url, array_merge( $args, array( 'blocking' => true ) ) );
    set_transient( $key, $res, 6 * HOUR_IN_SECONDS );
    return $res;
}, 10, 3 );

Drop that in an mu-plugin, not in functions.php, so a theme update can never erase it.

When we built Pier we kept running into the same pattern on dozens of WooCommerce checkouts. The diagnosis was rarely the hard part; the safe edit afterwards was, which is why Pier keeps full version history of every file you touch over FTP/SFTP and ships a MySQL editor that runs the autoloaded-options query above with one click.

The smallest thing you can do today: open your staging site, paste the autoloaded-options SQL into phpMyAdmin or your editor of choice, and look at the top five rows. If anything over 500KB is autoloaded, you have already found at least 100ms of latency to claw back before the plugin bisect even begins.

— Questions —

How long should a healthy WooCommerce update_order_review request take?

On a well-tuned site under 400ms TTFB is realistic. Anything above 1 second on every postcode or quantity change is worth auditing; above 2 seconds is customer-visible.

Can I run this audit on production safely?

Read-only steps (DevTools, the autoloaded-options SQL, Query Monitor in view-only mode for an admin) are safe. Plugin bisection is not. Clone to staging first.

What if Query Monitor itself slows the checkout down?

It will, slightly. Use it for relative comparison between runs, not for absolute timings. Disable it before re-measuring the baseline you report to the client.