Build Faceted Search with Live Inventory Filters
performance databases scalability
System Design Deep Dive
Faceted Search with Live Inventory
How to keep multi-dimensional filter counts accurate within seconds when inventory changes at scale
Think of a library card catalog. In a physical library, each card represents a book, and there are multiple indexes organized by author, genre, topic, and availability. When a book gets checked out, a librarian manually updates the “available” slot - but across 10,000 cards in 20 different drawers, that manual sync creates a lag. Shoppers on Amazon, Flipkart, or Zalando face the same problem at internet scale: the left sidebar showing “Nike (342 results)” needs to reflect the reality that 50 Nike sneakers just went out of stock during a flash sale, and that count should drop to 292 within seconds, not hours.
Faceted search is the technique of returning aggregate counts per filter dimension alongside search results. Users see “Brand: Nike (342), Adidas (218), Puma (89)” and can click to narrow their results. These counts aren’t cosmetic - they drive user decisions. A count of zero means the user won’t bother clicking. A stale count that says “5 items” but returns zero results is a broken experience that erodes trust and increases cart abandonment. The engineering challenge is that these counts must be correct enough to be useful while inventory changes continuously across a catalog of tens of millions of products.
The naive approach is to run a SQL COUNT(*) query per facet per search request. For a search with 15 active filter dimensions and a catalog of 50 million products, that means 15 separate aggregation queries touching millions of rows - at 50,000 search requests per second, that’s 750,000 full-table scans per second. Even with perfect indexing, this does not work. The opposite extreme - precomputing all facet counts and caching them forever - means a product going out of stock at 2pm won’t be reflected until a nightly batch job rebuilds the index. That is equally unacceptable.
The tension we need to resolve: facet index design for fast reads, real-time filter count update propagation when inventory changes, index refresh latency targets that balance staleness against compute cost, and cache invalidation on inventory change that avoids the thundering herd problem. We also need to handle filter combination explosion - when users combine multiple filters, the permutation space grows exponentially. We need to solve all three simultaneously: sub-100ms facet count retrieval, under 5-second propagation of inventory changes into visible counts, and a system that handles 500 million products without blowing up compute costs.
Requirements and Constraints
Functional Requirements
- Return facet counts for all active filter dimensions alongside search results (brand, price range, rating, availability, category, size, color)
- Reflect inventory changes (stock going to zero, new stock arriving) in filter counts within 5 seconds
- Support multi-filter combinations: user selects “Nike + $50-100 + Rating 4+” and all other facet counts must update to reflect only products matching that combination
- Support at least 20 distinct facet dimensions per product category
- Support catalog sizes up to 500 million products
- Provide approximate counts (within 2% accuracy) as an explicit design tradeoff for performance
Non-Functional Requirements
- Search with facets latency: p99 under 120ms end-to-end including facet count computation
- Facet count freshness: inventory changes reflected in 95% of filter counts within 5 seconds
- Throughput: 50,000 search-with-facets requests per second at peak (holiday traffic)
- Write throughput: 100,000 inventory update events per second (flash sales, warehouse sync)
- Availability: 99.99% uptime; stale counts acceptable during degraded mode but zero results is not
- Storage: facet index must fit within 2TB total for a 500M product catalog
Constraints
- Product catalog is append-heavy but not immutable - attributes like price and availability change frequently
- Filter combinations are not known in advance - users compose arbitrary combinations
- Some facets (brand, category) are low-cardinality (hundreds of values); others (price, rating) are range-based
- No exact count guarantees - the SLA is “useful approximation” not “exact to the integer”
- Inventory changes arrive as events from a warehouse management system, not as direct DB writes
High-Level Architecture
The system is composed of five major layers. The Query Gateway receives search-with-filter requests and orchestrates parallel execution. The Facet Engine computes filter counts using bitmap indexes maintained in memory and on disk. The Search Index (Elasticsearch or a custom inverted index) handles the full-text search and document retrieval. The Inventory Event Pipeline is a Kafka-based stream that captures inventory changes and fans them out to index updaters. The Facet Cache is a Redis cluster that caches computed facet count sets, keyed by the query+filter combination hash.
When a search request arrives, the Query Gateway sends two parallel requests: one to the Search Index for ranked document retrieval, and one to the Facet Engine for filter counts. The Facet Engine checks the Facet Cache first. On a cache hit, it returns the cached count set directly. On a cache miss, it executes bitmap index operations against the Bitmap Index Store - performing bitwise AND operations across the active filter dimensions to compute the result set, then running popcount operations to derive counts for each facet value. The result is written back to cache with a short TTL before being merged with search results and returned to the client.
Meanwhile, the Inventory Event Pipeline runs independently. Every inventory change - a product going out of stock, a new shipment arriving, a price update - is published to Kafka. The Facet Updater service consumes these events, identifies which facet bitmaps are affected (a product’s brand, category, price range, and availability facets all need updating), and applies targeted bitmap flips. After each bitmap update, it publishes cache invalidation messages so the Facet Cache discards stale counts within seconds.
The facet engine and the search index are deliberately decoupled: search results and facet counts can come from indexes at slightly different refresh states. A user seeing "Nike (342)" while the actual filtered result page shows 340 items is acceptable. A user seeing "Nike (0)" and getting zero results when items exist is catastrophic. Design for count-result consistency on zero-item facets first; small numeric discrepancies elsewhere are acceptable.
The Bitmap Index Layer
The Bitmap Index Layer’s job is to answer the question “how many products match this combination of filters?” in under 10 milliseconds for any filter combination across 500 million products.
Bitmap indexes work like a row of light switches, one switch per product. For the facet “brand: Nike”, there is a bitmap where bit position i is 1 if product i is made by Nike, and 0 otherwise. For “in-stock”, there is another bitmap where bit i is 1 if product i has available inventory. To answer “how many Nike products are currently in stock?”, you compute the bitwise AND of the Nike bitmap and the in-stock bitmap, then count the 1-bits (popcount). Modern CPUs can AND two 64-bit words in a single instruction, meaning a single CPU core can evaluate a filter combination across 64 products in one clock cycle. For 500 million products, that is about 8 million 64-bit words - a bitwise AND pass takes roughly 8ms on a single core, well within budget.
This is the same mechanism that Roaring Bitmaps uses - the data structure deployed in Apache Druid, Apache Kylin, and LinkedIn’s Pinot for fast aggregate queries over large datasets. The key insight from those systems is that product catalogs are sparse: most products don’t match most filter values. Roaring Bitmaps exploit this sparsity by using compressed run-length encoding for long runs of zeros, dramatically reducing memory. A catalog of 500 million products with 10,000 brand values averages 50,000 products per brand - each brand bitmap has only 0.01% density, which compresses to a few hundred bytes rather than 62MB.
Premium Content
Unlock the full article along with everything else in the archive — all in one place.