DEV Community

Cover image for Smart multifacet filters in JS using Bitmaps
Rosario De Chiara
Rosario De Chiara

Posted on

Smart multifacet filters in JS using Bitmaps

Introduction

Modern frontend applications are starting to look less like simple view layers and more like small databases running directly in the browser.

Think about an e-commerce catalog, an analytics dashboard, or an admin panel. In all of these cases, the UI isn’t just rendering data—it’s constantly slicing it, recombining it, and reacting to user input in real time. Filtering becomes one of the most common operations, and the most natural way to express it in JavaScript is still the familiar chain of Array.filter() calls:

const filtered = products  
 .filter(p => p.category === selectedCategory)  
 .filter(p => p.inStock)  
 .filter(p => p.price < maxPrice);
Enter fullscreen mode Exit fullscreen mode

It reads well and does exactly what you expect: take a list, narrow it down step by step. For small datasets, there’s nothing wrong with this approach. The problem starts when the data grows or when filtering becomes part of a tight interaction loop—every checkbox toggle, every slider movement, every keystroke.

The point is that filters are rarely applied once: they are run over and over again, often across tens or hundreds of thousands of items, and each step in that chain quietly walks the entire array again.

This is not a new problem on the backend: databases ran into it decades ago and solved it with a different mental model by replacing scanning records with precomputed indexes, turning the filtering into fast set operations. One of the most effective techniques they use is bitmap indexing, where filters become simple bitwise combinations rather than repeated object traversal.

In this article, we’ll bring that idea into JavaScript and see what happens when you treat your frontend data layer a bit more like a database engine. You can find the fully working code on the GitHub repository. The interactive example on the repository contains a time comparison of traditional array filtering and the techniques described here.

The core idea behind bitmap indexes

Bitmap indexes work by precomputing binary representations for attributes.

Imagine this dataset:

Product Red Blue Size M In Stock Description
P1 1 0 1 1 P1 is Red, Size M and in stock
P2 0 1 1 0 P2 is Blue, Size M and is not in stock
P3 1 0 0 1 P3 is Red, and is in stock
P4 1 0 1 0 P4 is Red, Size M and is not in stock

Each attribute becomes a bitmap:

red      = 1000  
blue     = 0100  
sizeM    = 0010  
inStock  = 0001
Enter fullscreen mode Exit fullscreen mode

Filtering becomes a bitwise operation:

red AND sizeM AND inStock

1000  
0010  
0001  
----  
1011
Enter fullscreen mode Exit fullscreen mode

Only the first product matches all filters.

Instead of repeatedly scanning arrays, we combine compact binary structures using highly optimized CPU operations.

This is one of the reasons bitmap indexes are widely used in analytical databases and search engines.

Example of use

Generating a realistic dataset

The demo starts by constructing a synthetic—but stable—product catalog. Instead of hardcoding data or relying on an API, the implementation uses a seeded pseudo-random generator to ensure reproducibility across page loads:

let _seed = 42;

function rng() {  
 _seed = (_seed * 9301 + 49297) % 233280;  
 return _seed / 233280;  
} 
Enter fullscreen mode Exit fullscreen mode

This small detail matters more than it seems. Because the dataset is deterministic, performance comparisons between runs are consistent—something you don’t get with Math.random().

The dataset itself is built via Array.from, producing 10,000 products (you can vary this by modifying the PRODUCT_COUNT constant) with attributes like color, size, inStock, and price:

const products = Array.from({ length: PRODUCT\_COUNT }, (\_, i) \=\> ({  
 id:      i,  
 name:    `${pick(ADJS)} ${pick(NOUNS)}`,  
 color:   pick(COLORS),  
 size:    pick(SIZES),  
 inStock: rng() > 0.38,  
 price:   Math.floor(rng() * 90) + 9,  
}));
Enter fullscreen mode Exit fullscreen mode

Each product is intentionally simple and categorical. That’s not a limitation—it’s exactly what makes this dataset ideal for bitmap indexing. Faceted search systems (like e-commerce filters) are dominated by discrete attributes, and this structure mirrors that reality.

From Array.filter to bitmap filtering

At a glance, the traditional approach uses chained Array.filter calls:

let arr = products;  
if (colors.length > 0) arr = arr.filter(p => colors.includes(p.color));  
if (sizes.length > 0) arr = arr.filter(p => sizes.includes(p.size));  
if (inStockOnly) arr = arr.filter(p => p.inStock); 
Enter fullscreen mode Exit fullscreen mode

This is intuitive and expressive: each filter step narrows down the dataset. But under the hood, it repeatedly scans the array—O(n) per filter—leading to cumulative cost as filters stack.

The bitmap approach flips the perspective entirely. Instead of filtering objects, it filters indices using precomputed bitsets.

First, we build indexes:

const idx = {  
 color: Object.fromEntries(COLORS.map(c => [c, new FastBitSet()])),  
 size: Object.fromEntries(SIZES.map(s => [s, new FastBitSet()])),  
 inStock: new FastBitSet(),  
};

products.forEach((p, i) => {  
 idx.color[p.color].add(i);  
 idx.size[p.size].add(i);  
 if (p.inStock) idx.inStock.add(i);  
});
Enter fullscreen mode Exit fullscreen mode

Each bitset represents a subset of product indices. For example:

  • idx.color.red → all indices of red products

  • idx.size.M → all medium-sized products

  • idx.inStock → all available products

This transforms filtering into set algebra.

The core logic starts from a “universe” of all indices and then refines it by applying intersection and union operations:

let result = universe.clone();
Enter fullscreen mode Exit fullscreen mode

Then applies constraints using bitwise operations. In the following code, we map the logical “or” operator by using the .union operation:

const colorSet = new FastBitSet();  
colors.forEach(c => colorSet.union(idx.color[c]));
Enter fullscreen mode Exit fullscreen mode

This corresponds to:

color = red OR blue OR green
Enter fullscreen mode Exit fullscreen mode

In the same way multiple selections within the same facet are merged via union, we map the logical “and” operator by using the .intersection operation:

result.intersection(colorSet);
Enter fullscreen mode Exit fullscreen mode

This corresponds to:

(color condition) AND (size condition) AND (stock condition)
Enter fullscreen mode Exit fullscreen mode

Each additional filter reduces the result set via intersection.

Putting it together:

if (sizes.length \> 0\) {  
 const sizeSet = new FastBitSet();  
 sizes.forEach(s => sizeSet.union(idx.size[s]));  
 result.intersection(sizeSet);  
}

if (inStockOnly) {  
 result.intersection(idx.inStock);  
}
Enter fullscreen mode Exit fullscreen mode

Conceptually, this translates the array pipeline:

products  
 .filter(color condition)  
 .filter(size condition)  
 .filter(stock condition)
Enter fullscreen mode Exit fullscreen mode

into:

universe  
 ∩ (color.red ∪ color.blue)  
 ∩ (size.M ∪ size.L)  
 ∩ inStock
Enter fullscreen mode Exit fullscreen mode

The key shift is that instead of iterating over objects repeatedly (this happens with the filter function on arrays), we manipulate compact bitmaps where each operation is vectorized and CPU-efficient.

Finally, the matching indices are materialized:

const matchedIds = result.array();

and mapped back to actual product objects only at the end:

matchedIds.map(i => products[i])
Enter fullscreen mode Exit fullscreen mode

This last operation maps the remaining matchedIds to the products to update the UI, and that is another subtle but important optimization by pushing back this operation just on the final set of matched identifiers.

Production considerations

In production, bitmap-based filtering is typically implemented as an index-first strategy: instead of repeatedly scanning objects, you precompute compact index structures and combine them efficiently at query time. On the server, this pattern is often handled by specialized libraries or search engines—using structures like compressed bitmaps (e.g., RoaringBitmap) that allow fast unions and intersections at scale, even across millions of records. On the client, the same idea can be applied to moderate in-memory datasets to deliver instant, zero-latency filtering for common facets, while delegating heavier or high-cardinality queries back to the backend. The main tradeoffs to manage are index maintenance (keeping bitmaps in sync with data updates) and memory usage, but when filters are frequent and latency matters, this approach provides a significant and predictable performance advantage.

Conclusion

Bitmap indexes are a good example of how ideas traditionally associated with database engines are increasingly relevant in frontend engineering.

Instead of repeatedly scanning arrays of objects, we can transform filtering operations into compact bitwise computations that scale much more efficiently as datasets grow.

For small applications, standard filtering techniques are usually simpler and perfectly sufficient. But for large client-side datasets, analytics dashboards, faceted search interfaces, and local-first applications, bitmap indexes can dramatically improve responsiveness while keeping implementation complexity relatively manageable.

Modern frontend applications increasingly resemble miniature data systems running directly in the browser. Techniques like bitmap indexing show that many optimizations once reserved for databases can now directly improve frontend user experiences as well.

Top comments (0)