Skip to main content

How to Keep a WooCommerce Checkout Up on Black Friday

6 min read
Prepare my WooCommerce checkout

We keep checkout traffic past the cache and size the store to a measured ceiling.

Ten times the traffic barely troubles a WooCommerce catalogue and takes the checkout down. The reason is that the catalogue is served from a page cache while the cart, the checkout and the account pages cannot be, so every one of those requests boots WordPress and hits the database. This guide walks through what WooCommerce itself excludes from caching, which cookies and AJAX endpoints have to be routed past the cache, where the cart and the order actually get stored, and how to turn the checkout ceiling into a number you can size instead of a surprise you discover on the day.

Point ten times your normal traffic at a WooCommerce catalogue and it will barely notice. Point the same traffic at the checkout and the store falls over. Once you know which requests can be served from a cache and which cannot, you know exactly what to size, and you stop spending the week before a sale tuning things that were never the constraint.

The catalogue is nearly free, the checkout never is

A full page cache serves a product or category page straight from disk or memory. PHP never starts, the database is never touched, and the ceiling is whatever your web server can push in bytes per second.

WooCommerce is explicit about which pages cannot work that way. Its caching guidance names Cart, My Account and Checkout as pages that have to be excluded, because what they render is specific to one customer and one cart. Every request to those three boots WordPress with every active plugin and issues real queries.

Peak catalogue traffic is therefore a bandwidth problem, and peak checkout traffic is a PHP worker and database problem. Only one of the two is cheap to raise.

What else never comes from the cache

Three more request types escape the page cache and are easy to miss.

WooCommerce has its own AJAX endpoint, reachable as /?wc-ajax=<action> or /wc-ajax/<action>. It exists so that front end AJAX does not have to load the admin the way admin-ajax.php does, and the documentation that introduced it says plainly that caching plugins must exclude it.

The Cart and Checkout blocks talk to the Store API, a set of public REST endpoints built for customer facing cart and checkout functionality. What they return belongs to one shopper, so a cache in front of them is a data leak rather than an optimisation.

Finally, any shopper carrying a WooCommerce cookie has to be routed past the cache. The three that matter are woocommerce_cart_hash, woocommerce_items_in_cart and wp_woocommerce_session_, the last being the key WooCommerce uses to find that customer's cart in the database. There is a second reason to respect them. For logged out visitors WooCommerce ties nonce generation to the session, so a cached page handed to the wrong visitor can carry a nonce that will not validate.

On nginx, one map and two directives cover it.

# /etc/nginx/conf.d/woocommerce-cache-skip.conf
map $http_cookie $wc_skip_cache {
    default                       0;
    "~*wp_woocommerce_session_"   1;
    "~*woocommerce_items_in_cart" 1;
    "~*woocommerce_cart_hash"     1;
}
# inside the PHP location block of your site
fastcgi_cache_bypass $wc_skip_cache;
fastcgi_no_cache     $wc_skip_cache;

fastcgi_cache_bypass stops the response being taken from the cache and fastcgi_no_cache stops it being written to the cache. You want both. Setting only the first serves fresh pages to cart holders and then stores one of those personalised pages for everybody else.

Where a checkout request actually goes

Since WooCommerce 2.5 the cart lives in a dedicated table rather than in options or transients. The cookie is wp_woocommerce_session_ plus the WordPress cookie hash, the table is wp_woocommerce_sessions with your own prefix, and the row is written on shutdown. WooCommerce 10.1 tightened this further, storing sessions for logged in users only in that table rather than duplicating them into user meta, with expiry defaulting to two days for guests and seven for logged in customers, capped at thirty, and the cleanup jobs moved onto Action Scheduler.

Orders land somewhere else again. High-Performance Order Storage has been the default for new installations since WooCommerce 8.2, and it writes to wc_orders, wc_order_addresses, wc_order_operational_data and wc_orders_meta instead of the old posts and postmeta pair.

A checkout is therefore a read, a modify and a write against the session table, followed by a burst of inserts across four order tables, all of it inside a PHP process that no cache will ever spare you.

Turn the ceiling into a number

The useful arithmetic is the simplest one available. The number of PHP workers kept busy is the arrival rate multiplied by the average request duration.

Measure the average duration of a completed checkout POST on your own store, in seconds. Call it D. If you expect R checkout requests per second at peak, then R multiplied by D workers are busy on average. At 20 requests per second and a 0.9 second checkout, that is 18 workers occupied by checkout alone, before a single product page, mini cart refresh or payment callback is served.

Compare that number against pm.max_children in your PHP-FPM pool. If they are close, queueing starts, latency climbs, durations get worse, and the two numbers chase each other upward. Leave real headroom rather than matching them.

The mini cart tax

Cart fragments are the AJAX call that keeps a mini cart count correct without a page reload, and every refresh is an uncached request. Before WooCommerce 7.8 the script was enqueued on every page of a WooCommerce store whether or not a mini cart was on screen. Since 7.8 it is enqueued when the Cart Widget is actually rendered, or when something else declares it a dependency. If a theme or plugin has pulled it back onto every page, the woocommerce_get_script_data filter is the documented way to limit where it runs.

Turn the rate limit on before the sale

The Store API ships with rate limiting built in and disabled by default. The default allowance once enabled is 25 requests per 10 second window, and there is a separate switch under WooCommerce, Settings, Advanced, Features labelled for the Checkout block and Store API, which applies a limit of 3 requests per 60 seconds to the place order flow specifically.

// wp-content/mu-plugins/store-api-rate-limit.php
add_filter( 'woocommerce_store_api_rate_limit_options', function () {
    return [
        'enabled'       => true,
        'proxy_support' => true, // required if a CDN or load balancer fronts the store
        'limit'         => 25,
        'seconds'       => 10,
    ];
} );

Proxy support is off by default, and that default is a trap behind a CDN or load balancer. Without it every request appears to arrive from the proxy address, so the limit is either shared by your entire customer base or effectively meaningless. Turn it on only when you trust the forwarding headers reaching your origin.

The week before

Load test the paths that cannot be cached and ignore the ones that can. A test that hammers the homepage tells you about nginx. A test that adds to cart, refreshes fragments and posts a checkout tells you about the thing that will actually break.

Two cheap read only queries are worth having on a dashboard for the day itself.

-- how many carts are alive right now
SELECT COUNT(*) FROM wp_woocommerce_sessions;

-- orders in the last hour, using the date_created index on the HPOS table
SELECT COUNT(*) FROM wp_wc_orders
WHERE date_created_gmt > UTC_TIMESTAMP() - INTERVAL 1 HOUR;

The first tells you how many shoppers are past the cacheable part of the store. The second tells you whether they are still getting through. When the first climbs and the second flattens, the checkout is the bottleneck and no amount of cache tuning will help.

If you would rather have this measured and sized before the sale than diagnosed during it, our WordPress speed optimization work covers exactly this ground, and server setup and optimization handles the PHP-FPM and database side of the same ceiling. The WordPress performance audit checklist is a good companion for the rest of the stack.

Frequently asked questions

Why does WooCommerce checkout go down on Black Friday when product pages stay fast?

Product and category pages are served from a full page cache, so PHP never starts and the database is never touched. WooCommerce requires Cart, My Account and Checkout to be excluded from caching because each renders one customer's cart, so every request to them boots WordPress with every active plugin and runs real queries. Peak catalogue traffic is a bandwidth problem, while peak checkout traffic is a PHP worker and database problem, and only the first is cheap to raise.

Which WooCommerce requests besides cart and checkout pages must bypass the page cache?

The WooCommerce AJAX endpoint, reachable as /?wc-ajax=<action> or /wc-ajax/<action>, has to be excluded, as the documentation that introduced it says plainly. So does the Store API behind the Cart and Checkout blocks, since its responses belong to one shopper and a cache in front of them leaks data. Any visitor carrying a WooCommerce cookie also has to skip the cache, partly because WooCommerce ties nonces for logged out visitors to the session and a cached page can carry one that will not validate.

How do I make nginx fastcgi_cache skip WooCommerce cart sessions?

Map $http_cookie to a variable such as $wc_skip_cache that is 1 when the cookie header matches wp_woocommerce_session_, woocommerce_items_in_cart or woocommerce_cart_hash, then pass that variable to both fastcgi_cache_bypass and fastcgi_no_cache in the PHP location block. The bypass directive stops a response being taken from the cache, and fastcgi_no_cache stops it being written to the cache. Setting only the bypass serves fresh pages to cart holders, then stores one of those personalised pages for everybody else.

Where does WooCommerce store carts and orders in the database?

Since WooCommerce 2.5 the cart lives in a dedicated wp_woocommerce_sessions table (with the site's own prefix), found through the wp_woocommerce_session_ cookie and written on shutdown. From WooCommerce 10.1, sessions for logged in users are stored only in that table, expiring by default after two days for guests and seven for logged in customers, capped at thirty. Orders go to the High-Performance Order Storage tables wc_orders, wc_order_addresses, wc_order_operational_data and wc_orders_meta, the default for new installations since WooCommerce 8.2.

How many PHP-FPM workers does a WooCommerce checkout need at peak?

Multiply the expected checkout requests per second by the average duration of a completed checkout POST measured on the store itself. At 20 requests per second and a 0.9 second checkout, 18 workers are busy on checkout alone, before any product page, mini cart refresh or payment callback is served. Compare that figure with pm.max_children in the PHP-FPM pool and leave real headroom, because once the two get close, queueing raises latency, durations grow and the numbers chase each other upward.

Do WooCommerce cart fragments still load on every page?

Since WooCommerce 7.8 the cart fragments script is enqueued only when the Cart Widget is rendered or something else declares it as a dependency. Before 7.8 it loaded on every page of the store, and each refresh is an uncached AJAX request made to keep the mini cart count correct without a reload. If a theme or plugin has pulled the script back onto every page, the woocommerce_get_script_data filter is the documented way to limit where it runs.

How do I enable WooCommerce Store API rate limiting behind a CDN?

Store API rate limiting is built in and disabled by default. Turn it on with the woocommerce_store_api_rate_limit_options filter, where the default allowance is 25 requests per 10 second window, while a separate Checkout block switch under WooCommerce, Settings, Advanced, Features limits the place order flow to 3 requests per 60 seconds. Behind a CDN or load balancer, also set proxy_support to true, or every request appears to come from the proxy address. Only do that when the forwarding headers reaching the origin can be trusted.

How can I tell during a sale whether the WooCommerce checkout is the bottleneck?

Watch two read only counts. A count of rows in wp_woocommerce_sessions shows how many carts are alive, and counting wp_wc_orders rows with date_created_gmt in the last hour shows whether shoppers are still getting through. When carts climb and orders flatten, the checkout is the bottleneck and cache tuning will not help. Before the sale, load test add to cart, fragment refreshes and checkout posts, since a homepage test only measures nginx.

Read how we handle it in DevOps for e-commerce.