Commit 7274c56fbac for woocommerce

commit 7274c56fbac0cb40adb454444bc279078954f7ce
Author: Vladimir Reznichenko <kalessil@gmail.com>
Date:   Tue Jun 30 07:07:28 2026 +0200

    [Performance] Introduce persistent product status counters (#65957)

    Migrates the admin products page and \Automattic\WooCommerce\Internal\Utilities\ProductUtil::get_counts_for_type onto persistent counters components. With persistent object caching, this eliminates "SELECT post_status, COUNT(*) AS num_posts FROM wp_posts WHERE post_type = 'product' GROUP BY post_status" SQL, which causes degrading admin performance for stores with larger product catalogs.

diff --git a/plugins/woocommerce/changelog/performance-product-status-counters-components b/plugins/woocommerce/changelog/performance-product-status-counters-components
new file mode 100644
index 00000000000..5123b2dbe0f
--- /dev/null
+++ b/plugins/woocommerce/changelog/performance-product-status-counters-components
@@ -0,0 +1,4 @@
+Significance: minor
+Type: performance
+
+Update the admin to use persistent product status counters to improve performance with large product catalogs.
diff --git a/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-products.php b/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-products.php
index 26aaab999c3..a54924fc5d2 100644
--- a/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-products.php
+++ b/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-products.php
@@ -8,6 +8,7 @@

 use Automattic\WooCommerce\Enums\ProductType;
 use Automattic\WooCommerce\Internal\CostOfGoodsSold\CostOfGoodsSoldController;
+use Automattic\WooCommerce\Internal\Utilities\ProductUtil;

 if ( ! defined( 'ABSPATH' ) ) {
 	exit;
@@ -57,14 +58,47 @@ class WC_Admin_List_Table_Products extends WC_Admin_List_Table {
 		add_filter( 'views_edit-product', array( $this, 'product_views' ) );
 		add_filter( 'get_search_query', array( $this, 'search_label' ) );
 		add_filter( 'posts_clauses', array( $this, 'posts_clauses' ), 10, 2 );
-		add_filter( 'the_posts', array( $this, 'prime_thumbnail_caches' ), 10, 2 );
 		add_action( 'manage_product_posts_custom_column', array( $this, 'add_sample_product_badge' ), 9, 2 );

+		// Use hooks to prime various caches and improve products page performance.
+		add_action( 'load-edit.php', array( $this, 'prime_status_counts_cache' ) );
+		add_filter( 'the_posts', array( $this, 'prime_thumbnail_caches' ), 10, 2 );
+
 		$cogs_controller              = wc_get_container()->get( CostOfGoodsSoldController::class );
 		$this->cogs_is_enabled        = $cogs_controller->feature_is_enabled();
 		$this->use_cogs_lookup_column = $this->cogs_is_enabled && $cogs_controller->product_meta_lookup_table_cogs_value_columns_exist();
 	}

+	/**
+	 * Pre-warm the wp_count_posts cache before the list table renders.
+	 *
+	 * @internal
+	 * @since 11.0.0
+	 *
+	 * @return void
+	 */
+	public function prime_status_counts_cache(): void {
+		$screen = get_current_screen();
+		if ( ! $screen || 'edit-product' !== $screen->id ) {
+			return;
+		}
+
+		// Performance note: the current listings architecture prevents us from isolating wp_count_posts calls.
+		// In the context of the products page, we can still isolate the underlying SQL by warming up the wp_count_posts cache.
+		$cache = (object) array_map(
+			static fn ( $count ) => $count ? (string) $count : (int) $count,
+			wc_get_container()->get( ProductUtil::class )->get_counts_for_type( 'product' )
+		);
+		// Trade-off: private-status tally may read slightly high for restricted roles (other users' privates included) — non-critical.
+		wp_cache_set_multiple(
+			array(
+				'posts-product' => $cache,
+				'posts-product_readable_' . get_current_user_id() => $cache,
+			),
+			'counts'
+		);
+	}
+
 	/**
 	 * Prime featured image caches for product queries to avoid individual queries during rendering.
 	 *
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index b7e140d9a26..fbfb1c68341 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -38,6 +38,7 @@ use Automattic\WooCommerce\Proxies\LegacyProxy;
 use Automattic\WooCommerce\Utilities\{LoggingUtil, TimeUtil};
 use Automattic\WooCommerce\Internal\Logging\RemoteLogger;
 use Automattic\WooCommerce\Caches\OrderCountCacheService;
+use Automattic\WooCommerce\Caches\ProductCountCacheService;
 use Automattic\WooCommerce\Internal\Caches\ProductVersionStringInvalidator;
 use Automattic\WooCommerce\Internal\Caches\OrdersVersionStringInvalidator;
 use Automattic\WooCommerce\Internal\Caches\TaxRateVersionStringInvalidator;
@@ -375,6 +376,7 @@ final class WooCommerce {
 		$container->get( ComingSoonCacheInvalidator::class );
 		$container->get( ComingSoonRequestHandler::class );
 		$container->get( OrderCountCacheService::class );
+		$container->get( ProductCountCacheService::class );
 		$container->get( EmailImprovements::class );
 		$container->get( DeferredEmailQueue::class );
 		$container->get( AddressProviderController::class );
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 73c8d0183a0..67e04a60a64 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -55417,54 +55417,12 @@ parameters:
 			count: 1
 			path: src/Caches/OrderCacheController.php

-		-
-			message: '#^Method Automattic\\WooCommerce\\Caches\\OrderCountCache\:\:decrement\(\) should return int but returns int\|false\.$#'
-			identifier: return.type
-			count: 1
-			path: src/Caches/OrderCountCache.php
-
-		-
-			message: '#^Method Automattic\\WooCommerce\\Caches\\OrderCountCache\:\:increment\(\) should return int but returns int\|false\.$#'
-			identifier: return.type
-			count: 1
-			path: src/Caches/OrderCountCache.php
-
 		-
 			message: '#^Method Automattic\\WooCommerce\\Caches\\OrderCountCacheService\:\:init\(\) has no return type specified\.$#'
 			identifier: missingType.return
 			count: 1
 			path: src/Caches/OrderCountCacheService.php

-		-
-			message: '#^Method Automattic\\WooCommerce\\Caches\\OrderCountCacheService\:\:unschedule_background_actions\(\) has no return type specified\.$#'
-			identifier: missingType.return
-			count: 1
-			path: src/Caches/OrderCountCacheService.php
-
-		-
-			message: '#^Method Automattic\\WooCommerce\\Caches\\OrderCountCacheService\:\:update_on_new_order\(\) has no return type specified\.$#'
-			identifier: missingType.return
-			count: 1
-			path: src/Caches/OrderCountCacheService.php
-
-		-
-			message: '#^Method Automattic\\WooCommerce\\Caches\\OrderCountCacheService\:\:update_on_order_deleted\(\) has no return type specified\.$#'
-			identifier: missingType.return
-			count: 1
-			path: src/Caches/OrderCountCacheService.php
-
-		-
-			message: '#^Method Automattic\\WooCommerce\\Caches\\OrderCountCacheService\:\:update_on_order_status_changed\(\) has no return type specified\.$#'
-			identifier: missingType.return
-			count: 1
-			path: src/Caches/OrderCountCacheService.php
-
-		-
-			message: '#^Method Automattic\\WooCommerce\\Caches\\OrderCountCacheService\:\:update_on_order_trashed\(\) has no return type specified\.$#'
-			identifier: missingType.return
-			count: 1
-			path: src/Caches/OrderCountCacheService.php
-
 		-
 			message: '#^Method Automattic\\WooCommerce\\Caching\\ObjectCache\:\:get_id_from_object_if_null\(\) never returns null so it can be removed from the return type\.$#'
 			identifier: return.unusedType
diff --git a/plugins/woocommerce/src/Caches/OrderCountCache.php b/plugins/woocommerce/src/Caches/OrderCountCache.php
index 23209e205a6..f8cfe41c116 100644
--- a/plugins/woocommerce/src/Caches/OrderCountCache.php
+++ b/plugins/woocommerce/src/Caches/OrderCountCache.php
@@ -4,10 +4,7 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\Caches;

-use Automattic\WooCommerce\Caching\ObjectCache;
 use Automattic\WooCommerce\Enums\OrderStatus;
-use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
-use Automattic\WooCommerce\Utilities\OrderUtil;

 /**
  * A class to cache counts for various order statuses.
@@ -89,6 +86,7 @@ class OrderCountCache {
 	 *
 	 * @param string $order_type The type of order.
 	 * @param string $order_status The status of the order.
+	 *
 	 * @return string The cache key.
 	 */
 	private function get_cache_key( $order_type, $order_status ) {
@@ -96,7 +94,7 @@ class OrderCountCache {
 	}

 	/**
-	 * Get the cache key saved statuses of the given order type.
+	 * Get the cache key for saved statuses of the given order type.
 	 *
 	 * @param string $order_type The type of order.
 	 *
@@ -111,6 +109,7 @@ class OrderCountCache {
 	 *
 	 * @param string $order_type The type of order.
 	 * @param string $order_status The status of the order.
+	 *
 	 * @return bool True if the cache has a value, false otherwise.
 	 */
 	public function is_cached( $order_type, $order_status ) {
@@ -124,6 +123,7 @@ class OrderCountCache {
 	 * @param string $order_type The type of order.
 	 * @param string $order_status The status slug of the order.
 	 * @param int $value The value to set.
+	 *
 	 * @return bool True if the value was set, false otherwise.
 	 */
 	public function set( $order_type, $order_status, int $value ): bool {
@@ -136,11 +136,10 @@ class OrderCountCache {
 	/**
 	 * Set the cache count value for multiple statuses at once.
 	 *
-	 * @param string $order_type The order type being set.
-	 * @param array  $counts     Normalized counts keyed by status slug
-	 *                           (e.g. [ 'wc-processing' => 10, 'wc-pending' => 5 ]).
+	 * @param string            $order_type The order type being set.
+	 * @param array<string,int> $counts     Normalized counts keyed by status slug (e.g. [ 'wc-processing' => 10, 'wc-pending' => 5 ]).
 	 *
-	 * @return array|bool[]      Success map from wp_cache_set_multiple().
+	 * @return array<string,bool> Success map from wp_cache_set_multiple().
 	 */
 	public function set_multiple( string $order_type, array $counts ) {
 		if ( empty( $counts ) ) {
@@ -162,8 +161,9 @@ class OrderCountCache {
 	 * Get the cache value for a given order type and set of statuses.
 	 *
 	 * @param string   $order_type     The type of order.
-	 * @param string[] $order_statuses The statuses of the order.
-	 * @return null|array<string, int> The cache value.
+	 * @param string[] $order_statuses The statuses to retrieve.
+	 *
+	 * @return array<string,int>|null The cache value.
 	 */
 	public function get( $order_type, $order_statuses = array() ) {
 		$order_type = (string) $order_type;
@@ -174,33 +174,36 @@ class OrderCountCache {
 			}
 		}

-		$cache_keys = array_map( function( $order_statuses ) use ( $order_type ) {
-			return $this->get_cache_key( $order_type, $order_statuses );
-		}, $order_statuses );
+		$cache_keys = array_map(
+			fn( $order_statuses ) => $this->get_cache_key( $order_type, $order_statuses ),
+			$order_statuses
+		);

 		$cache_values  = wp_cache_get_multiple( $cache_keys );
 		$status_values = array();

+		$cache_key_prefix = $this->get_cache_key( $order_type, '' );
 		foreach ( $cache_values as $key => $value ) {
 			// Return null for the entire cache if any of the requested statuses are not found because they fell out of cache.
-			if ( $value === false ) {
+			if ( false === $value ) {
 				return null;
 			}

-			$order_status                   = str_replace( $this->get_cache_key( $order_type, '' ), '', $key );
-			$status_values[ $order_status ] = $value;
+			$status                   = substr( $key, strlen( $cache_key_prefix ) );
+			$status_values[ $status ] = $value;
 		}

 		return $status_values;
 	}

 	/**
-	 * Increment the cache value for a given order status.
+	 * Increment the cache value for a given order type and status.
 	 *
-	 * @param string $order_type The type of order.
+	 * @param string $order_type   The type of order.
 	 * @param string $order_status The status of the order.
-	 * @param int $offset The amount to increment by.
-	 * @return int The new value of the cache.
+	 * @param int    $offset       The amount to increment by.
+	 *
+	 * @return int|false The new value of the cache.
 	 */
 	public function increment( $order_type, $order_status, $offset = 1 ) {
 		$cache_key = $this->get_cache_key( $order_type, $order_status );
@@ -208,12 +211,13 @@ class OrderCountCache {
 	}

 	/**
-	 * Decrement the cache value for a given order status.
+	 * Decrement the cache value for a given order type and status.
 	 *
-	 * @param string $order_type The type of order.
+	 * @param string $order_type   The type of order.
 	 * @param string $order_status The status of the order.
-	 * @param int $offset The amount to decrement by.
-	 * @return int The new value of the cache.
+	 * @param int    $offset       The amount to decrement by.
+	 *
+	 * @return int|false The new value of the cache.
 	 */
 	public function decrement( $order_type, $order_status, $offset = 1 ) {
 		$cache_key = $this->get_cache_key( $order_type, $order_status );
@@ -223,8 +227,9 @@ class OrderCountCache {
 	/**
 	 * Flush the cache for a given order type and statuses.
 	 *
-	 * @param string $order_type The type of order.
-	 * @param string[] $order_statuses The statuses of the order.
+	 * @param string   $order_type     The type of order.
+	 * @param string[] $order_statuses The statuses to flush. Flushes all known statuses if empty.
+	 *
 	 * @return void
 	 */
 	public function flush( $order_type = 'shop_order', $order_statuses = array() ) {
@@ -235,9 +240,10 @@ class OrderCountCache {
 			$flush_saved_statuses = true;
 		}

-		$cache_keys = array_map( function( $order_statuses ) use ( $order_type ) {
-			return $this->get_cache_key( $order_type, $order_statuses );
-		}, $order_statuses );
+		$cache_keys = array_map(
+			fn( $order_statuses ) => $this->get_cache_key( $order_type, $order_statuses ),
+			$order_statuses
+		);

 		if ( $flush_saved_statuses ) {
 			// If all statuses are being flushed, go ahead and flush the status list so any permanently removed statuses are cleared out.
diff --git a/plugins/woocommerce/src/Caches/OrderCountCacheService.php b/plugins/woocommerce/src/Caches/OrderCountCacheService.php
index 08786b6e8ad..bcf6f787d16 100644
--- a/plugins/woocommerce/src/Caches/OrderCountCacheService.php
+++ b/plugins/woocommerce/src/Caches/OrderCountCacheService.php
@@ -15,7 +15,7 @@ use Automattic\WooCommerce\Utilities\OrderUtil;
  */
 class OrderCountCacheService {

-	const BACKGROUND_EVENT_HOOK = 'woocommerce_refresh_order_count_cache';
+	public const BACKGROUND_EVENT_HOOK = 'woocommerce_refresh_order_count_cache';

 	/**
 	 * OrderCountCache instance.
@@ -26,15 +26,17 @@ class OrderCountCacheService {

 	/**
 	 * Array of order ids with their last transitioned status as key value pairs.
+	 * Guarantees idempotency for order status transitions when multiple hooks fire for the same order.
 	 *
-	 * @var array
+	 * @var array<int,string>
 	 */
 	private $order_statuses = array();

 	/**
 	 * Array of order ids with their initial status as key value pairs.
+	 * Guarantees idempotency for order status transitions when multiple hooks fire for the same order.
 	 *
-	 * @var array
+	 * @var array<int,string>
 	 */
 	private $initial_order_statuses = array();

@@ -64,6 +66,7 @@ class OrderCountCacheService {
 	 * @deprecated 10.7.0 Was used for handling `woocommerce_refresh_order_count_cache` actions.
 	 *
 	 * @param string $order_type The order type.
+	 *
 	 * @return void
 	 */
 	public function refresh_cache( $order_type ) {
@@ -79,6 +82,7 @@ class OrderCountCacheService {
 	 * @since 10.7.0
 	 *
 	 * @param string $order_type The order type.
+	 *
 	 * @return void
 	 */
 	public function prime_cache_if_cold( $order_type ) {
@@ -108,6 +112,8 @@ class OrderCountCacheService {
 	 *
 	 * @since 10.0.0
 	 * @internal
+	 *
+	 * @return void
 	 */
 	public function unschedule_background_actions() {
 		WC()->queue()->cancel_all( self::BACKGROUND_EVENT_HOOK );
@@ -117,7 +123,9 @@ class OrderCountCacheService {
 	 * Update the cache when a new order is made.
 	 *
 	 * @param int      $order_id Order id.
-	 * @param WC_Order $order The order.
+	 * @param WC_Order $order    The order.
+	 *
+	 * @return void
 	 */
 	public function update_on_new_order( $order_id, $order ) {
 		$order_type   = $order->get_type();
@@ -146,7 +154,9 @@ class OrderCountCacheService {
 	 * Update the cache when an order is trashed.
 	 *
 	 * @param int      $order_id Order id.
-	 * @param WC_Order $order The order.
+	 * @param WC_Order $order    The order.
+	 *
+	 * @return void
 	 */
 	public function update_on_order_trashed( $order_id, $order ) {
 		$order_type   = $order->get_type();
@@ -163,10 +173,12 @@ class OrderCountCacheService {
 	}

 	/**
-	 * Update the cache when an order is deleted.
+	 * Update the cache when an order is permanently deleted.
 	 *
 	 * @param int      $order_id Order id.
-	 * @param WC_Order $order The order.
+	 * @param WC_Order $order    The order.
+	 *
+	 * @return void
 	 */
 	public function update_on_order_deleted( $order_id, $order ) {
 		$order_type   = $order->get_type();
@@ -180,12 +192,14 @@ class OrderCountCacheService {
 	}

 	/**
-	 * Update the cache whenver an order status changes.
+	 * Update the cache whenever an order status changes.
 	 *
-	 * @param int      $order_id Order id.
-	 * @param string   $previous_status the old WooCommerce order status.
-	 * @param string   $next_status the new WooCommerce order status.
-	 * @param WC_Order $order The order.
+	 * @param int      $order_id        Order id.
+	 * @param string   $previous_status The old WooCommerce order status.
+	 * @param string   $next_status     The new WooCommerce order status.
+	 * @param WC_Order $order           The order.
+	 *
+	 * @return void
 	 */
 	public function update_on_order_status_changed( $order_id, $previous_status, $next_status, $order ) {
 		$order_type = $order->get_type();
@@ -203,7 +217,7 @@ class OrderCountCacheService {
 		}

 		$this->order_statuses[ $order_id ] = $next_status;
-		$was_decremented                   = $this->order_count_cache->decrement( $order_type, $this->get_prefixed_status( $previous_status ) );
+		$was_decremented                   = false !== $this->order_count_cache->decrement( $order_type, $this->get_prefixed_status( $previous_status ) );
 		$this->order_count_cache->increment( $order_type, $this->get_prefixed_status( $next_status ) );

 		// Set the initial order status in case this is a new order and the previous status should not be decremented.
diff --git a/plugins/woocommerce/src/Caches/ProductCountCache.php b/plugins/woocommerce/src/Caches/ProductCountCache.php
new file mode 100644
index 00000000000..703acd1bc0f
--- /dev/null
+++ b/plugins/woocommerce/src/Caches/ProductCountCache.php
@@ -0,0 +1,228 @@
+<?php
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Caches;
+
+/**
+ * A class to cache counts for various product statuses.
+ */
+class ProductCountCache {
+
+	/**
+	 * Cache prefix.
+	 *
+	 * @var string
+	 */
+	private string $cache_prefix = 'product-count';
+
+	/**
+	 * Default value for the duration of the objects in the cache, in seconds
+	 * (may not be used depending on the cache engine used WordPress cache implementation).
+	 *
+	 * @var int
+	 */
+	protected int $expiration = DAY_IN_SECONDS;
+
+	/**
+	 * Retrieves the list of known statuses by product type. A cached array of statuses is saved per product type for
+	 * improved backward compatibility with some of the extensions that don't register all statuses they use with
+	 * WooCommerce.
+	 *
+	 * @param string $product_type The post type (e.g. 'product', 'product_variation').
+	 *
+	 * @return string[]
+	 */
+	private function get_saved_statuses_for_type( string $product_type ): array {
+		$statuses = wp_cache_get( $this->get_saved_statuses_cache_key( $product_type ) );
+
+		return is_array( $statuses ) ? $statuses : array();
+	}
+
+	/**
+	 * Adds the given statuses to the cached statuses array for the product type if they are not already stored.
+	 *
+	 * @param string   $product_type     The post type (e.g. 'product', 'product_variation').
+	 * @param string[] $product_statuses One or more statuses to add.
+	 *
+	 * @return void
+	 */
+	private function ensure_statuses_for_type( string $product_type, array $product_statuses ): void {
+		if ( empty( $product_statuses ) ) {
+			return;
+		}
+
+		$existing     = $this->get_saved_statuses_for_type( $product_type );
+		$new_statuses = array_diff( $product_statuses, $existing );
+		if ( empty( $new_statuses ) ) {
+			return;
+		}
+
+		$merged = array_unique( array_merge( $existing, $new_statuses ) );
+		wp_cache_set( $this->get_saved_statuses_cache_key( $product_type ), $merged, '', $this->expiration );
+	}
+
+	/**
+	 * Get the cache key for a given product type and status.
+	 *
+	 * @param string $product_type   The post type (e.g. 'product', 'product_variation').
+	 * @param string $product_status The status of the product.
+	 *
+	 * @return string
+	 */
+	private function get_cache_key( string $product_type, string $product_status ): string {
+		return $this->cache_prefix . '_' . $product_type . '_' . $product_status;
+	}
+
+	/**
+	 * Get the cache key for saved statuses of the given product type.
+	 *
+	 * @param string $product_type The post type (e.g. 'product', 'product_variation').
+	 *
+	 * @return string
+	 */
+	private function get_saved_statuses_cache_key( string $product_type ): string {
+		return $this->cache_prefix . '_' . $product_type . '_statuses';
+	}
+
+	/**
+	 * Check if the cache has a value for a given product type and status.
+	 *
+	 * @param string $product_type   The post type (e.g. 'product', 'product_variation').
+	 * @param string $product_status The status of the product.
+	 *
+	 * @return bool
+	 */
+	public function is_cached( string $product_type, string $product_status ): bool {
+		return false !== wp_cache_get( $this->get_cache_key( $product_type, $product_status ) );
+	}
+
+	/**
+	 * Set the cache value for a given product type and status.
+	 *
+	 * @param string $product_type   The post type (e.g. 'product', 'product_variation').
+	 * @param string $product_status The status slug of the product.
+	 * @param int    $value          The value to set.
+	 *
+	 * @return bool
+	 */
+	public function set( string $product_type, string $product_status, int $value ): bool {
+		$this->ensure_statuses_for_type( $product_type, array( $product_status ) );
+		return wp_cache_set( $this->get_cache_key( $product_type, $product_status ), $value, '', $this->expiration );
+	}
+
+	/**
+	 * Set the cache count value for multiple statuses at once.
+	 *
+	 * @param string            $product_type The post type (e.g. 'product', 'product_variation').
+	 * @param array<string,int> $counts       Counts keyed by status slug (e.g. [ 'publish' => 10, 'draft' => 5 ]).
+	 *
+	 * @return array<string,bool>
+	 */
+	public function set_multiple( string $product_type, array $counts ) {
+		if ( empty( $counts ) ) {
+			return array();
+		}
+
+		$this->ensure_statuses_for_type( $product_type, array_keys( $counts ) );
+
+		$mapped_counts = array();
+		foreach ( $counts as $status => $count ) {
+			$mapped_counts[ $this->get_cache_key( $product_type, $status ) ] = (int) $count;
+		}
+
+		return wp_cache_set_multiple( $mapped_counts, '', $this->expiration );
+	}
+
+	/**
+	 * Get the cache value for a given product type and set of statuses.
+	 *
+	 * @param string   $product_type     The post type (e.g. 'product', 'product_variation').
+	 * @param string[] $product_statuses The statuses to retrieve.
+	 *
+	 * @return array<string,int>|null
+	 */
+	public function get( string $product_type, array $product_statuses = array() ) {
+		if ( empty( $product_statuses ) ) {
+			$product_statuses = $this->get_saved_statuses_for_type( $product_type );
+			if ( empty( $product_statuses ) ) {
+				return null;
+			}
+		}
+
+		$cache_keys = array_map(
+			fn( $product_status ) => $this->get_cache_key( $product_type, $product_status ),
+			$product_statuses
+		);
+
+		$cache_values  = wp_cache_get_multiple( $cache_keys );
+		$status_values = array();
+
+		$cache_key_prefix = $this->get_cache_key( $product_type, '' );
+		foreach ( $cache_values as $key => $value ) {
+			// Return null for the entire cache if any of the requested statuses are not found because they fell out of cache.
+			if ( false === $value ) {
+				return null;
+			}
+
+			$status                   = substr( $key, strlen( $cache_key_prefix ) );
+			$status_values[ $status ] = $value;
+		}
+
+		return $status_values;
+	}
+
+	/**
+	 * Increment the cache value for a given product type and status.
+	 *
+	 * @param string $product_type   The post type (e.g. 'product', 'product_variation').
+	 * @param string $product_status The status of the product.
+	 * @param int    $offset         The amount to increment by.
+	 *
+	 * @return int|false
+	 */
+	public function increment( string $product_type, string $product_status, int $offset = 1 ) {
+		return wp_cache_incr( $this->get_cache_key( $product_type, $product_status ), $offset );
+	}
+
+	/**
+	 * Decrement the cache value for a given product type and status.
+	 *
+	 * @param string $product_type   The post type (e.g. 'product', 'product_variation').
+	 * @param string $product_status The status of the product.
+	 * @param int    $offset         The amount to decrement by.
+	 *
+	 * @return int|false
+	 */
+	public function decrement( string $product_type, string $product_status, int $offset = 1 ) {
+		return wp_cache_decr( $this->get_cache_key( $product_type, $product_status ), $offset );
+	}
+
+	/**
+	 * Flush the cache for a given product type and statuses.
+	 *
+	 * @param string   $product_type     The post type (e.g. 'product', 'product_variation').
+	 * @param string[] $product_statuses The statuses to flush. Flushes all known statuses if empty.
+	 *
+	 * @return void
+	 */
+	public function flush( string $product_type = 'product', array $product_statuses = array() ): void {
+		$flush_saved_statuses = false;
+		if ( empty( $product_statuses ) ) {
+			$product_statuses     = $this->get_saved_statuses_for_type( $product_type );
+			$flush_saved_statuses = true;
+		}
+
+		$cache_keys = array_map(
+			fn ( $product_status ) => $this->get_cache_key( $product_type, $product_status ),
+			$product_statuses
+		);
+
+		if ( $flush_saved_statuses ) {
+			// If all statuses are being flushed, go ahead and flush the status list so any permanently removed statuses are cleared out.
+			$cache_keys[] = $this->get_saved_statuses_cache_key( $product_type );
+		}
+
+		wp_cache_delete_multiple( $cache_keys );
+	}
+}
diff --git a/plugins/woocommerce/src/Caches/ProductCountCacheService.php b/plugins/woocommerce/src/Caches/ProductCountCacheService.php
new file mode 100644
index 00000000000..2b673dbd21b
--- /dev/null
+++ b/plugins/woocommerce/src/Caches/ProductCountCacheService.php
@@ -0,0 +1,216 @@
+<?php
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Caches;
+
+use Automattic\WooCommerce\Internal\Utilities\ProductUtil;
+use WC_Product;
+use WP_Post;
+
+/**
+ * A service class to help with updates to the aggregate product counts cache.
+ *
+ * @internal
+ */
+class ProductCountCacheService {
+
+	public const BACKGROUND_EVENT_HOOK = 'woocommerce_refresh_product_count_cache';
+
+	/**
+	 * ProductCountCache instance.
+	 *
+	 * @var ProductCountCache
+	 */
+	private ProductCountCache $product_count_cache;
+
+	/**
+	 * Array of product IDs with their last transitioned status as key value pairs.
+	 * Guarantees idempotency for product status transitions when multiple hooks fire for the same product.
+	 *
+	 * @var array<int,string>
+	 */
+	private array $product_statuses = array();
+
+	/**
+	 * Array of product IDs with their initial status as key value pairs.
+	 * Guarantees idempotency for product status transitions when multiple hooks fire for the same product.
+	 *
+	 * @var array<int,string>
+	 */
+	private array $initial_product_statuses = array();
+
+	/**
+	 * Set of product IDs currently being created in this request (keyed by ID; detected via old_status='new').
+	 *
+	 * @var array<int,true>
+	 */
+	private array $products_in_creation = array();
+
+	/**
+	 * Class initialization, invoked by the DI container.
+	 *
+	 * @internal
+	 */
+	final public function init(): void {
+		$this->product_count_cache = new ProductCountCache();
+
+		add_action( 'action_scheduler_ensure_recurring_actions', array( $this, 'schedule_background_actions' ) );
+		add_action( self::BACKGROUND_EVENT_HOOK, array( $this, 'prime_cache_if_cold' ) );
+		if ( defined( 'WC_PLUGIN_BASENAME' ) ) {
+			add_action( 'deactivate_' . WC_PLUGIN_BASENAME, array( $this, 'unschedule_background_actions' ) );
+		}
+
+		// transition_post_status owns all mid-lifecycle status changes; woocommerce_new_product corrects for creation-time
+		// ephemeral transitions before the final status is committed; before_delete_post closes the lifecycle.
+		add_action( 'woocommerce_new_product', array( $this, 'update_on_new_product' ), 10, 2 );
+		add_action( 'transition_post_status', array( $this, 'update_on_product_status_changed' ), 10, 3 );
+		add_action( 'before_delete_post', array( $this, 'update_on_product_deleted' ), 10, 2 );
+	}
+
+	/**
+	 * Primes the product count cache for a given post type when it is cold.
+	 *
+	 * @param string $product_type The product post type.
+	 * @return void
+	 */
+	public function prime_cache_if_cold( string $product_type = 'product' ): void {
+		// Cache warm-up is only effective when an object cache plugin is active, and the cache entry is missing.
+		if ( wp_using_ext_object_cache() && null === $this->product_count_cache->get( $product_type ) ) {
+			$this->product_count_cache->flush( $product_type );
+			wc_get_container()->get( ProductUtil::class )->get_counts_for_type( $product_type );
+		}
+	}
+
+	/**
+	 * Register background caching for each product type.
+	 *
+	 * @return void
+	 */
+	public function schedule_background_actions(): void {
+		$frequency = HOUR_IN_SECONDS * 12;
+		$timestamp = time() + $frequency;
+		as_schedule_recurring_action( $timestamp, $frequency, self::BACKGROUND_EVENT_HOOK, array( 'product' ), 'count', true );
+	}
+
+	/**
+	 * Unschedules background actions.
+	 *
+	 * @return void
+	 */
+	public function unschedule_background_actions(): void {
+		WC()->queue()->cancel_all( self::BACKGROUND_EVENT_HOOK );
+	}
+
+	/**
+	 * Update the cache when a new product is created.
+	 *
+	 * @param int        $product_id Product ID.
+	 * @param WC_Product $product    The product.
+	 * @return void
+	 */
+	public function update_on_new_product( int $product_id, WC_Product $product ): void {
+		// transition_post_status already counted this product — reverse any errant decrement from a cold step 1 and stop.
+		// In-memory status may diverge from DB after a mid-creation wp_update_post; do not increment here.
+		if ( isset( $this->product_statuses[ $product_id ] ) ) {
+			$this->maybe_restore_initial_status_count( $product_id );
+			unset( $this->products_in_creation[ $product_id ] );
+			return;
+		}
+
+		// Cache was cold throughout creation — transition_post_status never fired; use in-memory status as the sole count.
+		$product_status = $product->get_status();
+		if ( $this->product_count_cache->is_cached( 'product', $product_status ) ) {
+			$this->product_statuses[ $product_id ] = $product_status;
+			$this->product_count_cache->increment( 'product', $product_status );
+		}
+		unset( $this->products_in_creation[ $product_id ] );
+	}
+
+	/**
+	 * Update the cache whenever a product status changes.
+	 *
+	 * @param string  $new_status The new post status.
+	 * @param string  $old_status The previous post status.
+	 * @param WP_Post $post       The post object.
+	 *
+	 * @return void
+	 */
+	public function update_on_product_status_changed( string $new_status, string $old_status, WP_Post $post ): void {
+		if ( 'product' !== $post->post_type ) {
+			return;
+		}
+
+		$product_id = $post->ID;
+
+		// WordPress uses 'new' as old_status exclusively on the first transition_post_status of a newly inserted post.
+		if ( 'new' === $old_status ) {
+			$this->products_in_creation[ $product_id ] = true;
+		}
+
+		$is_new_cached = $this->product_count_cache->is_cached( 'product', $new_status );
+		$is_old_cached = $this->product_count_cache->is_cached( 'product', $old_status );
+		if ( ! $is_new_cached && ! $is_old_cached ) {
+			return;
+		}
+
+		// If the status count has already been incremented for this product, skip.
+		if ( ( $this->product_statuses[ $product_id ] ?? null ) === $new_status ) {
+			return;
+		}
+
+		$previously_tracked                    = isset( $this->product_statuses[ $product_id ] );
+		$this->product_statuses[ $product_id ] = $new_status;
+		$was_decremented                       = $is_old_cached && false !== $this->product_count_cache->decrement( 'product', $old_status );
+		if ( $is_new_cached ) {
+			$this->product_count_cache->increment( 'product', $new_status );
+		}
+
+		// Record old status for creation-time correction only; existing-product decrements are correct and must not be reversed.
+		// If $previously_tracked, an earlier transition already counted the old status — decrement is legitimate.
+		if ( ! $previously_tracked && $was_decremented && ! isset( $this->initial_product_statuses[ $product_id ] ) && isset( $this->products_in_creation[ $product_id ] ) ) {
+			$this->initial_product_statuses[ $product_id ] = $old_status;
+		} elseif ( ( $this->initial_product_statuses[ $product_id ] ?? null ) === $new_status ) {
+			unset( $this->initial_product_statuses[ $product_id ] );
+		}
+	}
+
+	/**
+	 * Update the cache when a product is permanently deleted.
+	 *
+	 * @param int     $post_id Post ID.
+	 * @param WP_Post $post    The post object.
+	 *
+	 * @return void
+	 */
+	public function update_on_product_deleted( int $post_id, WP_Post $post ): void {
+		if ( 'product' === $post->post_type ) {
+			// Reverse any errant decrement from a mid-creation status transition that update_on_new_product will never get to correct.
+			$this->maybe_restore_initial_status_count( $post_id );
+
+			$product_status = $post->post_status;
+			if ( $this->product_count_cache->is_cached( 'product', $product_status ) ) {
+				$this->product_count_cache->decrement( 'product', $product_status );
+			}
+
+			unset( $this->product_statuses[ $post_id ], $this->products_in_creation[ $post_id ] );
+		}
+	}
+
+	/**
+	 * Reverses an errant decrement recorded in initial_product_statuses for a given product, if any.
+	 *
+	 * @param int $product_id Product ID.
+	 *
+	 * @return void
+	 */
+	private function maybe_restore_initial_status_count( int $product_id ): void {
+		if ( isset( $this->initial_product_statuses[ $product_id ] ) ) {
+			$initial_status = $this->initial_product_statuses[ $product_id ];
+			unset( $this->initial_product_statuses[ $product_id ] );
+			if ( $this->product_count_cache->is_cached( 'product', $initial_status ) ) {
+				$this->product_count_cache->increment( 'product', $initial_status );
+			}
+		}
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Utilities/ProductUtil.php b/plugins/woocommerce/src/Internal/Utilities/ProductUtil.php
index 3edf0a7bc7e..545d7aa3fa7 100644
--- a/plugins/woocommerce/src/Internal/Utilities/ProductUtil.php
+++ b/plugins/woocommerce/src/Internal/Utilities/ProductUtil.php
@@ -1,9 +1,11 @@
 <?php

-declare(strict_types=1);
+declare( strict_types=1 );

 namespace Automattic\WooCommerce\Internal\Utilities;

+use Automattic\WooCommerce\Caches\ProductCountCache;
+
 /**
  * Class with general utility methods related to products.
  */
@@ -129,7 +131,7 @@ class ProductUtil {
 	}

 	/**
-	 * Counts per-status number of products.
+	 * Counts per-status number of products of a given post type.
 	 *
 	 * @since 11.0.0
 	 *
@@ -137,7 +139,18 @@ class ProductUtil {
 	 * @return array<string,int>
 	 */
 	public function get_counts_for_type( string $post_type ): array {
-		// Performance note: integration point for upcoming persistent counters solution.
-		return array_map( 'intval', (array) wp_count_posts( $post_type ) );
+		$product_count_cache = wc_get_container()->get( ProductCountCache::class );
+		$count_per_status    = $product_count_cache->get( $post_type );
+
+		if ( null === $count_per_status ) {
+			$count_per_status = array_merge(
+				array_fill_keys( array_keys( get_post_stati() ), 0 ),
+				(array) wp_count_posts( $post_type )
+			);
+
+			$product_count_cache->set_multiple( $post_type, $count_per_status );
+		}
+
+		return array_map( 'intval', $count_per_status );
 	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-install-test.php b/plugins/woocommerce/tests/php/includes/class-wc-install-test.php
index d005bd6009f..573143790c8 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-install-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-install-test.php
@@ -2,6 +2,8 @@
 declare( strict_types = 1 );

 use Automattic\WooCommerce\Admin\Notes\Note;
+use Automattic\WooCommerce\Caches\ProductCountCache;
+use Automattic\WooCommerce\Enums\ProductStatus;

 /**
  * Class WC_Install_Test.
@@ -209,7 +211,7 @@ class WC_Install_Test extends \WC_Unit_Test_Case {

 		$supply_post_count = function () use ( &$post_count, &$counted_posts ) {
 			$counted_posts = true;
-			return $post_count;
+			return (object) array( ProductStatus::PUBLISH => $post_count );
 		};

 		$supply_coming_soon = function () use ( &$coming_soon ) {
@@ -250,6 +252,7 @@ class WC_Install_Test extends \WC_Unit_Test_Case {
 		$this->assertFalse( WC_Install::is_new_install(), 'We are not in a new install if the WC version is set and we have one or more products (even if the shop ID is not set).' );

 		$post_count = 0;
+		( new ProductCountCache() )->flush( 'product' );
 		$this->assertTrue( WC_Install::is_new_install(), 'We are in a new install if the WC version is set but the shop ID is not set and we do not have any products.' );

 		$counted_posts = false;
@@ -330,7 +333,7 @@ class WC_Install_Test extends \WC_Unit_Test_Case {
 	 */
 	public function test_order_stats_schema_includes_fulfillment_status_for_new_install_with_fulfillments_feature_enabled(): void {
 		// Mock is_new_install to return true.
-		$version = null;
+		$version = false;
 		$shop_id = null;

 		$supply_version = function () use ( &$version ) {
@@ -380,7 +383,7 @@ class WC_Install_Test extends \WC_Unit_Test_Case {
 		delete_option( 'woocommerce_feature_fulfillments_enabled' );

 		// Mock is_new_install to return true.
-		$version = null;
+		$version = false;
 		$shop_id = null;

 		$supply_version = function () use ( &$version ) {
diff --git a/plugins/woocommerce/tests/php/src/Caching/ProductCountCacheServiceTest.php b/plugins/woocommerce/tests/php/src/Caching/ProductCountCacheServiceTest.php
new file mode 100644
index 00000000000..85aeb04d176
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Caching/ProductCountCacheServiceTest.php
@@ -0,0 +1,339 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Caching;
+
+use WC_Helper_Product;
+use WC_Product_Simple;
+use Automattic\WooCommerce\Caches\ProductCountCache;
+use Automattic\WooCommerce\Caches\ProductCountCacheService;
+use Automattic\WooCommerce\Enums\ProductStatus;
+use Automattic\WooCommerce\Internal\Utilities\ProductUtil;
+
+/**
+ * Class ProductCountCacheServiceTest.
+ */
+final class ProductCountCacheServiceTest extends \WC_Unit_Test_Case {
+
+	/**
+	 * ProductCountCache instance.
+	 *
+	 * @var ProductCountCache
+	 */
+	private ProductCountCache $product_cache;
+
+	/**
+	 * ProductUtil instance.
+	 *
+	 * @var ProductUtil
+	 */
+	private ProductUtil $product_util;
+
+	/**
+	 * Setup test.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->product_cache = new ProductCountCache();
+		$this->product_cache->flush();
+		$this->product_util = wc_get_container()->get( ProductUtil::class );
+	}
+
+	/**
+	 * @testdox Count is incremented when a new product is created.
+	 */
+	public function test_count_incremented_on_product_create(): void {
+		$initial_count = $this->product_util->get_counts_for_type( 'product' )[ ProductStatus::PUBLISH ];
+
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_status( ProductStatus::PUBLISH );
+		$product->save();
+
+		$counts = $this->product_util->get_counts_for_type( 'product' );
+
+		$this->assertSame( $initial_count + 1, $counts[ ProductStatus::PUBLISH ] );
+
+		$product->delete( true );
+	}
+
+	/**
+	 * @testdox Count is decremented when a product is permanently deleted.
+	 */
+	public function test_count_decremented_on_product_delete(): void {
+		$initial_count = $this->product_util->get_counts_for_type( 'product' );
+
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_status( ProductStatus::PUBLISH );
+		$product->save();
+		$product->delete( true );
+
+		$counts = $this->product_util->get_counts_for_type( 'product' );
+
+		$this->assertSame( $initial_count[ ProductStatus::PUBLISH ], $counts[ ProductStatus::PUBLISH ] );
+	}
+
+	/**
+	 * @testdox Source and destination status counts are updated when a product status changes.
+	 */
+	public function test_count_on_product_status_change(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_status( ProductStatus::PUBLISH );
+		$product->save();
+
+		$initial_count = $this->product_util->get_counts_for_type( 'product' );
+
+		$product->set_status( ProductStatus::DRAFT );
+		$product->save();
+
+		$count = $this->product_util->get_counts_for_type( 'product' );
+
+		$this->assertSame( $initial_count[ ProductStatus::PUBLISH ] - 1, $count[ ProductStatus::PUBLISH ] );
+		$this->assertSame( $initial_count[ ProductStatus::DRAFT ] + 1, $count[ ProductStatus::DRAFT ] );
+
+		$product->delete( true );
+	}
+
+	/**
+	 * @testdox Only the final status is incremented when a new product is saved with a non-default initial status.
+	 */
+	public function test_count_on_new_product_with_initial_status(): void {
+		$initial_count = $this->product_util->get_counts_for_type( 'product' );
+
+		$product = WC_Helper_Product::create_simple_product( true, array( 'status' => ProductStatus::PENDING ) );
+
+		$count = $this->product_util->get_counts_for_type( 'product' );
+
+		$this->assertSame( $initial_count[ ProductStatus::PUBLISH ], $count[ ProductStatus::PUBLISH ] );
+		$this->assertSame( $initial_count[ ProductStatus::PENDING ] + 1, $count[ ProductStatus::PENDING ] );
+
+		$product->delete( true );
+	}
+
+	/**
+	 * @testdox Draft count is incremented correctly when transition_post_status fires during new product creation.
+	 */
+	public function test_count_on_new_product_with_status_change(): void {
+		$initial_count = $this->product_util->get_counts_for_type( 'product' );
+
+		$product = new WC_Product_Simple();
+		$product->set_status( ProductStatus::DRAFT );
+		$product->save();
+
+		$count = $this->product_util->get_counts_for_type( 'product' );
+
+		$this->assertSame( $initial_count[ ProductStatus::DRAFT ] + 1, $count[ ProductStatus::DRAFT ] );
+		$this->assertSame( $initial_count[ ProductStatus::PUBLISH ], $count[ ProductStatus::PUBLISH ] );
+
+		$product->delete( true );
+	}
+
+	/**
+	 * @testdox Only the final status is incremented when a product cycles through multiple statuses before being saved.
+	 */
+	public function test_count_on_multiple_status_changes(): void {
+		$initial_count = $this->product_util->get_counts_for_type( 'product' );
+
+		$product = new WC_Product_Simple();
+		$product->set_status( ProductStatus::PUBLISH );
+		$product->set_status( ProductStatus::PENDING );
+		$product->save();
+
+		$count = $this->product_util->get_counts_for_type( 'product' );
+
+		$this->assertSame( $initial_count[ ProductStatus::PUBLISH ], $count[ ProductStatus::PUBLISH ] );
+		$this->assertSame( $initial_count[ ProductStatus::DRAFT ], $count[ ProductStatus::DRAFT ] );
+		$this->assertSame( $initial_count[ ProductStatus::PENDING ] + 1, $count[ ProductStatus::PENDING ] );
+
+		$product->delete( true );
+	}
+
+	/**
+	 * @testdox Counts are not corrupted when a product cycles through publish and back to draft during creation.
+	 */
+	public function test_count_not_corrupted_on_status_cycle_during_creation(): void {
+		$initial_count = $this->product_util->get_counts_for_type( 'product' );
+
+		$hook = null;
+		$hook = static function ( int $post_id ) use ( &$hook ): void {
+			remove_action( 'save_post_product', $hook, 1 );
+			wp_update_post(
+				array(
+					'ID'          => $post_id,
+					'post_status' => ProductStatus::PUBLISH,
+				)
+			);
+			wp_update_post(
+				array(
+					'ID'          => $post_id,
+					'post_status' => ProductStatus::DRAFT,
+				)
+			);
+		};
+		add_action( 'save_post_product', $hook, 1 );
+
+		$product = new WC_Product_Simple();
+		$product->set_status( ProductStatus::DRAFT );
+		$product->save();
+
+		$count = $this->product_util->get_counts_for_type( 'product' );
+
+		$this->assertSame( $initial_count[ ProductStatus::DRAFT ] + 1, $count[ ProductStatus::DRAFT ] );
+		$this->assertSame( $initial_count[ ProductStatus::PUBLISH ], $count[ ProductStatus::PUBLISH ] );
+
+		$product->delete( true );
+	}
+
+	/**
+	 * @testdox Source status count is decremented correctly when only the source slot is warm and the destination slot is cold.
+	 */
+	public function test_count_decremented_when_only_source_status_is_cached(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_status( ProductStatus::DRAFT );
+		$product->save();
+
+		// Warm all status slots, then flush only the target to create a partially cold cache.
+		$this->product_util->get_counts_for_type( 'product' );
+		$draft_before = $this->product_cache->get( 'product', array( ProductStatus::DRAFT ) )[ ProductStatus::DRAFT ];
+
+		$this->product_cache->flush( 'product', array( ProductStatus::PUBLISH ) );
+
+		$product->set_status( ProductStatus::PUBLISH );
+		$product->save();
+
+		// The draft slot should be decremented even though the publish slot was cold.
+		$draft_after = $this->product_cache->get( 'product', array( ProductStatus::DRAFT ) )[ ProductStatus::DRAFT ];
+		$this->assertSame( $draft_before - 1, $draft_after );
+
+		$product->delete( true );
+	}
+
+	/**
+	 * @testdox Final status is not double-incremented when a plugin permanently changes product status during creation.
+	 */
+	public function test_count_not_double_incremented_on_new_product_with_mid_creation_status_change(): void {
+		// Warm all status slots and record the publish count before the test.
+		$this->product_util->get_counts_for_type( 'product' );
+		$publish_before = $this->product_cache->get( 'product', array( ProductStatus::PUBLISH ) )[ ProductStatus::PUBLISH ];
+
+		$hook = null;
+		$hook = static function ( int $post_id ) use ( &$hook ): void {
+			remove_action( 'save_post_product', $hook, 1 );
+			wp_update_post(
+				array(
+					'ID'          => $post_id,
+					'post_status' => ProductStatus::PUBLISH,
+				)
+			);
+		};
+		add_action( 'save_post_product', $hook, 1 );
+
+		$product = new WC_Product_Simple();
+		$product->set_status( ProductStatus::DRAFT );
+		$product->save();
+
+		// Publish should be exactly +1: the product ended in PUBLISH, draft must be unchanged.
+		$publish_after = $this->product_cache->get( 'product', array( ProductStatus::PUBLISH ) )[ ProductStatus::PUBLISH ];
+		$this->assertSame( $publish_before + 1, $publish_after );
+
+		$product->delete( true );
+	}
+
+	/**
+	 * @testdox Source status count is not corrupted when a plugin permanently changes product status during creation.
+	 */
+	public function test_source_count_not_corrupted_on_new_product_with_mid_creation_status_change(): void {
+		// Warm all status slots and record both counts before the test.
+		$this->product_util->get_counts_for_type( 'product' );
+		$draft_before   = $this->product_cache->get( 'product', array( ProductStatus::DRAFT ) )[ ProductStatus::DRAFT ];
+		$publish_before = $this->product_cache->get( 'product', array( ProductStatus::PUBLISH ) )[ ProductStatus::PUBLISH ];
+
+		$hook = null;
+		$hook = static function ( int $post_id ) use ( &$hook ): void {
+			remove_action( 'save_post_product', $hook, 1 );
+			wp_update_post(
+				array(
+					'ID'          => $post_id,
+					'post_status' => ProductStatus::PUBLISH,
+				)
+			);
+		};
+		add_action( 'save_post_product', $hook, 1 );
+
+		$product = new WC_Product_Simple();
+		$product->set_status( ProductStatus::DRAFT );
+		$product->save();
+
+		// The product ended in PUBLISH, so draft must be unchanged and publish must be exactly +1.
+		$draft_after   = $this->product_cache->get( 'product', array( ProductStatus::DRAFT ) )[ ProductStatus::DRAFT ];
+		$publish_after = $this->product_cache->get( 'product', array( ProductStatus::PUBLISH ) )[ ProductStatus::PUBLISH ];
+		$this->assertSame( $draft_before, $draft_after );
+		$this->assertSame( $publish_before + 1, $publish_after );
+
+		$product->delete( true );
+	}
+
+	/**
+	 * @testdox Background recurring action is scheduled.
+	 */
+	public function test_background_actions_scheduled(): void {
+		$product_count_cache_service = wc_get_container()->get( ProductCountCacheService::class );
+		$product_count_cache_service->schedule_background_actions();
+		$this->assertTrue( as_has_scheduled_action( 'woocommerce_refresh_product_count_cache' ) );
+	}
+
+	/**
+	 * @testdox Cache is populated when it is cold and an external object cache is active.
+	 */
+	public function test_prime_cache_if_cold_when_cache_is_cold(): void {
+		global $_wp_using_ext_object_cache;
+		$_before                    = $_wp_using_ext_object_cache;
+		$_wp_using_ext_object_cache = true; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+
+		$this->product_cache->flush();
+		$this->assertNull( $this->product_cache->get( 'product', array( ProductStatus::PUBLISH ) ) );
+
+		// We expect the cache to be populated with the relevant values.
+		$product_count_cache_service = wc_get_container()->get( ProductCountCacheService::class );
+		$product_count_cache_service->prime_cache_if_cold( 'product' );
+
+		$cached = $this->product_cache->get( 'product', array( ProductStatus::PUBLISH ) );
+		$this->assertNotNull( $cached );
+		$this->assertArrayHasKey( ProductStatus::PUBLISH, $cached );
+
+		$_wp_using_ext_object_cache = $_before; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+	}
+
+	/**
+	 * @testdox Cache values are preserved when the cache is already warm.
+	 */
+	public function test_prime_cache_if_cold_when_cache_is_warm(): void {
+		global $_wp_using_ext_object_cache;
+		$_before                    = $_wp_using_ext_object_cache;
+		$_wp_using_ext_object_cache = true; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+
+		$publish_count = $this->product_util->get_counts_for_type( 'product' )[ ProductStatus::PUBLISH ];
+		$this->product_cache->set( 'product', ProductStatus::PUBLISH, $publish_count + 10 );
+
+		// We expect the cached values to remain same as counting skipped for warm caches.
+		$product_count_cache_service = wc_get_container()->get( ProductCountCacheService::class );
+		$product_count_cache_service->prime_cache_if_cold( 'product' );
+
+		$this->assertSame( $publish_count + 10, $this->product_cache->get( 'product', array( ProductStatus::PUBLISH ) )[ ProductStatus::PUBLISH ] );
+
+		$_wp_using_ext_object_cache = $_before; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+	}
+
+	/**
+	 * @testdox Cache is not populated when no external object cache is active.
+	 */
+	public function test_prime_cache_if_cold_when_object_cache_unavailable(): void {
+		$this->product_cache->flush();
+		$this->assertNull( $this->product_cache->get( 'product', array( ProductStatus::PUBLISH ) ) );
+
+		// We expect the cache to remain unpopulated as object caching is unavailable.
+		$product_count_cache_service = wc_get_container()->get( ProductCountCacheService::class );
+		$product_count_cache_service->prime_cache_if_cold( 'product' );
+
+		$this->assertNull( $this->product_cache->get( 'product', array( ProductStatus::PUBLISH ) ) );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Caching/ProductCountCacheTest.php b/plugins/woocommerce/tests/php/src/Caching/ProductCountCacheTest.php
new file mode 100644
index 00000000000..7761c717018
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Caching/ProductCountCacheTest.php
@@ -0,0 +1,96 @@
+<?php
+declare( strict_types = 1);
+
+namespace Automattic\WooCommerce\Tests\Caching;
+
+use Automattic\WooCommerce\Caches\ProductCountCache;
+use Automattic\WooCommerce\Enums\ProductStatus;
+
+/**
+ * Class ProductCountCacheTest.
+ */
+final class ProductCountCacheTest extends \WC_Unit_Test_Case {
+
+	/**
+	 * ProductCountCache instance.
+	 *
+	 * @var ProductCountCache
+	 */
+	private ProductCountCache $product_cache;
+
+	/**
+	 * Setup test.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->product_cache = new ProductCountCache();
+		$this->product_cache->flush( 'product' );
+	}
+
+	/**
+	 * @testdox Known and third-party statuses can be set and retrieved by product type.
+	 */
+	public function test_cache_product_counts(): void {
+		$unregistered_status = 'third-party-unregistered-status';
+		$counts              = array(
+			ProductStatus::PUBLISH => 5,
+			ProductStatus::DRAFT   => 10,
+			$unregistered_status   => 20,
+		);
+
+		foreach ( $counts as $status => $count ) {
+			$this->product_cache->set( 'product', $status, $count );
+		}
+
+		$this->assertTrue( $this->product_cache->is_cached( 'product', ProductStatus::PUBLISH ) );
+		$this->assertTrue( $this->product_cache->is_cached( 'product', ProductStatus::DRAFT ) );
+		$this->assertTrue( $this->product_cache->is_cached( 'product', $unregistered_status ) );
+
+		$this->assertSame( 5, $this->product_cache->get( 'product', array( ProductStatus::PUBLISH ) )[ ProductStatus::PUBLISH ] );
+		$this->assertSame( 10, $this->product_cache->get( 'product', array( ProductStatus::DRAFT ) )[ ProductStatus::DRAFT ] );
+		$this->assertSame( 20, $this->product_cache->get( 'product', array( $unregistered_status ) )[ $unregistered_status ] );
+
+		// verify when a specific set of statuses isn't requested.
+		$this->assertSame( 5, $this->product_cache->get( 'product' )[ ProductStatus::PUBLISH ] );
+		$this->assertSame( 10, $this->product_cache->get( 'product' )[ ProductStatus::DRAFT ] );
+		$this->assertSame( 20, $this->product_cache->get( 'product' )[ $unregistered_status ] );
+	}
+
+	/**
+	 * @testdox Specific status slots are invalidated when flushed.
+	 */
+	public function test_flush_cache(): void {
+		$this->product_cache->set( 'product', ProductStatus::PUBLISH, 5 );
+		$this->product_cache->set( 'product', ProductStatus::DRAFT, 10 );
+		$this->product_cache->flush( 'product', array( ProductStatus::PUBLISH, ProductStatus::DRAFT ) );
+		$this->assertFalse( $this->product_cache->is_cached( 'product', ProductStatus::PUBLISH ) );
+		$this->assertFalse( $this->product_cache->is_cached( 'product', ProductStatus::DRAFT ) );
+	}
+
+	/**
+	 * @testdox get() returns null when any of the requested status slots has been evicted.
+	 */
+	public function test_get_returns_null_when_any_status_evicted(): void {
+		$this->product_cache->set( 'product', ProductStatus::PUBLISH, 5 );
+		$this->product_cache->set( 'product', ProductStatus::DRAFT, 10 );
+
+		// Evict one slot; the multi-status query must return null so callers fall back to DB.
+		$this->product_cache->flush( 'product', array( ProductStatus::DRAFT ) );
+
+		$this->assertNull( $this->product_cache->get( 'product', array( ProductStatus::PUBLISH, ProductStatus::DRAFT ) ) );
+	}
+
+	/**
+	 * @testdox All registered statuses are returned when no specific statuses are requested.
+	 */
+	public function test_cache_gets_all_statuses_when_no_statuses_are_provided(): void {
+		foreach ( array_keys( get_post_stati() ) as $status ) {
+			$this->product_cache->set( 'product', $status, 5 );
+		}
+
+		$cached = $this->product_cache->get( 'product' );
+		foreach ( ProductStatus::get_all() as $status ) {
+			$this->assertSame( 5, $cached[ $status ] );
+		}
+	}
+}