Building Faceted Filters with Aggregations End-to-End

You have an Elasticsearch index and a sidebar that needs checkboxes with live counts, and clicking a checkbox must narrow the results. The specific problem is the round trip: turning an aggs request body into rendered facet rows, then turning a user’s clicks back into filter clauses on the next request. This walkthrough sits under the faceted navigation and filtering guide and the broader search frontend UX patterns area, and it assumes you have already decided which fields are facets. Here we wire them together so the loop closes correctly on the first click.

Prerequisites

  1. An index at localhost:9200 with keyword/numeric facet fields (see schema design and index mapping).
  2. A backend that proxies search requests (never let the browser hit Elasticsearch directly).
  3. Node 18+ for the example fetch code.

Diagnosis / Context

The mistake that breaks most first implementations is treating the aggregation result and the filter request as unrelated. They are one cycle: aggregations describe the available filters, the user’s selection becomes the applied filter, and the next response’s aggregations describe the new available filters. If you parse buckets without preserving the field-to-agg mapping, you cannot reconstruct which checkbox produced which term clause.

A raw aggregation response looks like this — note that bucket keys are the literal filter values you will send back:

{
  "aggregations": {
    "by_brand": {
      "doc_count_error_upper_bound": 0,
      "sum_other_doc_count": 0,
      "buckets": [
        { "key": "acme",  "doc_count": 42 },
        { "key": "globex", "doc_count": 17 }
      ]
    }
  }
}

Each key is what goes into a term query; each doc_count is the number rendered beside the checkbox. The naming convention by_<field> is deliberate — it lets the parser derive the field name by stripping the prefix, so the click handler knows to emit { "term": { "brand": "acme" } }.

Solution Steps

1. Define the aggregation request from a facet config

Drive the request from a declarative config so adding a facet is a one-line change.

// facets.js — single source of truth for which fields are facets
const FACETS = [
  { name: "brand", field: "brand", type: "terms" },
  { name: "color", field: "color", type: "terms" },
  {
    name: "price", field: "price", type: "range",
    ranges: [
      { key: "under-50", to: 50 },
      { key: "50-100", from: 50, to: 100 },
      { key: "100-plus", from: 100 }
    ]
  }
];

function buildAggs() {
  const aggs = {};
  for (const f of FACETS) {
    aggs[`by_${f.name}`] = f.type === "range"
      ? { range: { field: f.field, ranges: f.ranges } }
      : { terms: { field: f.field, size: 20 } }; // cap buckets per facet
  }
  return aggs;
}

2. Translate active selections into filter clauses

Selected values for the same facet union (OR); different facets intersect (AND). Build one terms clause per facet, and collect them into the filter array.

// selections = { brand: ["acme"], color: ["red", "blue"] }
function buildFilters(selections) {
  const filter = [];
  for (const f of FACETS) {
    const picked = selections[f.name];
    if (!picked || picked.length === 0) continue;
    if (f.type === "range") {
      // OR across selected bands -> bool.should
      filter.push({
        bool: {
          should: picked.map((key) => rangeClauseFor(f, key)),
          minimum_should_match: 1
        }
      });
    } else {
      // terms clause = OR across values within one facet
      filter.push({ terms: { [f.field]: picked } });
    }
  }
  return filter;
}

function rangeClauseFor(facet, key) {
  const band = facet.ranges.find((r) => r.key === key);
  const r = {};
  if (band.from != null) r.gte = band.from;
  if (band.to != null) r.lt = band.to; // 'lt' so bands don't double-count the edge
  return { range: { [facet.field]: r } };
}

3. Assemble and send the request

async function search(queryText, selections) {
  const body = {
    size: 24,
    query: {
      bool: {
        must: queryText ? [{ match: { title: queryText } }] : [{ match_all: {} }],
        filter: buildFilters(selections)
      }
    },
    aggs: buildAggs()
  };
  const res = await fetch("http://localhost:9200/products/_search", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body)
  });
  return res.json();
}

4. Parse buckets into render-ready facet rows

Strip the by_ prefix to recover the field name, then mark which values are currently checked so the UI is stateful across re-renders.

function parseFacets(aggregations, selections) {
  return FACETS.map((f) => {
    const agg = aggregations[`by_${f.name}`] || { buckets: [] };
    return {
      name: f.name,
      rows: agg.buckets.map((b) => ({
        value: b.key,
        count: b.doc_count,
        checked: (selections[f.name] || []).includes(b.key)
      }))
    };
  });
}

5. Close the loop on click

A click toggles the value in selections, then re-runs search. The new response’s aggregations replace the rendered rows.

function onToggle(state, facetName, value) {
  const cur = new Set(state.selections[facetName] || []);
  cur.has(value) ? cur.delete(value) : cur.add(value);
  state.selections[facetName] = [...cur];
  return search(state.query, state.selections).then((r) => ({
    hits: r.hits.hits,
    facets: parseFacets(r.aggregations, state.selections)
  }));
}

Verification

Run a request with one active filter and confirm both the hit total and the bucket counts respond.

curl -s -X POST "localhost:9200/products/_search" -H 'Content-Type: application/json' -d '{
  "size": 0,
  "query": { "bool": { "filter": [{ "terms": { "color": ["red","blue"] } }] } },
  "aggs": { "by_brand": { "terms": { "field": "brand", "size": 20 } } }
}' | jq '{ total: .hits.total.value, brands: .aggregations.by_brand.buckets }'

Expected output: a total reflecting only red/blue items, with per-brand counts summing to that total.

{
  "total": 59,
  "brands": [
    { "key": "acme", "doc_count": 41 },
    { "key": "globex", "doc_count": 18 }
  ]
}

Common Pitfalls

Range bands double-count items at the boundary

Using lte on one band and gte on the next makes an item priced exactly 50 fall into both. Always pair gte with lt (half-open intervals) so each value lands in exactly one band, matching the from/to semantics Elasticsearch uses internally for range aggregations.

Checked state is lost after re-render

If parseFacets does not receive the current selections, every re-render returns unchecked rows and the UI appears to forget the user’s clicks even though the filter still applies. Always pass selections into the parser so each row’s checked flag is recomputed.

Bucket truncation hides values the user already selected

A terms agg with size: 20 can omit a selected value that ranks 21st, so its checkbox disappears mid-session. Either raise size, or merge selected values back into the row list after parsing so an active filter never vanishes from the sidebar.

Before tuning anything, confirm which facets are actually used. Instrumenting facet interactions usually reveals that two or three carry nearly all the clicks while the rest are decoration — and removing an unused high-cardinality facet is a larger performance win than any parameter change.

Aggregation cost is where facets get expensive

A faceted result page is not one query but a query plus an aggregation per facet, and the aggregation cost scales with the cardinality of the field rather than with the number of results shown. A brand facet over 50,000 brands builds a 50,000-bucket structure per shard, merges them at the coordinating node, and then discards all but the top ten.

Three settings control that cost directly. size bounds what is returned; shard_size bounds what each shard computes and defaults to a multiple of size, which is where the real work happens; and whether the field has eager global ordinals decides how much per-request setup is needed for a high-cardinality keyword field.

{
  "aggs": {
    "brands": {
      "terms": { "field": "brand.raw", "size": 10, "shard_size": 50 }
    }
  }
}

The default shard_size is deliberately generous because a small value can produce inaccurate counts when a term’s documents are unevenly distributed across shards. That is the trade to be explicit about: lowering it is the single biggest lever on aggregation cost and it buys speed with accuracy.

Where facet aggregation work happens Each shard builds buckets up to shard_size, the coordinating node merges them, and only size buckets are returned. shard 1 — 50 buckets shard 2 — 50 buckets coordinating node merges and trims 10 buckets shown the work is proportional to shard_size × shards, not to what the user sees
Users see ten values; the search cluster computed fifty per shard to produce them accurately. That multiplier is the tuning surface.

The same reasoning applies to facet depth. Requesting fifty values for a facet that displays ten costs accuracy insurance you may not need on a low-cardinality field, where the top ten are unambiguous regardless of shard distribution.

Cache what the shape allows

Facet aggregations are unusually cacheable because most result pages share a filter prefix. A category landing page issues the same aggregation for every visitor until the catalog changes, and a search with no filters applied produces the same facet counts for everyone. Recognising which requests are user-specific and which are not turns a large fraction of aggregation work into cache hits.

Two things break that cacheability, and both are avoidable. A now-relative date range in the filter makes every request unique, which is fixed by rounding the boundary to the hour. And including a user or session identifier in the query — for personalised boosting, usually — makes every request unique by construction, which is fixed by applying personalisation as a rescore over cached candidates rather than as part of the filtered query.

Which facet requests are cacheable Unfiltered and category-level facet requests are shared across users, while now-relative ranges and per-user parameters make every request unique. cacheable category pages · unfiltered search unique per request now-relative ranges · user ids in the filter Round date boundaries and move personalisation into a rescore, and most of the right-hand box moves left.
Cacheability is mostly a property of how the query is written. Two small changes convert the majority of facet requests into shared, cached work.

Request one query, not one per facet

The naive implementation issues a query per facet, which multiplies both round trips and engine work by the number of facets. A single request carrying all aggregations does the retrieval once and computes every facet from the same result set — usually a three-to-five-fold reduction in total work on a typical sidebar.

The exception is a facet that must be computed against a different filter set — the post-filtering case where a facet shows counts as if its own filter were not applied. That genuinely needs separate computation, and the multi-search API is the right tool: several logical queries in one round trip, which keeps the network cost at one even when the engine work cannot be shared.

One request with several aggregations versus one request per facet A single request computes all facets from one retrieval, while separate requests repeat the retrieval for every facet. per facet the same retrieval repeated four times batched one retrieval, four aggregations Batching removes both the extra round trips and the duplicated retrieval work.
The saving is not just network round trips: the expensive retrieval phase runs once instead of once per facet.