— Artikel — № 033

033 —WordPress

WooCommerce checkout audit: de plugin van 2,3 seconden

Loom van een Nederlands bureau: WooCommerce checkout hangt drie seconden op elke postcodewijziging. De audit van twintig minuten die de plugin vindt.

Plat overzicht: papieren waterval-grafiek met balken, koperen stopwatch op 2,3s, manila CHECKOUT-tab, rode lakzegel op linnen.
Hero · gestileerd stilleven№ 033

Een Nederlands bureau waar we mee samenwerken stuurde zondagavond om 23:41 een Loom door. De WooCommerce checkout van hun klant hing ongeveer drie seconden bij elke wijziging van het postcodeveld. De site stond zes jaar live, had onderweg een flinke stapel plugins verzameld, en niemand in het team wist welke de boosdoener was. De opdracht was simpel. Vind de plugin die de latency veroorzaakt. En sloop daarbij niet de checkout.

Dit is een WooCommerce checkout-audit die iedere capabele ontwikkelaar in twintig minuten kan doen, mits je weet waar je moet kijken. De truc is de stappen in de juiste volgorde uitvoeren, anders ben je vijftien van die minuten kwijt aan het uitsluiten van dingen die nooit het probleem waren.

Eerst een baseline meten

Open de live checkout in een incognito-venster. Leg hetzelfde product in de cart waarmee je gaat testen, ga naar /checkout/, open DevTools, switch naar het Network-tabblad en filter op wc-ajax. Wijzig nu het postcodeveld. Kijk naar de request naar ?wc-ajax=update_order_review. Noteer drie getallen: totale tijd, time to first byte, en response-grootte. Schrijf ze op. Zonder meting vooraf is de rest van de audit toneel.

Is de TTFB 2,5 seconden en het totaal 2,6, dan zit het probleem aan de serverkant, in PHP of MySQL. Is de TTFB 200ms en het totaal 2,6 seconden, dan ligt de bottleneck in de response (enorme cart fragments) of in de JS die op die response draait. Op een legacy site is de boosdoener bijna altijd TTFB.

Eerst de waterfall lezen, dan de query log

WordPress kent een constante genaamd SAVEQUERIES. Zet deze in wp-config.php op staging, nooit op productie:

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

Installeer Query Monitor. Doe de postcodewijziging opnieuw. Open het Query Monitor-paneel en sorteer op query-tijd. Je zoekt één van drie patronen: een enkele query van 800ms of meer, een hook die vijftig queries in een loop afvuurt, of een externe HTTP-request die de response blokkeert.

Dat derde patroon komt het vaakst voor en wordt het vaakst gemist. Currency-switchers, tax-rate API's en shipping-plugins doen routinematig synchrone calls naar adresvalidators of tariefservices bij elke update_order_review-tick. Het HTTP API-paneel van Query Monitor toont elke blokkerende uitgaande request met de timing erbij. Zie je daar een call van 1,4 seconde naar een address-lookup endpoint, dan heb je de plugin al gevonden.

Bisect de plugin-lijst op staging

Wijst Query Monitor niet meteen één plugin aan, bisect dan. Gebruik WP-CLI op een staging-kopie:

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)

Vijf rondes bisection over veertig plugins brengen je bij één verdachte. Schakel WooCommerce zelf uiteraard niet uit. Schakel de gateway-plugin niet uit, tenzij je tijd hebt om het volledige aankoopproces opnieuw te testen. Houd per ronde bij welke set je hebt getest en wat de timing was, anders ben je het spoor kwijt zodra een meeting je halverwege onderbreekt.

De gebruikelijke verdachten

Na genoeg audits komen dezelfde namen steeds terug. Multi-currency switchers die per request opnieuw wisselkoersen ophalen. Smart-coupon plugins die bij elke fragment-update de hele cart doorlopen. Vertaalplugins die in elke shortcode haken. Cart-abandonment plugins die bij elke toetsaanslag een rij naar wp_postmeta wegschrijven. Real-time shipping calculators die synchroon met UPS of DHL praten binnen de AJAX-response.

De filter om te inspecteren is woocommerce_update_order_review_fragments. Alles wat daaraan hangt draait bij elke postcodewijziging, elke quantity-wijziging, elke kortingscode. Gebruik het Hooks-paneel van Query Monitor om alles te tonen wat aan die filter hangt, gesorteerd op uitvoertijd. De boosdoener zit daar bijna altijd tussen.

De autoloaded-options valkuil

Negeer de database-kant niet. De wp_options-tabel op een zes jaar oude WooCommerce-site sleept vaak duizenden autoloaded rijen mee van plugins die ooit zijn verwijderd maar nooit opgeruimd. Draai dit even en zie waar je per request voor betaalt:

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

Eén autoloaded option van 2MB is genoeg om elke pageload op de site een paar honderd milliseconden trager te maken, checkout incluis. De site van dat Nederlandse bureau had een dode analytics-plugin met 4,1MB aan autoloaded JSON. Eén rij verwijderen leverde 380ms op, nog voordat we begonnen waren aan de plugin die de oorspronkelijke verdachte was.

Wat te doen als je hem gevonden hebt

Je hebt drie pragmatische opties. Vervang de plugin door een lichter alternatief. Patch de specifieke hook van de plugin zodat hij zijn werk uitstelt of cachet. Of, als het een third-party plugin is die je niet kunt forken, wikkel de trage hook in je eigen cache-laag met een transient. Op een klantsite wint optie drie meestal, omdat optie één een migratie betekent en optie twee een fork onderhouden door elke toekomstige update heen.

De kleinst mogelijke wrapper, voor een uitgaande HTTP-call binnen update_order_review, ziet er zo uit:

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 );

Zet die in een mu-plugin, niet in functions.php, zodat een theme-update hem nooit kan wegvegen.

Tijdens het bouwen van Pier kwamen we precies dit patroon tegen op tientallen WooCommerce checkouts. De diagnose was zelden het lastige deel; het veilig aanpassen daarna wel, en daarom houdt Pier volledige version history bij van elk bestand dat je aanraakt via FTP/SFTP en levert het een MySQL editor mee die de autoloaded-options query hierboven met één klik draait.

Het kleinste wat je vandaag kunt doen: open je staging-site, plak de autoloaded-options SQL in phpMyAdmin of de editor van je keuze, en kijk naar de top vijf rijen. Staat daar iets boven de 500KB als autoloaded, dan heb je al minstens 100ms latency te winnen voordat de plugin-bisect überhaupt begonnen is.

— Vragen —

Hoe snel hoort een gezonde WooCommerce update_order_review request te zijn?

Op een goed afgestelde site is een TTFB onder de 400ms realistisch. Alles boven de 1 seconde bij elke postcode- of quantity-wijziging verdient een audit; boven de 2 seconden merkt de klant het.

Kan ik deze audit veilig op productie draaien?

De read-only stappen (DevTools, de autoloaded-options SQL, Query Monitor in view-only modus voor een admin) zijn veilig. Plugin-bisection is dat niet. Kloon eerst naar staging.

Wat als Query Monitor zelf de checkout vertraagt?

Dat doet hij ook, een beetje. Gebruik hem voor relatieve vergelijking tussen runs, niet voor absolute timings. Schakel hem uit voordat je de baseline opnieuw meet die je aan de klant rapporteert.