Why most WooCommerce stores feel slow (even on good hosting)

Why WooCommerce stores feel slow even on good hosting: cart fragments, extra scripts, autoloaded options and old sessions, with code to check and fix each.

SpeedForge, the free speed plugin we make, has one file just for WooCommerce: class-speedforge-woocommerce.php. A lot of what’s in it maps to a section of this post.

The first job its header lists is empty cart fragment caching. When a visitor’s cart is empty, WooCommerce’s get_refreshed_fragments request still loads WordPress and WooCommerce from scratch, only to send back an empty mini-cart. SpeedForge saves that empty answer once and hands out the copy.

It’s a small thing, and it’s typical of where WooCommerce time goes: not the server, but work the store does on every visit without anyone asking for it.

I wanted the manual version written down too. When a store feels slow, hosting gets the blame first, and I’d look at the code before I’d pay for a bigger server. And you should know what a speed plugin changes before you let it change anything.

TLDR

Most WooCommerce slowness comes from the store, not the host: cart fragment requests, WooCommerce scripts on pages that don’t need them, a bloated wp_options autoload, an oversized sessions table and slow meta queries. Turn on HPOS for orders, check your autoload size in Site Health, add a persistent object cache, and load cart and checkout scripts only where they’re used. Query Monitor shows you which of these is your problem.

The real reasons your store is slow#

1. Cart fragments#

Cart fragments keep the classic mini-cart in your header up to date. The script, wc-cart-fragments, posts to ?wc-ajax=get_refreshed_fragments and swaps in fresh cart HTML:

// From WooCommerce's assets/js/frontend/cart-fragments.js
var $fragment_refresh = {
    url: wc_cart_fragments_params.wc_ajax_url.toString().replace( '%%endpoint%%', 'get_refreshed_fragments' ),
    type: 'POST',
    data: {
        time: new Date().getTime()
    },
    // ...
};

That request can’t be page cached, so each one runs PHP and hits the database. The script keeps a copy in the browser’s sessionStorage and reuses it while the cart hash matches. It still fires on the first page of each visit, whenever the cart changes, and after a day.

This used to be worse. Before WooCommerce 7.8, the script loaded on every page of every store. Since 7.8, WooCommerce only loads it when the classic Cart widget is on the page, as its cart fragments guide explains. In WooCommerce 11.2.1, the only place core enqueues wc-cart-fragments is WC_Widget_Cart.

So if it’s still on every page of your store, something put it there. Usually that’s a Cart widget in a sidebar or footer, a theme that builds the widget into its header, or another plugin that lists wc-cart-fragments as a dependency. The Mini-Cart block doesn’t use cart fragments at all.

How to stop cart fragments on non-WooCommerce pages#

Add this to your child theme’s functions.php or a code snippets plugin like WPCode:

/**
 * Stop cart fragments outside the shop.
 * The mini-cart won't update on these pages until a refresh.
 */
add_action( 'wp_enqueue_scripts', 'themology_limit_cart_fragments', 99 );

function themology_limit_cart_fragments() {
    if ( ! function_exists( 'is_woocommerce' ) ) {
        return;
    }

    // Keep cart fragments on shop, product, cart, checkout and account pages.
    if ( is_woocommerce() || is_cart() || is_checkout() || is_account_page() ) {
        return;
    }

    wp_dequeue_script( 'wc-cart-fragments' );
}

SpeedForge does the same thing, with one more check: it leaves the script alone if the Cart widget is active, so a sidebar mini-cart keeps working.

2. WooCommerce loads scripts everywhere#

Some WooCommerce scripts load only on the pages that use them. Two don’t. This is from load_scripts() in WooCommerce’s class-wc-frontend-scripts.php:

if ( 'yes' === get_option( 'woocommerce_enable_ajax_add_to_cart' ) ) {
    self::enqueue_script( 'wc-add-to-cart' );
}

// ...

// Global frontend scripts.
self::enqueue_script( 'woocommerce' );

The woocommerce script loads on every front-end page. So does wc-add-to-cart, when “Enable AJAX add to cart buttons on archives” is on. Your About page and every blog post get both.

Load WooCommerce scripts only where they’re needed#

This snippet keeps the scripts on shop pages and on any page with product shortcodes or blocks, and removes them everywhere else. Test it on staging, because a theme or plugin may expect them:

/**
 * Load WooCommerce scripts only where they're needed.
 */
add_action( 'wp_enqueue_scripts', 'themology_limit_woocommerce_scripts', 99 );

function themology_limit_woocommerce_scripts() {
    // Bail if WooCommerce isn't active
    if ( ! class_exists( 'WooCommerce' ) ) {
        return;
    }

    $is_wc_page = is_woocommerce() || is_cart() || is_checkout() || is_account_page();

    // Check if the page has WooCommerce shortcodes or blocks
    global $post;
    $has_wc_shortcode = false;
    $has_wc_block     = false;

    if ( is_a( $post, 'WP_Post' ) ) {
        $has_wc_shortcode = has_shortcode( $post->post_content, 'products' )
            || has_shortcode( $post->post_content, 'product' )
            || has_shortcode( $post->post_content, 'add_to_cart' );

        $has_wc_block = has_block( 'woocommerce/all-products', $post )
            || has_block( 'woocommerce/featured-product', $post )
            || has_block( 'woocommerce/product-collection', $post );
    }

    if ( ! $is_wc_page && ! $has_wc_shortcode && ! $has_wc_block ) {
        wp_dequeue_script( 'wc-cart-fragments' );
        wp_dequeue_script( 'woocommerce' );
        wp_dequeue_script( 'wc-add-to-cart' );

        // Styles too (optional, test first)
        // wp_dequeue_style( 'woocommerce-general' );
        // wp_dequeue_style( 'woocommerce-layout' );
        // wp_dequeue_style( 'woocommerce-smallscreen' );
    }
}

If another script that’s still on the page depends on woocommerce, WordPress prints it anyway, so this can’t break that dependency.

3. The SelectWoo problem#

SelectWoo is WooCommerce’s fork of Select2, the searchable dropdown it uses for country and state fields. WooCommerce loads it on the cart, checkout and account pages. In WooCommerce 11.2.1, that’s selectWoo.full.min.js at about 78 KB, plus a 15 KB select2.css, before compression.

The check is is_checkout(), which is true on a Block Checkout page too. Block Checkout draws its own country and state fields, so on that page SelectWoo has nothing to do.

Remove SelectWoo on Block Checkout#

/**
 * Remove SelectWoo on Block Checkout.
 * Block Checkout has its own country and state fields.
 */
add_action( 'wp_enqueue_scripts', 'themology_remove_selectwoo_block_checkout', 99 );

function themology_remove_selectwoo_block_checkout() {
    if ( is_checkout() && has_block( 'woocommerce/checkout' ) ) {
        wp_dequeue_script( 'selectWoo' );
        wp_dequeue_style( 'select2' );
    }
}

If a checkout plugin adds its own dropdowns with SelectWoo, they’ll lose their styling, so check the page after.

4. Database: the postmeta bottleneck#

HPOS moved orders out of wp_posts, but products are still there. Every product is a row in wp_posts, each variation is another row, and each field is a row in wp_postmeta: price, stock, SKU, dimensions and anything an extension adds. A catalog of a few thousand variable products turns into hundreds of thousands of meta rows.

Product queries join those rows by meta_key. WordPress’s default keys on wp_postmeta don’t fit that pattern well, so the database reads more rows than it needs.

Check your table sizes#

-- Run this in phpMyAdmin or your database tool
SELECT
    table_name,
    table_rows,
    ROUND(data_length / 1024 / 1024, 2) AS 'Data (MB)',
    ROUND(index_length / 1024 / 1024, 2) AS 'Index (MB)'
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name IN ('wp_posts', 'wp_postmeta', 'wp_options')
ORDER BY table_rows DESC;

table_rows is an estimate for InnoDB tables, which is close enough for this. If wp_postmeta runs into the millions, keys are worth adding.

The fix: Index WP MySQL For Speed#

Index WP MySQL For Speed adds better keys to WordPress tables, including wp_postmeta, wp_options, and WooCommerce’s wp_wc_orders_meta. On large tables, use WP-CLI to avoid timeouts:

# Install via WP-CLI
wp plugin install index-wp-mysql-for-speed --activate

# Add the high-performance keys to every table
wp index-mysql enable --all

Back up first. wp index-mysql disable --all restores the WordPress default keys. Its own docs say the difference shows most on larger sites, and a small store may not notice.

5. Options table autoload bloat#

WordPress loads every autoloaded option on every request with wp_load_alloptions(). Plugins that save logs, big serialized arrays or caches as autoloaded options make every page pay for them.

The autoload column changed in WordPress 6.6. It used to hold yes or no. It can now hold on, off, auto, auto-on and auto-off, and WordPress autoloads yes, on, auto-on and auto. An old query that only checks autoload = 'yes' misses most of them.

WordPress also tells you when this is a problem. Site Health warns once your autoloaded options pass 800 KB, a check added in the same release, as the Options API update for 6.6 explains.

Audit your autoloaded options#

# Total size of everything that autoloads
wp option list --autoload=on --format=total_bytes

# The 30 biggest autoloaded options
wp option list --autoload=on --fields=option_name,size_bytes --format=csv | sort -t, -k2 -n -r | head -n 30

Or in SQL:

-- Find the biggest autoloaded options
SELECT
    option_name,
    LENGTH(option_value) AS size_bytes,
    ROUND(LENGTH(option_value) / 1024, 2) AS size_kb
FROM wp_options
WHERE autoload IN ('yes', 'on', 'auto-on', 'auto')
ORDER BY size_bytes DESC
LIMIT 30;

Look for:

  • Options over 10 KB from plugins you’ve stopped using.
  • Plugin options that store logs or history.
  • Leftovers from plugins you deleted.

Fix large autoloaded options#

Turn autoload off for the ones a page doesn’t need, with WP-CLI:

wp option set-autoload some_plugin_log off

Or in PHP with wp_set_option_autoload(), which also clears the options cache:

// Run once, for example with: wp eval-file fix-autoload.php
$options_to_fix = array(
    'some_plugin_log', // Replace with the options from your audit
);

foreach ( $options_to_fix as $option ) {
    wp_set_option_autoload( $option, false );
}

Leave core options like rewrite_rules and cron alone. WordPress reads both on nearly every request, so turning autoload off only adds a separate query to fetch them.

6. Session storage overhead#

WooCommerce keeps carts, applied coupons and customer location in its own table, wp_woocommerce_sessions. In WooCommerce 11.2.1, a guest’s session lasts two days and a logged-in customer’s lasts a week. WooCommerce deletes expired sessions every 12 hours, through a scheduled woocommerce_cleanup_sessions action.

So a huge sessions table usually means that cleanup isn’t running. Check WooCommerce > Status > Scheduled Actions for woocommerce_cleanup_sessions stuck in Pending or marked Failed. If WP-Cron doesn’t run on your site, scheduled actions don’t either.

# Check session count
wp db query "SELECT COUNT(*) FROM wp_woocommerce_sessions"

# Count sessions that have already expired
wp db query "SELECT COUNT(*) FROM wp_woocommerce_sessions WHERE session_expiry < UNIX_TIMESTAMP()"

If the second number is big, fix the cron before anything else. To start over, WooCommerce > Status > Tools has “Clear customer sessions”. It deletes every session, current carts included, so run it when the store is quiet.

Quick wins that don’t require migration#

1. Turn on HPOS#

If you haven’t already, turn on High-Performance Order Storage. It’s the biggest free speed change WooCommerce has for order screens. I wrote a full guide to HPOS and Block Checkout on how to switch safely.

WooCommerce > Settings > Advanced > Features > Order data storage

To check it in code:

// True when HPOS is the active order storage
if ( \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled() ) {
    // Using HPOS
}

2. Skip work your custom queries don’t need#

If you write your own product queries, tell WordPress what to skip:

// Runs a second query to count every matching product
$products = new WP_Query( array(
    'post_type'      => 'product',
    'posts_per_page' => 10,
) );

// Skips the count when you don't need pagination
$products = new WP_Query( array(
    'post_type'              => 'product',
    'posts_per_page'         => 10,
    'no_found_rows'          => true,  // Skip SQL_CALC_FOUND_ROWS
    'update_post_meta_cache' => false, // Skip meta cache if not needed
    'update_post_term_cache' => false, // Skip term cache if not needed
) );

3. Cache expensive queries#

WooCommerce uses transient versions to know when cached product data is stale. You can use the same pattern:

/**
 * Cache an expensive product query.
 * Uses WooCommerce's transient versioning pattern.
 */
function themology_get_cached_product_stats() {
    // Versioned cache key
    $transient_version = WC_Cache_Helper::get_transient_version( 'product' );
    $transient_name    = 'my_product_stats_' . md5( $transient_version );

    $cached = get_transient( $transient_name );

    if ( false !== $cached ) {
        return $cached;
    }

    global $wpdb;
    $stats = $wpdb->get_results( "
        SELECT
            COUNT(*) as total_products,
            SUM(CASE WHEN meta_value = 'instock' THEN 1 ELSE 0 END) as in_stock
        FROM {$wpdb->postmeta}
        WHERE meta_key = '_stock_status'
    " );

    // Cache for 1 hour
    set_transient( $transient_name, $stats, HOUR_IN_SECONDS );

    return $stats;
}

When products change, WooCommerce refreshes the version itself, which makes the old key useless:

// WooCommerce runs this when products are updated
WC_Cache_Helper::get_transient_version( 'product', true ); // true = refresh

4. Object cache for repeated data#

/**
 * Keep data you read several times in the object cache.
 */
function themology_get_store_settings() {
    $cache_key   = 'my_store_settings';
    $cache_group = 'my_plugin';

    $settings = wp_cache_get( $cache_key, $cache_group );

    if ( false !== $settings ) {
        return $settings;
    }

    $settings = array(
        'currency'    => get_option( 'woocommerce_currency' ),
        'tax_display' => get_option( 'woocommerce_tax_display_shop' ),
        'weight_unit' => get_option( 'woocommerce_weight_unit' ),
    );

    wp_cache_set( $cache_key, $settings, $cache_group );

    return $settings;
}

Without a persistent object cache, this only lasts for the current request. With Redis or Memcached, it lasts across requests.

5. Turn off WooCommerce features you don’t use#

Check your widget areas first. If the classic Cart widget sits in a sidebar or footer, that’s what loads cart fragments on every page that shows it. If you don’t use WooCommerce’s classic widgets at all, you can unregister them:

/**
 * Unregister WooCommerce's classic widgets you don't use.
 */
add_action( 'widgets_init', function() {
    unregister_widget( 'WC_Widget_Cart' );
    unregister_widget( 'WC_Widget_Layered_Nav' );
    unregister_widget( 'WC_Widget_Layered_Nav_Filters' );
    unregister_widget( 'WC_Widget_Price_Filter' );
    unregister_widget( 'WC_Widget_Product_Categories' );
    unregister_widget( 'WC_Widget_Product_Search' );
    unregister_widget( 'WC_Widget_Product_Tag_Cloud' );
    unregister_widget( 'WC_Widget_Products' );
    unregister_widget( 'WC_Widget_Recently_Viewed' );
    unregister_widget( 'WC_Widget_Top_Rated_Products' );
    unregister_widget( 'WC_Widget_Recent_Reviews' );
    unregister_widget( 'WC_Widget_Rating_Filter' );
}, 11 );

Two settings are worth checking too:

Password strength meter: WooCommerce loads wc-password-strength-meter on checkout and account pages when customers pick their own password, and on the edit account and lost password pages. You can dequeue it, but it’s what stops customers choosing weak passwords, so I’d keep it.

Geolocation: WooCommerce > Settings > General > Default customer location. “Geolocate (with page caching support)” loads an extra wc-geolocation script on every page outside the cart, checkout and account pages. If your shipping and tax don’t depend on location, “Shop country/region”, the default, skips it.

Block Checkout vs classic: what changes for speed#

The two checkouts update the page differently. The classic checkout sends an update_order_review request whenever the shopper changes the address or shipping method, and replaces whole sections of the page with the HTML it gets back. Block Checkout talks to the Store API, gets JSON back, and React redraws only the parts that changed.

Classic checkoutBlock Checkout
Built onPHP templates and jQueryBlocks and React
Updates while typingupdate_order_review, returns HTMLStore API, returns JSON
Page updatesWhole sections replacedOnly changed parts redrawn
Extending itPHP hooks and templatesStore API and block extensibility

Check which checkout you’re using#

// Add to your child theme's functions.php to show an admin notice
add_action( 'admin_notices', function() {
    if ( ! function_exists( 'wc_get_page_id' ) ) {
        return;
    }

    $checkout_page = get_post( wc_get_page_id( 'checkout' ) );

    if ( $checkout_page && has_block( 'woocommerce/checkout', $checkout_page ) ) {
        echo '<div class="notice notice-success"><p>This store uses Block Checkout.</p></div>';
    } else {
        echo '<div class="notice notice-warning"><p>This store uses the classic checkout.</p></div>';
    }
} );

Server-side fixes#

1. Object caching (Redis or Memcached)#

Without a persistent object cache, WordPress throws away its cached query results at the end of every request and runs the same queries again on the next one.

// Check if a persistent object cache is active
if ( wp_using_ext_object_cache() ) {
    // Redis, Memcached or similar is in use
} else {
    // Non-persistent cache: each page load starts fresh
}
Hosting typeRecommendation
Managed WordPressOften built in, check your host’s docs.
VPS or dedicatedRedis Object Cache plugin.
Shared hostingSQLite Object Cache plugin.

2. Page caching configuration#

WooCommerce tells caching plugins to skip the cart, checkout and account pages. It defines these constants on those pages, and since WooCommerce 10.1 it sends no-cache headers there too:

// From WC_Cache_Helper
public static function set_nocache_constants( $return = true ) {
    wc_maybe_define_constant( 'DONOTCACHEPAGE', true );
    wc_maybe_define_constant( 'DONOTCACHEOBJECT', true );
    wc_maybe_define_constant( 'DONOTCACHEDB', true );
    return $return;
}

Most caching plugins respect them. If you write your own cache rules:

// Never cache these pages
$no_cache_pages = array(
    wc_get_page_id( 'cart' ),
    wc_get_page_id( 'checkout' ),
    wc_get_page_id( 'myaccount' ),
);

if ( is_page( $no_cache_pages ) ) {
    // Skip page cache
}

3. Let the built-in cleanup run#

You don’t need your own cleanup job for the basics. WordPress deletes expired transients once a day with its delete_expired_transients event, and WooCommerce clears expired sessions every 12 hours. Both depend on cron. If your host turns off WP-Cron, set up a real server cron that calls wp-cron.php, or neither cleanup runs.

Diagnosing performance issues#

1. Query Monitor#

Query Monitor shows:

  • Every database query and how long it took.
  • Which plugin or hook ran each query.
  • Duplicate queries, the N+1 problem.
  • HTTP API calls.
wp plugin install query-monitor --activate

2. Log slow queries#

WordPress only records queries when SAVEQUERIES is on, so add define( 'SAVEQUERIES', true ); to wp-config.php on a staging site first. Then:

/**
 * Log slow queries (staging only, needs SAVEQUERIES).
 */
add_filter( 'log_query_custom_data', function( $data, $query, $time ) {
    if ( $time > 0.05 ) { // Log queries over 50ms
        error_log( sprintf(
            'Slow query (%.4fs): %s',
            $time,
            $query
        ) );
    }
    return $data;
}, 10, 3 );

Turn SAVEQUERIES off when you’re done. Keeping every query in memory slows the site down.

The performance checklist#

Do today#

  • Find out what loads cart fragments, and stop it outside the shop.
  • Turn on HPOS if it isn’t already.
  • Install Query Monitor and look for slow queries.
  • Check your autoloaded options size in Site Health.
  • Check that woocommerce_cleanup_sessions runs.

This week#

  • Install Index WP MySQL For Speed.
  • Switch to Block Checkout if you’re still on the classic one.
  • Set up object caching (Redis or Memcached).
  • Remove plugins you don’t use.
  • Limit WooCommerce scripts to the pages that need them.

Every month#

  • Check the sessions table size.
  • Look at Query Monitor for new slow queries.
  • Check wp_options autoload size.
  • Update WooCommerce.
ToolPurposeLink
Query MonitorFind slow queriesWordPress.org
Index WP MySQL For SpeedAdd better database keysWordPress.org
Redis Object CachePersistent object cachingWordPress.org
SQLite Object CacheObject cache for shared hostingWordPress.org

For the official checklist, WooCommerce keeps a guide on troubleshooting a slow site, and the WordPress developer docs explain how object caching works.

Frequently asked questions#

Does hosting really not matter?#

Hosting matters. But a fast server still runs every slow query and every uncached AJAX request you give it. I’d fix the store first, then upgrade hosting if Query Monitor shows the server is still the limit.

Will disabling cart fragments break my mini-cart?#

On the pages where you turn it off, the classic mini-cart won’t update until the page reloads. On blog posts and info pages, that’s usually fine. If you use the Mini-Cart block instead of the classic widget, it doesn’t rely on cart fragments.

Is Block Checkout really faster?#

For updates while the shopper fills in the form, it does less work: it gets JSON from the Store API and redraws only what changed, where the classic checkout swaps whole HTML sections. It loads its own JavaScript up front, so the first load depends on your theme and plugins. Measure your own checkout before and after.

Should I disable all WooCommerce widgets?#

Only the ones you don’t use. Check your sidebar and footer first. The one that matters most is the classic Cart widget, because it’s what loads cart fragments.

How do I know if HPOS is working?#

wp wc hpos status

Or open WooCommerce > Status > System Status. The Settings section shows “HPOS enabled”, “Order datastore” and “HPOS data sync enabled”.

If you’d rather not do this by hand#

SpeedForge, the plugin from the start of this post, handles a lot of this for you: page caching, above-the-fold CSS generated on your own server, JavaScript defer and delay, and WooCommerce fixes like the empty cart fragments cache and Store API response caching. It’s free on WordPress.org.

If you only do one thing today, run wp option list --autoload=on --format=total_bytes, or open Site Health and look for the autoloaded options check. It takes a minute, and it tells you whether every page on your store is carrying weight it doesn’t need.

What I’d read next: the HPOS and Block Checkout migration guide, the WooCommerce security checklist, which also covers REST API lockdown and unused payment methods, and my WooCommerce CAPTCHA plugin comparison, since CAPTCHA adds JavaScript to your pages too.