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 -> Solr

Five 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:

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:

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

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.

← All posts