← Blog
How it works FilterProof

Filtering across 50,000 products without a page reload

“Our store has about 30,000 products. Every time a shopper ticks a filter the page reloads, and the filter apps we looked at cap the number of products.”

A store with 30,000 products has a filtering problem that a store with 300 does not. A shopper ticks "linen", then "navy", then a size, and each tick is a new question about the whole catalog: which products match all three, and how many would match if they also ticked "wool" instead?

This post is about answering those questions in the shopper's browser instead of on a server, which is how FilterProof works, and about the one hard limit that comes with it.

Where the server-side approach strains

The usual design sends each filter change to a server. The server works out which products match, recounts every facet for the new selection, and returns a page of results, either as a fresh page load or as a block of HTML the theme swaps in.

That design is reasonable, and for many stores it is the right one. Its shape is the problem at this size:

  • Every click is a round trip. The shopper waits for the network and for the server, whatever their connection is like at that moment.
  • Facet counts change with every combination. "How many navy products are linen?" has a different answer for every other selection, so the counts have to be recomputed for each combination a shopper reaches. The more products and facet values a catalog has, the more combinations there are, and the less any one cached answer gets reused.
  • The page often reloads. When it does, scroll position and focus can reset, and the shopper sees the whole page rebuild to change a grid of 24 products.

None of these get better as the catalog grows. The alternative is to move the question to where the shopper is.

Ship the index once, answer every click locally

FilterProof sends the browser a compact binary index of the catalog, once per page load, and a filtering engine written in Rust and compiled to WebAssembly. From then on a filter change is a calculation on the shopper's device:

  • Each facet value ("navy", "linen", "M") is stored as a roaring bitmap: a compressed set of the products that have that value.
  • Filters within one field are combined with OR (navy or black), and different fields with AND (navy or black, and linen). Both are bitmap operations.
  • Facet counts are disjunctive: when counting a field, the shopper's own selection in that field is left out, so ticking a second colour widens the results and the counts show how much it would widen them.
  • A price range is a numeric filter on each product's lowest variant price, and the search box matches typed words against prefixes of the words in product titles.

Working out which products match, the facet counts, the sort order and the page of ids to show involves no network request. One small request follows, and it is covered below.

What is in the index, and what is deliberately not

The index holds, per product: its id, its lowest price in minor units, its membership of each facet value, and the words of its title as search tokens. The format is a short header, then the ids, then the prices, then one bitmap per facet value, then one bitmap per title word. Anything that can be recomputed when the index loads, such as the products in price order, is recomputed rather than shipped.

It does not hold titles, images, URLs or descriptions. Display data is most of what a product card is made of, and none of it is needed to decide which products match. Leaving it out is what keeps the index small enough to send to a browser at all.

How small, we re-measured for this post rather than quoting earlier figures. We rebuilt the synthetic apparel catalog from the engine's own benchmark (eight facet fields, Shopify-style 13-digit product ids, four-word titles) and built each index with the same engine build the app uses to build real indexes:

Products Index as stored Per product Gzipped Per product
10,000 491 KB 50 bytes 300 KB 31 bytes
30,000 1,384 KB 47 bytes 835 KB 29 bytes
50,000 2,099 KB 43 bytes 1,025 KB 21 bytes

Two things in that table are worth reading carefully.

The commonly quoted figure, roughly 21 to 31 bytes per product, is the gzipped size. The index as stored is about twice that, and the gzipped size is the one that matters, because the gzipped copy is what the shopper's browser downloads. Below we explain why that took a deliberate change.

Most of the size is the product ids, at 13 digits each, which is why the size per product falls as the catalog grows: the bitmaps get denser and compress better, while the ids do not shrink. Real titles carry more distinct words than the benchmark's. Adding two words per title, drawn from 5,000 model names, took the 50,000-product index from 2,099 KB to 2,404 KB stored, and from 1,025 KB to 1,133 KB gzipped. Your catalog's number will depend on its own titles and facets; a rebuild from the app's dashboard reports the real stored size.

Compressing the index ourselves

Until the day FilterProof was listed, the browser downloaded the raw index. The proxy serves it as application/octet-stream, a type Cloudflare does not compress by default, and nothing else on the way compressed it either. On our 30,000-product test index that meant 1.42 MB went over the wire where 0.86 MB would have done.

We could have tried to get one of the hops to compress it. Instead, every rebuild now writes a gzipped copy of the index next to the raw one. The proxy serves that copy as plain bytes, deliberately without a Content-Encoding: gzip header, and the widget unpacks it in the browser with the standard DecompressionStream API. That way the smaller download no longer depends on how Cloudflare or Shopify's proxy happen to treat a response.

The raw index stays as a fallback. A browser without DecompressionStream (Safari before 16.4) fetches it, and so does a shop whose index has not been rebuilt since the change.

How product cards still render

The index answers "which products, in what order". It cannot draw a product card. So after each change the widget asks the app proxy for the display data of the current page of results only: up to the page size the merchant sets (12 to 60, 24 by default), as one request carrying the product ids.

The response is a few dozen small rows of title, price, URL and image, and the proxy marks it cacheable for 60 seconds, so going back to a page the shopper saw within the last minute does not have to fetch it again. That request is the only network traffic a filter click causes, and its size depends on the page size, not on the catalog size.

Keeping the index current

A catalog changes, and an index that lags behind it is wrong in a way nobody notices. The pipeline:

  1. Shopify sends a webhook when a product is created, updated or deleted, or a collection is updated.
  2. Each webhook re-arms a per-shop timer held in a Cloudflare Durable Object, set to 15 seconds after the most recent change. A burst of edits whose webhooks arrive less than 15 seconds apart produces one rebuild, not one per product.
  3. When the timer fires, a Cloudflare Worker fetches the shop's active products through Shopify's Admin API, builds the index with the same WebAssembly engine, and writes it to R2, Cloudflare's object storage, together with the display data and a small file of price bands.
  4. The storefront fetches the index from the app proxy, a Worker that serves it from R2.

The caching in step 4 is the part we got wrong first. The index lives at one constant URL and each rebuild overwrites it, so the URL never changes to signal a new version. With a long cache lifetime, a shopper's browser kept showing whatever index it had cached, and for a while a merchant's new facet settings were saved correctly and invisible on the storefront, with no error anywhere.

The proxy now serves the index with Cache-Control: no-cache and an ETag. That does not mean "do not cache". It means the browser keeps its copy but asks before reusing it, and when nothing has changed the answer is a 304 with no body. A returning shopper's browser asks a small question instead of downloading the index again, and a rebuild is visible on the next page load.

A rebuild also has to fail safely. The catalog arrives from Shopify in pages, and if any page comes back unusable, for example because the request was throttled, the rebuild now stops and the index already in R2 stays exactly as it was. An earlier version did not stop: it kept the products fetched so far and could overwrite a complete index with a partial one, with nothing reporting an error. A slightly stale index still contains every product; a partial one quietly drops some. The failed rebuild is retried automatically.

Keeping the storefront light

None of this should hold up the page it sits on. The theme block loads its script as type="module", and module scripts are deferred by default: in MDN's words, "the processing of the script contents is deferred", so the browser parses the page without waiting for it. The script then fetches the engine and the index in parallel, and until both have arrived the block shows a short "Loading filters…" line while the rest of the page is already usable.

If the index or the engine fails to load, the block says that products could not be loaded and asks the shopper to reload, and the rest of the storefront carries on.

The ceiling, stated plainly

There is a hard limit, and it is about 50,000 active products.

It is not a computing limit. In our measurements the slowest of five representative queries (no filters, one facet, three facets, facets plus price plus text with a price sort, and page 50 of a price-sorted list) had a 95th percentile under 3.5 ms at every size we tried, up to 60,000 products. That was on a server CPU under Node, not on a phone. A mid-range phone will be slower, and we have not measured one.

The limit is the download. The index grows with every product, and at 50,000 it is already about 1 MB to download, even gzipped. Beyond that, a shopper on a phone connection waits for an index before they can filter at all, which is the delay this design exists to remove.

So FilterProof's catalog build stops at 50,000 active products, and products past that are not included. A store well beyond that size is better served by a hosted search engine that answers queries on its own servers. That is not a weakness of either design. They suit different sizes.

What stays in the shopper's browser

What a shopper types into the search box and which filters they tick are evaluated in their browser. Neither is sent to the app. The requests the widget makes are for the index, the price bands and, per page of results, a list of product ids.

Two precise qualifications, because "no data" is easy to say and harder to mean:

  • The app's Shopify permission is read_products and nothing else, so it cannot read customers or orders.
  • Every request through a Shopify app proxy carries parameters that Shopify adds, including logged_in_customer_id, which Shopify's documentation describes as "the ID of the logged-in customer". FilterProof's proxy reads those parameters only to verify Shopify's signature on the request; it does not use the customer id for anything. Because that id arrives in the request URL, we have also switched off request logging on the proxy, so it is not stored in our logs either.

What this post checked

Every claim about FilterProof above was checked against the app's deployed source code. The index sizes and query times were measured by us, for this post, on a synthetic catalog with the engine build the app ships, in Node on a server. We did not measure a phone.

FilterProof is on the Shopify App Store at apps.shopify.com/filterproof.