Replacing Solr Autocomplete with a Cached JSON File and Fuse.js
The client asked us to reduce the number of requests reaching the search backend, on top of the work we had already done on listing pages. The obvious target was autocomplete. The platform used Search API's Solr autocomplete, which is convenient and works out of the box, and sends a request through Drupal to Solr for every character the visitor types:
type "p" -> GET autocomplete?q=p -> Drupal -> Solr
type "pu" -> GET autocomplete?q=pu -> Drupal -> Solr
type "pup" -> GET autocomplete?q=pup -> Drupal -> Solr
type "pupp" -> GET autocomplete?q=pupp -> Drupal -> Solr
type "puppy" -> GET autocomplete?q=puppy -> Drupal -> SolrFive keystrokes, five backend requests, none of them cacheable in any useful way. Multiply that by every search box on every site: in the header, on the search page and in the mobile menu.
The idea: ship the whole list once
The client pointed at another of their sites as a reference. Its autocomplete doesn't ask the server anything while you type. It loads a list of everything that can be suggested, once, and searches it in the browser.
That works because the set of things worth suggesting is limited. Autocomplete on these sites suggests content, like articles and products, not every word on every page. That fits in one JSON file per site, and a single file that's the same for every visitor is exactly what a cache is good at.
[
{ "title": "Feeding your puppy", "url": "/articles/feeding-your-puppy" },
{ "title": "Puppy food, small breeds", "url": "/products/puppy-small-breed" },
...
]focus search -> GET autocomplete.json -> CDN cache (Drupal only on a miss)
type "p" -> Fuse.js, in the browser
type "pu" -> Fuse.js, in the browser
...Now a visitor makes one autocomplete request per page, and most of the time it's answered from the cache without reaching Drupal at all.
How the work was split
This was a team change, and it splits cleanly along layers:
- A custom Drupal module builds the JSON with everything autocomplete can suggest for a site, so it can be served from cache. A backend colleague wrote it.
- The themes load the JSON and render suggestions in the header search, the search page and the mobile search. Our front-end developer did that once, as a shared structure every theme uses.
- Search configuration: turning off Solr autocomplete and updating the index settings, which I did.
- The search page blocks were updated per site by our site builder.
Choosing the matching library
The reference implementation used Lunr.js, and it was suggested to us too. When we looked, Lunr's last release was about four years old. For something that would run on every page of every site for years, that was a risk we didn't need to take.
We went with Fuse.js instead. It's actively maintained, available on npm, has no dependencies, and does fuzzy matching. That matters for autocomplete, where people type half a word with a typo in it. Lunr is a full-text index with stemming, more than a list of titles needs. A minimal version of the client side looks like this:
import Fuse from "fuse.js";
const items = await fetch(AUTOCOMPLETE_URL).then((r) => r.json());
const fuse = new Fuse(items, { keys: ["title"], threshold: 0.3 });
input.addEventListener("input", () => {
const results = fuse.search(input.value).slice(0, 8);
render(results.map((r) => r.item));
});Turning off the old autocomplete
Switching each site over was a short checklist, done once per site:
- In the Search API index's autocomplete settings, untick everything and save. That stops the Solr autocomplete endpoints.
- Disable the old search block in the block layout.
- Enable
views_ui, which the edits below need, if it's off. - In the search results view, on the fulltext search filter: set the filter identifier to
query, copy the placeholder text from the site's search input, and make sure the searched fields include the content title. - Set the pager to "Display a specified number of items" with a high number (we used 999), so the results page shows everything that matches.
One visible side effect: the search page's URL parameter changed from q to query. If your old search URLs are linked from campaigns, bookmarks or Google, add a redirect that maps the old parameter to the new one.
How to check it
Open the browser's network tab, start typing in any search box, and keep typing. There should be exactly one autocomplete request, the JSON file, no matter how many characters you type. If you see a request per keystroke, the old autocomplete is still enabled somewhere.
Trade-offs
- Freshness. New content appears in suggestions when the JSON is rebuilt and the cache expires, not instantly. For editorial content that changes a few times a week, that's fine.
- Size. This only works while the list stays small. With tens of thousands of items, you'd be shipping a large file to every visitor for a feature most of them never use.
- Relevance. Fuse.js matches what's in the JSON, here titles. It doesn't know about body text or Solr's relevance tuning. The full search page still uses Solr; only the suggestions moved to the browser.
For content sites like these, the trade-offs were easy to accept. Typing no longer touches the backend at all, and search has been noticeably more stable since.