A WooCommerce performance checklist for Black Friday covers seven areas: cache rules, object cache, order storage, background jobs, database weight, the checkout path, and abuse limits. Black Friday falls on November 27, 2026, so you have about four weeks to measure each area, fix what fails, and freeze changes. This post gives the checks, the commands, and the failure modes for each one.
The commands assume shell access and WP-CLI. Run everything on a staging copy first. We checked every function, constant, flag, and directive against the official documentation. We did not run these snippets against a live store, so treat the nginx and k6 examples as patterns to adapt.
A Four Week Plan
Order matters more than any single fix. Changes made late carry the most risk, and a peak sale is a bad moment to find a regression.
- Week 1, measure: record a baseline for page times, queue depth, autoload size, and error rates.
- Week 2, fix: apply cache rules, system cron, database cleanup, and rate limits.
- Week 3, test: run a load test, a restore test, and a full checkout test with every payment method.
- Week 4, freeze: stop deploying about a week before the sale, except for security fixes.
Cache Rules That Protect the Cart
The WooCommerce documentation says the Cart, My Account, and Checkout pages must stay dynamic. They show information specific to the current customer and their cart. A cache that serves one version to everyone shows stale or wrong totals, and it can break login and password reset flows.
Pages and cookies to exclude
The same documentation lists the cookies that signal a personalized request. If your cache offers database caching, it also suggests excluding _wc_session_.
store_notice[notice id]woocommerce_cart_hashwoocommerce_items_in_cartwoocommerce_recently_viewedwp_woocommerce_session_
The nginx example below bypasses the cache for those three pages, for add to cart requests, for wc-ajax calls, and for the cart, session, and login cookies. Nginx treats a bypass value as true when it is neither empty nor 0. The map blocks produce exactly that.
# http context
fastcgi_cache_path /var/cache/nginx/wp levels=1:2 keys_zone=WPCACHE:100m inactive=60m;
map $request_uri $wc_skip_uri {
default 0;
~*^/(cart|checkout|my-account)(/|\?|$) 1;
~*[?&]add-to-cart= 1;
~*[?&]wc-ajax= 1;
}
map $http_cookie $wc_skip_cookie {
default 0;
~*woocommerce_items_in_cart 1;
~*woocommerce_cart_hash 1;
~*wp_woocommerce_session_ 1;
~*wordpress_logged_in_ 1;
}
# server context
location ~ \.php$ {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/run/php/php-fpm.sock;
fastcgi_cache WPCACHE;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
fastcgi_cache_valid 200 301 10m;
fastcgi_cache_bypass $wc_skip_uri $wc_skip_cookie;
fastcgi_no_cache $wc_skip_uri $wc_skip_cookie;
add_header X-Cache-Status $upstream_cache_status always;
}
Three edge cases cause most failures here. First, the regex matches the default slugs, so change it if you renamed or translated the pages. Second, the example leaves out woocommerce_recently_viewed and store_notice. Add them if you use those features, and expect a lower hit rate for those visitors. Third, a cache plugin can sit in front of this rule, so test through the real stack.
The X-Cache-Status header makes the test simple. Request a product page twice, then the cart. The product page should show HIT on the second call, and the cart should never show HIT.
Object Cache and Cart Fragments
A persistent object cache
A persistent object cache keeps the results of repeated lookups in memory between requests. It helps most on the pages a full page cache must skip, such as the cart, checkout, and account pages. The Redis Object Cache plugin documents these constants, and each site on a shared Redis server needs its own prefix and database.
// wp-config.php, above the "stop editing" line
define( 'WP_REDIS_HOST', '127.0.0.1' );
define( 'WP_REDIS_PORT', 6379 );
define( 'WP_REDIS_DATABASE', 0 );
define( 'WP_REDIS_PREFIX', 'shop-example-com' );
// Shell:
// wp plugin install redis-cache --activate
// wp redis enable
// wp redis status
// redis-cli info stats | grep evicted_keys
Watch memory, not just connectivity. A full Redis instance evicts keys, and the hit rate drops without any error message. The evicted_keys counter in redis-cli info stats shows it.
Cart fragments
The wc-cart-fragments script refreshes the mini cart through admin-ajax. Before WooCommerce 7.8, it loaded on every page. Since 7.8, WooCommerce loads it only when the cart widget renders or something depends on it. Some themes hard-code the widget, so check your network tab for get_refreshed_fragments.
The WooCommerce developer blog offers two fixes. One filter stops the script from running outside store pages. The other fix replaces the widget with the Mini-Cart block, which does not use fragments. The filter has a caveat. With page caching, a mini cart outside store pages can show stale contents.
add_filter( 'woocommerce_get_script_data', function ( $script_data, $handle ) {
if ( 'wc-cart-fragments' === $handle ) {
if ( is_woocommerce() || is_cart() || is_checkout() ) {
return $script_data;
}
return null;
}
return $script_data;
}, 10, 2 );
High-Performance Order Storage
HPOS keeps orders in dedicated tables instead of the posts and postmeta tables. It has been the default for new installs since WooCommerce 8.2. Existing stores may still run the older storage, so check before you plan anything else.
# Is HPOS on, is compatibility mode on, are orders unsynced?
wp wc hpos status
# Only with compatibility mode enabled, and best on staging
wp wc hpos verify_data --batch-size=500
# Sync pending orders before any disable
wp wc hpos sync --batch-size=500
# Branch in custom code that touches order storage
# use Automattic\WooCommerce\Utilities\OrderUtil;
# if ( OrderUtil::custom_orders_table_usage_is_enabled() ) { ... }
The status command reports whether HPOS and compatibility mode are on, plus unsynced orders. Compatibility mode keeps both datastores in sync, which doubles order writes. That is a reasonable price during a migration and an unnecessary one afterward.
Two rules apply this close to the sale. First, do not migrate in week 4. The WooCommerce CLI documentation warns that disable fails with “There are orders pending sync” until you run a sync. Second, verify_data --re-migrate can overwrite an order in the database, so reserve it for a staging copy.
Custom code needs a check too. Plugins that query order data with SQL against the posts table break under HPOS. Use OrderUtil::custom_orders_table_usage_is_enabled() to branch, or better, use the WooCommerce order APIs. You can list incompatible plugins at /wp-admin/plugins.php?plugin_status=incompatible_with_feature&feature_id=custom_order_tables.
Action Scheduler and WP-Cron
WooCommerce sends emails, processes subscriptions, and syncs data through Action Scheduler. Its default runner hooks into WP-Cron, and WP-Cron only fires when someone visits the site. Under a traffic spike, the queue can fall behind, and a late order email is the first symptom customers notice.
Check the backlog
Count actions by status, then count pending actions that are already due. A growing past-due number means the runner cannot keep up. Replace wp_ with your table prefix.
-- Actions by status
SELECT status, COUNT(*) AS total
FROM wp_actionscheduler_actions
GROUP BY status;
-- Pending actions that should already have run
SELECT hook, COUNT(*) AS past_due, MIN(scheduled_date_gmt) AS oldest
FROM wp_actionscheduler_actions
WHERE status = 'pending' AND scheduled_date_gmt < UTC_TIMESTAMP()
GROUP BY hook
ORDER BY past_due DESC;
Run the queue from system cron
The WordPress plugin handbook documents the DISABLE_WP_CRON constant and a system scheduler for this purpose. Its example uses wget on a 15 minute schedule. WP-CLI avoids the web server entirely, and wp cron event run --due-now respects the doing_cron transient, so overlapping runs are limited. We suggest one run per minute during the sale, wrapped in flock so a slow run never stacks.
// wp-config.php
define( 'DISABLE_WP_CRON', true );
# crontab for the site user (crontab -e)
* * * * * cd /var/www/example.com && flock -n /tmp/wp-cron-example.lock wp cron event run --due-now --quiet
# Optional: a dedicated queue run, only if the default runner is disabled
# wp action-scheduler run --batch-size=100
Run the crontab entry as the same user that owns the site files. Otherwise the runner can create files with the wrong owner.
Tune the runner
Action Scheduler defaults to 25 actions per batch, a 30 second time limit, and one concurrent batch. The filters are action_scheduler_queue_runner_batch_size, action_scheduler_queue_runner_time_limit, and action_scheduler_queue_runner_concurrent_batches. The Action Scheduler documentation warns that raising concurrency can greatly increase load, and that WP-CLI is the better path to throughput. The --force flag on wp action-scheduler run overrides the concurrent batch limit, so use it sparingly. To trim old rows, run wp action-scheduler clean.
Database Weight
Autoloaded options
WordPress loads every autoloaded option on every request. Since WordPress 6.6, Site Health raises a critical issue when the total passes 800 KB. Core also stops autoloading a new option larger than 150000 bytes unless the code explicitly asks for it. Plugins that wrote large options before that change can still leave a heavy table behind.
# Total bytes of autoloaded options
wp option list --autoload=on --format=total_bytes
# The 20 largest autoloaded options (values of auto, on, auto-on and yes load on every request)
wp db query "SELECT option_name, LENGTH(option_value) AS bytes
FROM wp_options
WHERE autoload IN ('yes','on','auto-on','auto')
ORDER BY bytes DESC
LIMIT 20;"
# Turn autoload off for one option you have verified is safe (WP-CLI 2.8.0 or later)
wp option set-autoload some_large_option off
# Remove expired transients
wp transient delete --expired
Be careful with the fix. Turning off autoload for an option that every request needs only moves the cost into extra queries. Check the plugin that owns each large row first. Deactivated plugins are the usual source, and an expired transient is safe to delete.
Indexes and slow queries
Do not add indexes to core tables on a hunch. WooCommerce ships indexes for its own tables, and Action Scheduler indexes its status and scheduled date columns. Find the real slow queries first, then run EXPLAIN on each one. Enabling the MySQL slow query log for a day of staging traffic gives you that list. A query that scans the whole postmeta table usually points to a plugin, not a missing index.
Images and Largest Contentful Paint
Sale banners often become the new largest element on the homepage. Re-test after the banner ships, because the old preload can point at an image that no longer leads the page. Our guide to LCP image preloading covers finding the element and adding the preload tag. Test the mobile layout separately, since the banner crop often differs.
Checkout Path and Abuse Limits
Blocks or shortcode
The Cart and Checkout blocks became the default for new stores in WooCommerce 8.3. The shortcodes [woocommerce_cart] and [woocommerce_checkout] remain supported. Do not switch in the final weeks. If you must, test every payment gateway, coupon rule, shipping method, and tax setting on staging. Extensions declare cart and checkout block support through the cart_checkout_blocks feature, so confirm each active extension does.
WooCommerce rate limiting
The Store API can limit POST requests, but it ships disabled. The defaults are 25 requests per 10 seconds. Checkout has a separate setting under WooCommerce, Settings, Advanced, Features, capped at 3 requests per 60 seconds. Clients behind a proxy need proxy_support turned on. Otherwise every shopper can share one address and one limit. The documentation does not state the status code of a rejected request, so test it.
add_filter( 'woocommerce_store_api_rate_limit_options', function () {
return [
'enabled' => true,
'proxy_support' => true, // only if a trusted proxy sets the client IP header
'limit' => 25,
'seconds' => 10,
];
} );
Limits at the web server
An nginx limit protects PHP before WordPress loads. The example limits POST requests to checkout endpoints and login attempts. A map returns an empty key for other methods, and nginx does not count empty keys. It rejects with 429 instead of the default 503.
# http context
limit_req_zone $binary_remote_addr zone=wc_login:10m rate=10r/m;
map "$request_method:$request_uri" $wc_checkout_key {
default "";
~^POST:/checkout(/|\?|$) $binary_remote_addr;
~^POST:/wp-json/wc/store/v1/checkout $binary_remote_addr;
~^POST:/\?wc-ajax=checkout $binary_remote_addr;
}
limit_req_zone $wc_checkout_key zone=wc_checkout:10m rate=30r/m;
limit_req_status 429;
# server context
limit_req zone=wc_checkout burst=10 nodelay;
limit_req_dry_run on; # log only; remove after reviewing the logs
location = /wp-login.php {
limit_req zone=wc_login burst=5 nodelay;
limit_req_dry_run on;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/run/php/php-fpm.sock;
}
Two edge cases matter. Many shoppers can share one public address, such as an office or mobile carrier, so start generous and use limit_req_dry_run on; to see what would be rejected. Behind a CDN, nginx sees the CDN address unless you restore the client address with set_real_ip_from and real_ip_header. Trust only your proxy ranges, because a header from an untrusted source can be forged. If you use Cloudflare, check your SSL mode first, because a mismatch causes endless redirect loops that look like checkout outages.
Load Testing
A load test shows what breaks first, and it is cheap to run against staging. This k6 script browses, then adds to the cart. It does not touch a payment gateway. Never point it at production or at a live gateway.
@@code:k6@@
The thresholds come from the k6 documentation format, but the numbers are placeholders. Set them from your week 1 baseline. Raise the user count in steps. Watch the database, PHP workers, and the Action Scheduler backlog while it runs, because the first limit is often not the web server.
Backups and Rollback
A restore plan that nobody tested is only a hope. Take a database export and a file snapshot before the freeze, then restore both onto staging and load the site.
# Database export with a timestamped name
wp db export "pre-freeze-$(date +%F).sql" --add-drop-table
# Restore on staging, then load the site and place a test order
wp db import pre-freeze-2026-11-20.sql
# Pin plugin versions for a code rollback (replace the version)
wp plugin list --fields=name,version,status
wp plugin install woocommerce --version=X.Y.Z --force
Plan the rollback as a code rollback. Restoring a database snapshot during the sale discards every order placed after it was taken. Pin the previous plugin version, redeploy, and clear caches. Reserve a database restore for corruption, and export recent orders first.
Monitoring During the Sale
Set alerts from your baseline, not from guesses. These checks cover the failures above.
- Checkout availability: a synthetic check that loads the checkout page and adds a product to the cart.
- Errors: web server 5xx and 429 counts, plus the PHP error log.
- PHP workers: the PHP-FPM slow log, enabled with the pool
slowlogandrequest_slowlog_timeoutsettings. - Queue depth: the past-due Action Scheduler count from the earlier query.
- Redis: memory use and the
evicted_keyscounter.
Which Items Fit Your Store
- A small store on shared hosting: do the cache exclusions, system cron, and autoload cleanup. Ask your host about Redis.
- A store with a custom theme or plugins: audit HPOS compatibility and cart fragments, then run the load test.
- A store with subscriptions or heavy email: watch the Action Scheduler backlog daily.
- A store that sees bot traffic: turn on Store API rate limiting and the nginx limits, in dry run mode first.
Want a second pair of eyes on your store before the sale? Contact FKW Digital or book a consultation.




Leave a Reply