Try 14 days free
Skip to content

Dynamic WooCommerce facet counts without SQL per click

A filter that shows “Blue (14)” and never updates that number is lying. As soon as the visitor chooses size 42, blue should only count the sneakers that are both blue and size 42. Options that then fall to zero should leave the list. That is called dynamic filtering. Most WooCommerce plugins do that recount with a SQL COUNT per option, via admin-ajax.php. InstantFilter does the same recount in the browser, on the products of the current archive, without those queries.

What are dynamic product filters in WooCommerce?

A static filter is a list locked in when the category loads. Size 36 is on it even when “Men > Sneakers” has no size 36 at all. The visitor clicks, gets an empty grid, and leaves. A dynamic filter recalculates two things after every choice: how many products each remaining option still yields, and whether that option may still be shown.

Those two things are not the same. The count is the number in parentheses, for example (14), in the element .if-filter__count. Hiding is the next step: an option with count 0 goes to display: none, unless the visitor has that option selected themselves. A whole filter block (color, size, brand) only disappears when it has no visible option left and nothing in that block is selected. That way an active choice can always be turned off.

This plays out on the category archive, not in the header search bar. Search and filtering are two jobs; that boundary is covered in the guide search vs filtering on WooCommerce category archives. Dynamic counts belong with filtering: the collection is already fixed, and every click makes the intersection smaller.

Why do facet counts cost so much SQL with AJAX?

A naive implementation does a separate count per option. Twenty sizes, fifteen colors, and ten brands are forty-five SELECT COUNT(*) queries, plus the query that fetches the product grid itself. Every query joins wp_postmeta or wp_term_relationships, boots WordPress via admin-ajax.php, and occupies a PHP worker. That is the 800ms to 2.5 seconds visitors see as a spinner.

It gets more expensive as the choice gets more specific. The first click (“blue”) must recount for every other facet which sizes and brands still have blue products. The second click (“size 42”) does that round again. Plugins such as JetSmartFilters solve that on the server, even when they have an index: the index saves joins, but the roundtrip and the worker remain.

Caching helps poorly here. Every combination of facets is a different query. A category with a handful of attributes has more combinations than a page cache can remember. The archive page itself is cacheable when the recount does not go to the server. How that works with WP Rocket and Redis is covered in the guide on filter caching.

How does InstantFilter count options without asking the database?

After hydration the archive sits in the browser: contextItems, the products of this category, tag, or brand. A click filters that set in memory. The function updateFilterCountsFromItems then walks the facet blocks and fills the counts. That is the same pass as the grid itself, not a second request. Interactions land in 1.5ms to 5ms. During that click no SQL goes to MySQL.

There are two ways to link an option to products, depending on the codebook:

  • Inverted index. When filtering_mode is not set to fx and the codebook has an inverted_index for that property, the engine pulls the item IDs for that option from the index and counts how many of them sit in the current set.
  • Property codes on the item. Otherwise the engine compares the option code with item.props. For a hierarchical taxonomy, child terms count toward the parent: “Shoes” also counts products that only hang under “Sneakers”.

Price and number filters skip this loop. They have no list with (14). Their minimum and maximum shift with the filtered set, via applyRangeBounds. A price slider that still shows €20–€400 while the chosen color only sits between €80 and €150 is therefore a different mechanism from hiding an empty color.

The index on the server does exist, but not per click. if_filter_counts stores prepared counts per property, value, and context (global, category, tag). That table is filled by the background index. The visitor who clicks size 42 does not read that table again. For catalogs up to tens of thousands of SKUs, that recount still fits in browser memory. Above roughly 100,000 SKUs the codebook itself can become too large; then the trade-off is a server index, not a return to a COUNT per option on the category page.

Why does an empty option disappear, and why does a selected option stay?

An option with count 0 is a dead-end click. InstantFilter sets the label to display: none. The visitor no longer sees “Size 36” when there is no size 36 in the current intersection. That is the automatic hiding of empty filters: not a separate plugin setting with that name, but the behavior of the count update itself.

There is one exception, and it is deliberate. If the option is on (checkbox or radio is checked), the label stays visible even if the count would fall to 0. Otherwise the control that undoes the choice disappears, and the visitor is stuck in a filter they can no longer see. The same applies to an option whose mapping to the codebook is unreliable: a selected option stays; an unknown unselected option is not casually renamed.

Stock is not a separate hide switch here. Products carry stock_status (instock or outofstock) in the index. A color that only remains on sold-out items falls to count 0 as soon as those items are not in the filtered set, and disappears with them. As long as sold-out items are still in the set, the color counts. The card shows the stock status; the facet list follows the set, not a hidden “hide out of stock” checkbox next to the count.

How does self-exclude work, so you can still switch within one facet?

If the count included the facet’s own selection, every other color would drop to 0 as soon as you turn on “blue”. The list would collapse to one option. InstantFilter therefore counts the way a large department store does: for the “color” facet the engine counts products that match all other filters, without the color choice itself. Size 42 still bounds the set. Blue, black, and gray each show how many size-42 products still have that color.

In code that is called self-exclude. getSubsetExcluding copies the active filters, removes the key of this facet, and filters contextItems with the rest. Facets with nothing selected use the set the grid already has. There is a cache on that subset, so two options in the same facet do not compute the same intersection twice.

The effect for the visitor: they can switch from blue to black without first turning blue off, and they see before the click whether black still yields anything. The effect for the server: that subset exists only in memory. There is no second query “count black within size 42, but ignore color”.

A sneaker shop makes that concrete. Suppose the “Men” category contains 240 products. With no selection, size 42 shows a count of 60 and blue a count of 40. The visitor clicks size 42. The engine filters those 240 down to those 60. Then color counts without a color choice and with the size: blue drops from 40 to 9, because only 9 of the blue pairs are also size 42. Gray drops to 0 and disappears. Size 42 itself stays, because that option is selected.

Then they click blue. The grid shows 9 products. The count for color now ignores the color choice and keeps size 42. Black shows how many black pairs remain in size 42, even though blue is on. The count for size ignores the size choice and keeps blue, so size 43 shows how many blue pairs that size has. Two subsets, both from the same 240 items in memory, neither a query.

If they turn size 42 off, the same function recalculates the set on blue alone. Sizes that do exist for blue and did not for size 42 return to the screen. The block was not removed from the page. Only visibility toggled on and off. That is why an empty list after one click is no reason to fetch the filters from the server again.

Multiple values in the same facet work the same way. If the visitor checks blue and black, self-exclude removes the whole “color” key from that facet’s count, not only blue. The other colors are counted as if no color were chosen yet, while size and brand still apply. That way they can check a third color and see before that click whether it still adds products. The grid filter itself keeps blue and black on: the count and the results are deliberately not the same set.

That difference is why a count can be higher than the number of cards on the screen. The cards follow all chosen filters, including the color that is on. The counts inside the color block follow all filters except color. A visitor who reads that as “the count is lying” is comparing two questions. The card question is “what do I have left”. The count question is “what do I have left if I release this color”.

When does a whole filter block disappear?

updateFacetContainerVisibility looks at the block itself after the options. Every .if-filter element with a data-prop-key is evaluated. Price and number skip this function; they manage their visibility via the new min and max bounds.

If there is an active choice in the block, the block stays open. The visitor must be able to clear that choice. If there is no choice, and every option is hidden, the whole block goes to display: none. When an option returns later—because the visitor turns another filter off—the same function brings the block back. Nothing is thrown out of the DOM. It is visibility, not removal, so the next count can restore everything.

That is the difference from a classic W3 script that hides cards and leaves the filter list alone. DOM show/hide on product cards does not recalculate counts. Why that breaks on a shop is covered in the analysis of the W3 filter technique. Dynamic counts are exactly the piece those scripts do not have.

How do static lists, AJAX counts, and InstantFilter compare?

Static filter listAJAX count per optionInstantFilter after hydration
Count after a clickStays at the initial valueAgain via SQLAgain in the browser
Option with 0 resultsStays clickableOften grayed or gone, after the roundtripHidden immediately
Selected optionStaysDepends on the pluginStays, even at 0
Other color beside the selected colorShows the old countCOUNT without that color, on the serverSelf-exclude in memory
Empty facet blockStays on the pageVariesGone, until an option returns
SQL per click0, but the list liesOne COUNT per option0
LatencyNo recount800ms – 2,500ms1.5ms – 5ms

How do you set this up on a category archive?

You do not build the count yourself in Elementor or Bricks. The page builder is the shell. The recount lives in the filter engine; the list layout lives in the Layout Builder under InstantFilter → Layouts.

  1. Bound the archive. The listing must respect the WordPress context. Otherwise “blue” counts products from outside the category, and the count lies against the URL.
  2. Place it. In Bricks one of the four native elements, in Elementor the archive shortcode. The facet block is a .if-filter with data-prop-key and data-type.
  3. HTML first. The category is server-side rendered. Google sees the products of that archive without clicking a facet. The counts are updated afterward for the visitor.
  4. Verify. Open a category with multiple attributes. Click one color. Sizes that do not occur with it disappear. The other colors stay, with a new count. Turn the color off: the hidden sizes return. No request goes to admin-ajax.php for that recount.

Dynamic counts in the browser when

  • The visitor combines facets: color, size, and brand must show a correct number after every click.
  • Empty options frustrate: a click that opens an empty grid costs conversion. Hiding at 0 prevents that click.
  • The category is large: dozens of COUNT queries per visitor do not scale; browser memory does, until the codebook gets too large.

Do not expect this mechanism when

  • You want a relevance search engine: counts are intersections, not ranking. The header search bar remains a search plugin.
  • You want price as an (n) list: range filters shift their bounds; they do not show a count per euro.
  • The catalog sits well above 100,000 SKUs: then the codebook for the browser becomes the limit, not the counting itself.

Frequently asked questions about dynamic WooCommerce filters

A filter that recalculates the counts after every choice and hides options with zero results. “Blue (14)” becomes “Blue (3)” as soon as another facet makes the set smaller. A static list leaves the old number in place.
Because otherwise you could no longer turn that choice off. InstantFilter hides options with count 0, except the option that is currently selected. The same applies to the whole filter block: as long as something in it is selected, the block stays open.
With a classic AJAX filter, yes: often a COUNT per option, via admin-ajax.php, with joins on wp_postmeta or term tables. After hydration InstantFilter counts in the browser on the products of the current archive. During that click 0 SQL queries go to the database.
Only when those products are not in the filtered set. The index stores stock_status per item. An option drops to 0 and is hidden when no remaining product has that value. There is no separate switch that hides all sold-out options independent of the set.
A facet’s count ignores that facet’s own selection. Size and brand still bound the set. That way you see how many products of that size are still black, and you can switch color without first turning blue off.

Go deeper into filter counts and archives

Counts are a consequence of the architecture. These pages continue on that architecture:

Let facet counts move with you without SQL per click

Try InstantFilter for 14 days. Turn a color on and see whether the sizes match immediately, without a spinner and without a COUNT query.

Ready to make filtering instant?

Download the free 14-day trial of Pro. No credit card required — test immediately.