Guide · Frontend
Faceted Search Explained: Filter URLs, Honest Counts and the Back Button
22 min readUpdated By Saket Bhatnagar
Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.
In short
- Faceted search is a search page where you narrow results by ticking values in groups called facets, such as colour, size and type, with a count beside each value.
- Keep the ticked filters in the URL's query string, the part after the question mark. Then reload, sharing a link and the Back button all show the same results.
- Use pushState, which adds a browser history entry, for each tick. Use replaceState, which overwrites the current entry, while someone types. Re-read the URL on popstate, the event Back fires.
- Count each facet with every filter except its own. Search servers such as Solr and Elasticsearch have built-in options for this. Otherwise the colours you did not pick drop to zero and multi-select breaks.
- Beginners can stop after the popstate section. Engineers then get tested behaviour from Chromium 153, the open-source core of Chrome: stale responses, zero-count rules, and a 50-entry history cap that per-keystroke pushState can fill.
What goes wrong when a search page forgets its filters?
The shopper loses their work. They tick three filters, open a product, press Back, and land on an unfiltered page or a different site. Faceted search pages fail in a few repeatable ways.
Picture a common setup. A shoe shop lists 12 shoes with three filter groups: colour, size and type. The shop is made up, but every behaviour below comes from tests we ran in Chromium 153.
A shopper ticks red, then blue, then running. Four things can go wrong from here, and each has a different cause.
- Back leaves the shop instead of un-ticking running, because the page never added history entries.
- Back changes the address bar, but the ticks and results stay the same, because nothing listens for the Back event.
- The colour list shows blue (0) after red is ticked, even though ticking blue would add three shoes.
- Two quick clicks show results for the first click, because its slower answer arrived last.
This guide fixes each one with browser standards you already have. It also covers counts, keyboard and screen-reader users, and search engine crawling.
Which words do you need before building faceted search?
Eight words carry the guide. Each is tied to the shoe shop.
- Facet: one filter group, such as colour. Facet value: one choice inside it, such as red.
- Facet count: the number beside a value, such as red (3), telling you how many results that choice gives.
- Query string: the part of a URL after the question mark, such as colour=red&size=8. Each name=value piece is a parameter.
- URLSearchParams: the browser's built-in object for reading and writing query strings safely.
- Session history entry: one stop on the Back button's list for a tab.
- pushState and replaceState: two methods on the history object that change the URL without loading a new page. pushState adds an entry; replaceState overwrites the current one.
- popstate: the event the browser fires on the window when Back or Forward moves between entries of the same page.
- Live region: a part of the page that screen readers announce when its text changes, such as 6 shoes found.
What is faceted search and how does it work in the browser?
Faceted search lets people narrow a result list by ticking values in several facets at once, with a count beside each value. On the front end, it is a loop between the URL, the server and the screen.
Within one facet, ticks usually mean OR: red or blue. Across facets they mean AND: red or blue, and running. That is the common convention, and our shop follows it.
Here is the loop for one tick. The page reads the ticks, writes them to the URL, asks the server for results and counts, then draws both. Back runs the same loop from the URL.
The faceted search loop in the browser
Drag to exploreNotice that both paths meet at the URL. A tick writes it, Back changes it, and the page always draws from what the URL says.
Diagram connections:
- Shopper ticks blue to pushState new URL (1. write filters)
- pushState new URL to URL query string (2. new entry)
- URL query string to Search API (3. fetch with same query)
- Search API to Render ticks and counts (4. results and counts)
- Shopper presses Back to popstate event (fires)
- popstate event to URL query string (read filters)
- Shopper ticks blue: Checkbox change event
- pushState new URL: ?colour=blue&colour=red
- URL query string: The single source of truth
- Search API: Returns hits and facet counts
- Render ticks and counts: Checkboxes, labels, results, status
- Shopper presses Back: Browser moves to older entry
- popstate event: Page re-reads the URL
An everyday picture helps. In a busy restaurant kitchen, the cook makes whatever the order ticket says. The ticket is the URL, the waiter's memory is your component state, and the plate is the rendered page.
If the waiter changes the order without updating the ticket, the wrong plate comes out. Where the picture breaks: a kitchen never lets you step back to an older ticket, while Back does exactly that.
Why should faceted search filters live in the URL?
Because the URL is the only place that survives a reload, a shared link and the Back button. Filters kept anywhere else are lost in at least one of those.
| Where the ticks live | Reload | Shared link | Back button |
|---|---|---|---|
| Component state only, such as a React useState value | Lost | Lost | Lost, or leaves the page |
| localStorage, the browser's per-site storage | Kept | Lost: the friend has empty storage | Not tied to history |
| URL with replaceState | Kept | Kept | One Back leaves the page |
| URL with pushState and a popstate handler | Kept | Kept | Each Back undoes one change |
Our reload test confirmed the last row. After ticking red and size 8, a reload showed the same two ticks and 2 shoes found. It worked because the page reads the URL when it loads, and our server returned the same page for any /shoes address.
The SevyDevy guide on frontend state management covers the general question of where state should live. A search page is the clearest case where the answer is the URL.
How do you put filters in the URL with URLSearchParams?
Use one parameter per ticked value, repeat the name, and let URLSearchParams do the encoding. Two ticks in colour become colour=red&colour=blue, and getAll gives you both back.
We ran the same cases in Node 22 and Chromium 153 and got identical output. Each row below is what the browser printed.
| What we did | Output | Why it matters |
|---|---|---|
| append colour red, then colour blue | colour=red&colour=blue | Repeated names are the standard way to send several values |
| get colour on that | red | get returns only the first; use getAll for facets |
| set colour to black | colour=black | set keeps the first pair and removes the rest |
| append q as red running shoes | q=red+running+shoes | Spaces become a plus sign in query strings |
| append colour as black & white | colour=black+%26+white | The ampersand is escaped, so it cannot split the value |
| append size as UK 8+ | size=UK+8%2B | A real plus sign becomes %2B |
| parse the hand-built string size=UK+8+ | UK 8 with a trailing space | Unescaped plus signs turn into spaces when read |
| parse colour=red,blue and call getAll | one value: red,blue | Commas are not a separator unless you split them yourself |
| append a key named colour[] | colour%5B%5D=red | Bracket names from other tools get escaped |
The space rule surprises people. The URL Standard's form serializer writes a space as a plus sign, while encodeURIComponent wrote %20 in our run. URLSearchParams reads both back as a space.
Danger comes when someone builds a query string by hand. A size like UK 8+ written without escaping came back as UK 8 with a trailing space. Always build query strings with URLSearchParams, never by joining strings.
Two more details keep URLs clean. When we deleted the last parameter through a URL object's searchParams, the question mark disappeared too. The standard sets an empty query to null.
Sort the values before writing them. Then ticking red then blue and ticking blue then red give one identical URL, which helps caching and search engines. URLSearchParams also has a sort method; in our run it kept same-name values in their original order.
Here is the URL code from our test page. It ran in Chromium 153, where renderResults was our own function that fetches and draws results.
const FACETS = ['colour', 'size', 'type'];
// URL to filters: repeated keys become arrays.
function readFilters() {
const params = new URLSearchParams(location.search);
const selected = {};
for (const facet of FACETS) {
const values = params.getAll(facet);
if (values.length) selected[facet] = values;
}
return selected;
}
// Filters to URL: one entry per ticked value, in a fixed order.
function writeFilters(selected, mode) {
const params = new URLSearchParams();
for (const facet of FACETS) {
for (const value of [...(selected[facet] || [])].sort()) {
params.append(facet, value);
}
}
const query = params.toString();
const url = location.pathname + (query ? '?' + query : '');
if (mode === 'push') history.pushState(null, '', url);
else history.replaceState(null, '', url);
}
// Back and Forward: the URL changed, so re-read it and re-render.
window.addEventListener('popstate', function () {
renderResults(readFilters());
});In our run, two pushes produced ?colour=blue&colour=red&type=running. Writing an empty selection produced plain /shoes with no question mark.
pushState vs replaceState: which one should a filter change use?
Use pushState for each deliberate choice, such as a tick, so Back undoes it. Use replaceState for in-between states, such as each letter typed into the search box.
Pause and predict: the shopper arrives from a landing page and ticks red, blue and running. How many Back presses take them back to the landing page in each mode?
With pushState, it took four. The first three walked back through red and blue, then red, then no filters, and the fourth left the shop. With replaceState, a single Back left the shop.
History entries after three ticks
Drag to exploreNotice that push builds one entry per tick, so Back has somewhere to go. Replace keeps overwriting one entry, so Back skips straight to the landing page.
Diagram connections:
- Landing page to Push: /shoes (open shop, push mode)
- Push: /shoes to Push: colour=red (tick red)
- Push: colour=red to Push: red and blue (tick blue)
- Push: red and blue to Push: plus running (tick running)
- Landing page to Replace: one entry (open shop, replace mode)
- Landing page: Where the shopper came from
- Push: /shoes: Entry 1, no filters
- Push: colour=red: Entry 2
- Push: red and blue: Entry 3
- Push: plus running: Entry 4, Back goes to entry 3
- Replace: one entry: Ends as red, blue and running
One more rule comes with pushState. If the shopper goes back two steps and ticks size 8, the forward entries are gone. In our run, history.length dropped from 6 to 5 and Forward did nothing.
| Change | Call | Why |
|---|---|---|
| Tick or untick a facet value | pushState | A deliberate step the shopper may want to undo with Back |
| Each keystroke in the search box | replaceState | Half-typed words are not steps anyone wants to revisit |
| The shopper stops typing or presses Enter | pushState once | The finished query becomes one entry |
| Page 2 of results, or a new sort order | pushState | People expect Back to return to page 1 |
| Fixing up the URL on first load, such as sorting values | replaceState | The visit itself already has an entry |
Why does the Back button change the URL but not the filters?
Because pushState and replaceState never fire popstate, and Back does not reload a same-document entry. If nothing listens for popstate, the address bar changes and the screen stays put.
Our test showed it plainly. With red and blue ticked and no popstate listener, Back changed the URL to colour=red. Both boxes stayed ticked and the status still said 6 shoes found.
The HTML Standard says this directly: popstate fires for fragment navigations but not for history.pushState calls. A fragment navigation changes only the part after a hash sign.
| Action | popstate fired? | event.state |
|---|---|---|
| history.pushState with a new query | No | Not applicable |
| history.replaceState | No | Not applicable |
| history.back to an entry we had replaced | Yes | The replaced state object |
| Setting location.hash to top | Yes | null |
The fix is the popstate listener at the end of the code above. On Back, it re-reads the URL and redraws everything from it. With it in place, each Back press in our test showed the right ticks and counts.
You can store an object as the first argument of pushState, but keep it small and plain. Browsers copy it with structured cloning, a deep copy that cannot hold functions, so an object holding a function threw DataCloneError in our run.
A URL on a different origin threw SecurityError, so pushState cannot point at another site. Reading filters from the URL rather than from event.state also means fresh visits and Back share one code path.
How should facet counts be calculated when filters are already ticked?
Count each facet with every filter except its own. Otherwise ticking red makes blue, black and white show zero, and nobody can add a second colour.
We ran both methods on the 12 shoes. Naive counting uses the final result list for every facet. Exclude-own counting skips the facet's own filter when counting that facet.
| Facet value | Naive count | Exclude-own count | What happens if ticked |
|---|---|---|---|
| colour: blue | 0 | 1 | Results grow from 2 to 3 |
| colour: black | 0 | 1 | Results grow from 2 to 3 |
| type: casual | 0 | 1 | Results grow from 2 to 3 |
| type: formal | 0 | 0 | Results stay at 2 |
| size: 8 | 1 | 1 | Results shrink to 1 |
| size: 7 | 0 | 0 | Results drop to 0 |
Naive counts said blue (0), yet ticking blue added a shoe. That is a count that lies. Exclude-own counts answer the question the shopper is really asking: how many would this choice add or leave?
How search engines compute exclude-own counts
Apache Solr, an open-source search server, calls this tagging and excluding filters. You tag the colour filter, then tell the colour facet to exclude that tag. The Solr 10.0 guide says counts then appear as if that filter had not been applied.
Elasticsearch, another widely used search server, uses post_filter. The docs say a post filter applies only to search hits, not aggregations, which are the grouped counts. In their shirts example, the colour tick goes in post_filter, so the colour counts ignore it.
That example post-filters one facet. With several, the usual extension wraps each facet's aggregation in a filter holding every other facet's ticks. The post_filter holds all the ticks, which is what our test API computed.
One search request with exclude-own counts
Drag to exploreNotice that each facet's count sees every filter except its own, while the result list sees all of them. That split is what keeps multi-select honest.
Diagram connections:
- Request: red, running to Main query (run)
- Main query to Colour counts (count colour)
- Main query to Type counts (count type)
- Main query to Size counts (count size)
- Main query to post_filter on hits (filter hits)
- Colour counts to Response (colour buckets)
- Type counts to Response (type buckets)
- Size counts to Response (size buckets)
- post_filter on hits to Response (2 shoes)
- Request: red, running: Browser → Search API
- Main query: Text query, no facet filters
- Colour counts: Filtered by type running only
- Type counts: Filtered by colour red only
- Size counts: Filtered by red and running
- post_filter on hits: Applies red and running
- Response: Search API → Browser: 2 hits, counts
Three details that make counts look wrong
- Missing values: as of 29 October 2026, an Elasticsearch terms aggregation returns the top ten values by default, and Solr's facet.limit defaults to 100. Raise the limit for long lists such as brands.
- Zero buckets: Elasticsearch hides values with no matches unless min_doc_count is 0. Our shop kept zero values on purpose so the list does not jump around.
- Approximate numbers: the Elasticsearch docs say terms aggregation counts may be approximate when an index is split into shards. Do not promise exact counts in the UI on indexes with many shards.
How do you prevent zero-result filter combinations?
Use the exclude-own counts to spot dead ends before the click. A zero in a facet with nothing ticked means that tick gives zero results, so disable or hide it.
A zero inside a facet that already has a tick means something different. Ticking formal while running is ticked kept 2 results in our run, because OR within a facet adds shoes, never removes them.
So our test page disabled only zeros in untouched facets. With colour red ticked, it disabled size 7, size 10 and formal, and nothing else.
This rule assumes each shoe has one value per facet. If one product can be both red and blue, counts inside a ticked facet overlap, and a count no longer equals the shoes added.
Zero results can still happen, through a typed query or an old shared link. Show the active filters as removable chips and say which one to drop first, rather than a bare empty page.
How do you stop slow responses from showing the wrong results?
Cancel the older request, or ignore any answer that is not the newest. Otherwise a slow first answer can arrive after a fast second one and overwrite it.
We slowed the server on purpose: the red request took 800 milliseconds and the red-plus-blue request took 50. Without a guard, the page drew red-plus-blue first, then red alone on top.
The result was a page that contradicted itself. The URL said colour=blue&colour=red, but only red was ticked and the status said 3 shoes found.
With the guard, the page aborted the red request with an AbortController, an object that cancels a fetch. It also checked a request counter before drawing. The final page showed both ticks and 6 shoes found.
Why can pushState on every keystroke trap the Back button in Chromium 153?
Because Chromium keeps at most 50 history entries per tab, and older ones are dropped to make room. Push one entry per letter and the page the shopper came from falls off the list.
This cap is browser behaviour, not a number in the standard. The HTML Standard lets browsers limit state entries per page, then drop the entry right after that page's first one. Chromium's source instead sets kMaxSessionHistoryEntries to 50 per tab, as of 29 October 2026.
We tested it by typing a 56-character search into a box that called pushState on every key. history.length stopped at 50. Pressing Back 49 times walked through half-typed queries and then got stuck.
The landing page was gone. We then switched to replaceState while typing and one pushState when typing paused. The same search added one entry, and two Back presses reached the landing page.
A loop of 1,000 pushState calls also stopped at 50 entries, with no error and no console message. MDN lists calling pushState too frequently as one reason it can throw, so do not rely on silence.
Chromium's source also has a history manipulation intervention. It can mark entries as skippable for the Back button when a page adds them without any user interaction. We did not test that path, so treat it as a reason to push only on real clicks.
How do you make filters and result counts accessible?
Use real checkboxes grouped under a visible label, announce the result count in a status region, and never rebuild the list under the keyboard user's focus.
The WAI-ARIA Authoring Practices checkbox pattern says Space toggles a focused checkbox. A group of checkboxes sits in an element with role group, labelled through aria-labelledby. Its mixed-state example uses a native fieldset, which our test page used with a legend.
For the count, give an element role status before any message appears. The WCAG technique ARIA22 says status is a polite live region, so screen readers announce changes without interrupting. Its first example is a search results message.
We found one easy mistake. Our first test page redrew the checkbox list after every change. After pressing Space on blue, keyboard focus fell back to the page body, so the user lost their place.
Updating the same checkboxes in place fixed it. Focus stayed on blue, the labels showed new counts, and the status read 3 shoes found.
Disabled zero options have a cost too. In our run, the Tab key skipped the three disabled checkboxes entirely, so a keyboard user never lands on them. Keep their labels visible with the count of zero so everyone can still see them.
The SevyDevy guide on frontend accessibility and i18n covers labels, focus and live regions in more depth.
Should search engines crawl every faceted search URL?
Usually not. Every combination of ticks is a new URL, so a crawler can find thousands of near-copies of one listing.
Google's page on managing crawling of faceted navigation URLs, last updated 18 December 2025, gives concrete advice. If you do not need these URLs indexed, block them in robots.txt, the file that tells crawlers what to skip.
If you do want them crawled, use the standard ampersand separator. Google asks for a fixed filter order when filters sit in the URL path; sorted query values follow the same idea. Return an HTTP 404 status, the not found code, when a combination has no results. Google notes this may not be possible in a single-page app.
Google also says Search generally does not support URL fragments in crawling and indexing. So filters placed after a hash sign are generally not crawled, which suits pages you never wanted indexed.
How do you explain faceted search in a system design interview?
Interviewers ask about faceted search in frontend rounds for shopping, travel and job sites. Show that you know the browser mechanics, not only the boxes on a diagram.
Define the page
Facets are filter groups with counts; OR within a facet, AND across facets.
Pick the source of truth
The URL query string, written with URLSearchParams, repeated names and sorted values.
Handle history
pushState for ticks, replaceState while typing, and a popstate listener that redraws from the URL.
Make counts honest
Each facet counts with every filter except its own, using Solr tag-and-exclude or Elasticsearch post_filter.
Prevent dead ends
Disable zero values in untouched facets and offer removable filter chips when results are empty.
Guard the network
Abort or ignore stale requests so only the newest response draws.
Cover access and crawling
A status region for counts, focus kept in place, and robots.txt or a 404 policy for filter URLs.
Questions people ask
- What is faceted search?
- Faceted search is a search page where you narrow results by ticking values in several filter groups at once. Each group, such as colour or size, is a facet. Each value shows a count of matching results. Ticks inside one facet usually combine with OR, and ticks across facets combine with AND.
- Should filters use pushState or replaceState?
- Use pushState for each tick and replaceState while someone types. A tick is a deliberate step people expect Back to undo. Keystrokes are not. In our Chromium 153 test, pushState per tick let Back undo one tick at a time. replaceState per tick sent the shopper straight back to the previous page.
- Why is my page not updating when I press Back?
- Your page is probably not listening for popstate. Back moves to an older same-document entry and updates the URL, but it does not reload the page. Add a popstate listener that reads the URL with URLSearchParams and redraws the filters and results from it. pushState and replaceState never fire popstate themselves.
- How do I put several values for one filter in the URL?
- Repeat the parameter name once per value, such as colour=red&colour=blue, and read it with getAll. URLSearchParams handles the escaping. A comma-joined value like colour=red,blue comes back as one value from getAll, so you would have to split it yourself and escape commas inside values.
- Why do my facet counts show zero for options that would add results?
- Your counts are probably computed over the fully filtered results. Count each facet with every filter except its own instead. In Solr that is tagging and excluding filters. In Elasticsearch it is a post_filter for hits plus filtered aggregations for each facet.
- Is it bad to call pushState on every keystroke?
- Yes, it fills history with half-typed searches and can push older pages out. In our Chromium 153 test, a 56-character search with one pushState per key capped history at 50 entries. The page the shopper came from was gone. Use replaceState while typing and push once when typing stops.
- How do I announce the result count to screen readers?
- Put the count in an element with role status that is on the page before any results load. Status is a polite live region, so screen readers announce changes like 6 shoes found without stealing focus. Update the text in place and keep keyboard focus on the checkbox the user just changed.
Sources and further reading
- WHATWG URL Standard: URLSearchParams set, sort, update steps and the urlencoded serializer
- WHATWG HTML Standard: History interface, pushState and replaceState steps, popstate note
- MDN: History pushState method (hashchange, frequency and origin exceptions)
- MDN: Window popstate event
- MDN: URLSearchParams (percent encoding and plus signs)
- Chromium source: kMaxSessionHistoryEntries constant (main branch)
- Chromium source: NavigationControllerImpl, pruning and history manipulation intervention (main branch)
- Apache Solr 10.0 Reference Guide: Faceting, tagging and excluding filters
- Elasticsearch docs: Filter search results (post_filter)
- Elasticsearch docs: Terms aggregation (size, min_doc_count, approximate counts)
- WAI-ARIA Authoring Practices: Checkbox pattern
- WCAG technique ARIA22: Using role=status to present status messages
- Understanding WCAG SC 4.1.3: Status Messages
- Google: Managing crawling of faceted navigation URLs