Fuzzy search that keeps up with typing: ranking 20,000 items in a VS Code webview

# javascript# typescript# algorithms# performance
Fuzzy search that keeps up with typing: ranking 20,000 items in a VS Code webviewAlex

Slashi, the VS Code extension I maintain, is a launcher: press Cmd/Ctrl + / and one popup searches...

Slashi, the VS Code extension I maintain, is a launcher: press Cmd/Ctrl + / and one popup searches commands, settings, files, Git actions and project scripts. Its whole value depends on typing feeling instant. If the list lags one keystroke behind, people go back to the Command Palette.

This post is how the ranking works, what it costs, and where I'd expect it to fall over.

Where to rank: the webview, not the extension host

The popup is a webview. The obvious design is to send each keystroke to the extension host, rank there, and post results back. That puts a round trip on every keystroke, through an extension host process that every other installed extension shares. When something else is busy, typing stutters, and you can't fix it from your side.

So Slashi sends the local catalogue (commands, settings, scripts, recents) to the webview once, when the popup opens, and ranks it in the webview as you type. Only catalogues too big to ship, like every file in the workspace, are ranked in the host.

That only works if the same ranking code runs in both places. The ranking lives in src/core/search.ts, which never imports the vscode module, so it bundles for Node and the browser alike.

Trick 1: lower-case once, when the index is built

The naive loop calls toLowerCase() on every field of every item on every keystroke. With 20,000 items and five fields, that's 100,000 string allocations per keystroke, before any scoring happens.

Instead, building the index stores a lower-cased copy of each field, plus one string with all of them joined:

export function indexItems(items: readonly ItemView[]): IndexedItem[] {
  return items
    .map((item) => ({
      item,
      name: item.name.toLowerCase(),
      title: item.title,
      titleLower: item.title.toLowerCase(),
      keywords: (item.keywords ?? []).join(' ').toLowerCase(),
      category: item.category.toLowerCase(),
      description: (item.description ?? '').toLowerCase(),
    }))
    .map((e) => ({ ...e, all: `${e.name} ${e.titleLower} ${e.keywords} ${e.category} ${e.description}` }));
}
Enter fullscreen mode Exit fullscreen mode

The original-case title is kept too, because the scorer needs it to spot camelCase humps.

Trick 2: reject most items before scoring them

Most items can't match a given query. A fuzzy match requires every typed character to appear in the item, in order, so one pass over the joined string rules an item out:

function isSubsequence(word: string, text: string): boolean {
  let from = 0;
  for (let i = 0; i < word.length; i++) {
    from = text.indexOf(word[i], from);
    if (from === -1) return false;
    from++;
  }
  return true;
}
Enter fullscreen mode Exit fullscreen mode

indexOf is native and fast, so this costs far less than scoring. Only items that pass go on to the field-by-field scorer.

Trick 3: compute highlights only for the rows on screen

The bold letters in each result need character positions, which means a second, more detailed pass. Ranking returns the top 60, and positions are worked out for those, not for the thousands of candidates that were scored.

The scoring, briefly

For each typed word, each field gets a score, weighted by field (name 1.25, title 1, keywords 0.75, category 0.45, description 0.35), and the best one counts. Within a field:

  • A contiguous match beats a scattered one, and a match at position 0 or at a word boundary gets a bonus. Boundaries are separators like space, -, ., / or :, or a camelCase hump, so fs finds Font Size and gcm can find Git: Commit.
  • Scattered matches of one- or two-letter words that score low are thrown away. They're mostly noise.
  • An exact slash-name match (/format) wins outright.
  • Things you've used often and recently get a frecency bonus, capped so it can't bury a much better text match.
  • On ties, shorter titles win, so "Format Document" comes before "Format Document With…".

None of these numbers came from theory. They're what made a few hundred real queries rank the way I expected, which is the honest description of most fuzzy-search tuning.

What it costs

npm run bench in the repo builds synthetic catalogues and runs eight queries 25 times each against every size, including one that matches nothing (xyzzy) and one exact slash command (/run npm test). I ran it on an Apple M2 Pro with Node 26.12, Slashi 0.6.0, October 2026:

Items Build index Average rank p95 rank
1,000 1.0 ms 0.30 ms 0.51 ms
5,000 2.9 ms 1.20 ms 2.18 ms
20,000 8.0 ms 4.65 ms 8.59 ms
50,000 19.2 ms 10.97 ms 19.82 ms

For reference, a 60 Hz display draws a frame every 16.7 ms. Up to around 20,000 items, ranking fits comfortably inside a frame; at 50,000, the slowest queries don't.

What this benchmark doesn't tell you:

  • It's synthetic. Items are built from a 20-word vocabulary, which makes them more similar to each other than real commands and settings are. That probably makes it pessimistic, but I haven't measured real catalogues at these sizes.
  • It runs in Node, not the webview. Same code, same V8, but not the same process, and it doesn't include rendering the list.
  • One machine. An older laptop will be slower. Re-run it: git clone https://github.com/meness/slashi && cd slashi && npm ci && npm run bench.

Where it would fall over

The approach scales linearly with catalogue size, and at 50,000 items the p95 already passes a frame. That's why workspace files, which can run into the hundreds of thousands, stay in the extension host behind a separate, capped index (slashi.files.maxIndexed, 20,000 by default). If I ever needed more, the next step would be a prefix index or a worker, not a cleverer scoring formula.

The scorer is also English-centric. Boundaries are ASCII separators and A–Z humps, so it does nothing useful for languages without word separators.

The takeaway

For in-memory fuzzy search, the scoring formula gets the attention, but the speed comes from work you avoid: lower-casing once, rejecting with a subsequence check, and only computing highlights you'll show. The full file is under 270 lines, with no dependencies: src/core/search.ts.


I'm the author of Slashi, the VS Code extension this code comes from. It's free and MIT-licensed: Marketplace · Open VSX · GitHub.