fuse.js
Fuse.js is client-side fuzzy search with no dependencies and no backend. You hand it an array of objects and a list of keys to search, and it scores every record against the query using the Bitap algorithm, which measures approximate string similarity so that a query of javscript still finds JavaScript. Results come back sorted by a score where 0 is a perfect match and 1 is no match at all, optionally with the exact character ranges that matched so you can highlight them. There is no index server, no tokenizing pipeline, and no analyzer configuration; the whole dataset lives in memory in the browser or in your Node process. Version 7.5 adds two things worth knowing about: token search, which splits a multi-word query into terms, matches each one independently, and ranks with BM25-style weighting so rare words count more; and FuseWorker, which shards the collection across Web Workers so a large collection does not freeze the UI. The package ships full and basic builds, the basic one dropping extended, logical, and token search to save about two kilobytes.
The fastest way to put decent typo-tolerant search in front of a few thousand client-side records, with highlighting included and no infrastructure. Plan on an hour of tuning threshold, ignoreLocation, and per-key weights, because the out-of-the-box relevance is loose enough that people assume the library is broken.
Use it if
- Your searchable data is small enough to ship to the client, roughly a few thousand records, and you want instant results with no network round trip per keystroke
- You need typo tolerance rather than substring matching: a command palette, a country picker, a docs sidebar, or a settings search where users half-remember the wording
- You want match highlighting for free: includeMatches gives you character index ranges per key, which is tedious to compute yourself once fuzziness is involved
- You are searching structured objects, not strings: nested keys with dot paths, array fields, and per-key weights so a title match outranks a description match
- You want to add search to a static site with no infrastructure at all, since the index can be precomputed at build time and parsed back with Fuse.parseIndex
- Your collection is large. Every search scans every record against every key, so cost grows linearly with data and with query length; past roughly ten thousand records with several keys, per-keystroke search on the main thread becomes visible jank. FuseWorker moves it off the main thread but does not change the total work
- You need actual search engine behaviour. There is no stemming, no synonyms, no stop words, no language analysis, and no phrase or field query syntax beyond the extended operators. Searching for running will not find run
- You expected relevance to be tuneable by intuition. The defaults are threshold 0.6, location 0, and distance 100, which together mean the scorer strongly prefers matches near the start of a field and returns a lot of loose matches. Nearly every complaint about Fuse.js returning nonsense is those three options, and fixing it usually means setting ignoreLocation: true and dropping the threshold to somewhere near 0.3
- You need the score to mean something across queries. It is a relative distance for one pattern against one field, not a probability, so a cutoff you tuned on one query will silently filter out good results on another
- Your data is on the server and large. This is not a server-side search library in any useful sense; if the answer involves a database, use Postgres full text search, Meilisearch, or Typesense and stop shipping rows to the browser
- You are running the full build on a size budget for one simple string list. The full bundle is about 9 KB gzipped, and if you only need fuzzy matching over an array of strings, fuse.js/basic or a small Levenshtein helper does the job for less
Setup reality
npm install fuse.js and there is nothing else to install: zero dependencies, TypeScript types in the package, and an exports map covering ESM and CommonJS. The friction is entirely in configuration. A default Fuse with only keys set will return almost everything for almost any query, because threshold defaults to 0.6 and location scoring penalizes matches later in a field, so the first real task is tuning: set ignoreLocation: true unless you genuinely want position to matter, then lower threshold until the noise stops, then add minMatchCharLength so single characters do not match everything. Two structural gotchas follow. Building a Fuse instance re-indexes the whole collection, so constructing one inside a React render or a keystroke handler is why search feels slow; build it once with useMemo or at module scope and use fuse.add, fuse.remove, or fuse.setCollection for updates. And the Bitap implementation works on 32 character chunks, so very long queries get split into pieces and scored separately, which degrades ranking rather than erroring; useTokenSearch is the intended answer for long multi-word queries. Choose a build deliberately: the default entry is the full build, fuse.js/basic drops extended, logical, and token search, and fuse.js/worker is a separate entry with an async API where function options such as sortFn and getFn cannot be used because functions do not cross the worker boundary.
Patterns
Search an array of objectsbasic-search
import Fuse from "fuse.js";
const books = [
{ title: "Old Man's War", author: "John Scalzi" },
{ title: "JavaScript: The Good Parts", author: "Douglas Crockford" },
];
const fuse = new Fuse(books, { keys: ["title", "author"] });
fuse.search("javscript");
// [{ item: { title: 'JavaScript: The Good Parts', ... }, refIndex: 1 }]
fuse.search("crockford", { limit: 5 });Results are wrapped: item is your original object, refIndex is its position in the array you passed in. The constructor indexes the whole collection, so create it once and reuse it, not per keystroke. limit is a second argument to search, not an option on the constructor, and it truncates after sorting so it does not save any scanning work.
Fix the defaults before blaming the resultstune-relevance
const fuse = new Fuse(items, {
keys: ["title", "body"],
threshold: 0.3, // default 0.6 is very loose
ignoreLocation: true, // default false: matches far from index 0 score badly
minMatchCharLength: 2, // default 1: single letters match everything
includeScore: true,
});
// only if position really matters (matching an ID prefix, say)
new Fuse(items, { keys: ["sku"], location: 0, distance: 20, threshold: 0.2 });threshold is the maximum score allowed through, where 0 demands a perfect match and 1 lets everything through. location and distance define a window around the start of the field, and a match outside that window is penalized even if it is exact, which is why searching for a word in the middle of a long description returns nothing until you set ignoreLocation: true. These three are the source of most Fuse.js issue reports.
Weight fields and reach into nested dataweighted-and-nested-keys
const fuse = new Fuse(docs, {
keys: [
{ name: "title", weight: 3 },
{ name: "tags", weight: 2 }, // array of strings works
{ name: ["author", "lastName"], weight: 1 }, // nested path
"body", // implicit weight 1
{
name: "fullName", // computed field
getFn: (doc) => `${doc.first} ${doc.last}`,
},
],
ignoreFieldNorm: false,
});Weights are relative and get normalized, so 3/2/1 and 0.6/0.4/0.2 behave the same. Nested keys can be written as 'author.lastName' or as an array, and the array form is the one that survives a field name containing a dot. Field norm divides the score by the square root of the field length, so a hit in a two-word title beats the same hit in a long paragraph; set ignoreFieldNorm: true if you want length not to matter.
Get the character ranges and render themhighlight-matches
const fuse = new Fuse(items, {
keys: ["title"],
includeMatches: true,
includeScore: true,
minMatchCharLength: 2,
});
const [hit] = fuse.search("javscript");
hit.matches[0].indices; // [[0, 9]] inclusive start and end
function highlight(text, indices) {
let out = "", last = 0;
for (const [s, e] of indices) {
out += escapeHtml(text.slice(last, s)) + "<mark>" +
escapeHtml(text.slice(s, e + 1)) + "</mark>";
last = e + 1;
}
return out + escapeHtml(text.slice(last));
}Indices are inclusive on both ends, so the slice needs e + 1; off-by-one here is the usual reason highlights come out one character short. Fuzzy matching produces many tiny disjoint ranges, so raise minMatchCharLength or the output looks like confetti. Always escape the surrounding text before inserting the mark tags, since this is user-controlled content going into innerHTML.
Give power users query syntaxextended-search-operators
const fuse = new Fuse(list, { keys: ["title", "lang"], useExtendedSearch: true });
fuse.search("jscript"); // fuzzy, the default behaviour
fuse.search("=scheme"); // exact whole-field match
fuse.search("'python"); // includes this exact substring
fuse.search("!ruby"); // does NOT include
fuse.search("^java"); // field starts with
fuse.search(".js$"); // field ends with
fuse.search("!^java"); // does not start with
fuse.search("^core go$"); // space means AND
fuse.search("^java | ^python"); // pipe means ORSpace is AND and the pipe is OR, so a user typing a two-word phrase gets an AND query, not a phrase search. Quote a term to include spaces in it, as in ='exact phrase' style with double quotes after the operator. These operators are not available in the basic build, and they are not sanitized: a query that is entirely operators returns whatever the parser makes of it, so validate before showing results to end users.
Multi-word queries with per-word typo tolerancetoken-search
const fuse = new Fuse(docs, {
useTokenSearch: true,
keys: ["title", "body"],
tokenMatch: "any", // 'all' requires every term to match
});
fuse.search("javascrpt paterns");
// finds "JavaScript Patterns" despite two typos in two words
// custom tokenizer for terms with internal punctuation, or CJK
new Fuse(docs, {
useTokenSearch: true,
keys: ["title"],
tokenize: /[\w.+#-]+/g,
});Each term is fuzzy-matched independently and ranked with IDF weighting, so rare words count more than common ones and word order stops mattering. This is the right mode for a search box where people type sentences, and it also sidesteps the 32 character chunking that degrades long single-pattern queries. It is full-build only, it cannot be used with Fuse.match, and tokenMatch: 'all' turns the search into a filter that returns nothing when one word is misspelled beyond the threshold.
Do not rebuild the index on every renderbuild-instance-once
import { useMemo, useState } from "react";
import Fuse from "fuse.js";
function Search({ items }) {
const [q, setQ] = useState("");
const fuse = useMemo(
() => new Fuse(items, { keys: ["title"], threshold: 0.3, ignoreLocation: true }),
[items],
);
const results = useMemo(
() => (q ? fuse.search(q, { limit: 20 }) : items.slice(0, 20).map((item) => ({ item }))),
[q, fuse, items],
);
// ...
}new Fuse(...) walks the entire collection and builds the index, so putting it in the render body means re-indexing on every keystroke and is the number one cause of slow Fuse.js. Note the options object must also be stable, since an inline literal is a new reference each render and would defeat the memo if you listed it as a dependency. An empty query returns an empty array rather than everything, so handle that case yourself.
Add and remove documents without rebuildingmutate-collection
const fuse = new Fuse(items, { keys: ["title"] });
fuse.add({ title: "New Book", author: "New Author" });
fuse.remove((doc) => doc.title === "Old Book"); // returns removed docs
fuse.removeAt(3);
fuse.setCollection(nextItems); // wholesale replace, re-indexes
fuse.getIndex().size();add and remove update the index incrementally, which is much cheaper than constructing a new instance when a websocket pushes one row. remove takes a predicate and runs it over every document, so it is a linear scan; batching several deletions into one predicate call is worth it. setCollection re-indexes everything, so it is a rebuild by another name and belongs outside the hot path.
Build the index at build time, parse it at runtimeprecomputed-index
// build step (Node)
import Fuse from "fuse.js";
import fs from "node:fs";
const keys = ["title", "body"];
const index = Fuse.createIndex(keys, docs);
fs.writeFileSync("search-index.json", JSON.stringify(index.toJSON()));
// runtime (browser)
const raw = await fetch("/search-index.json").then((r) => r.json());
const index = Fuse.parseIndex(raw);
const fuse = new Fuse(docs, { keys }, index);This moves index construction off the client, which matters for a static site with a few thousand pages where the build already has the data. The keys array must be identical on both sides or matching silently misbehaves. You still have to ship the documents themselves, so this saves CPU on load, not bytes; if payload size is the problem, ship only the fields you display and look the rest up by id.
Search off the main threadweb-worker
import { FuseWorker } from "fuse.js/worker";
const fuse = new FuseWorker(docs, {
keys: ["title", "author", "description"],
threshold: 0.3,
ignoreLocation: true,
});
const results = await fuse.search("query");
// clean up when the component unmounts
fuse.terminate();Same options and same result shape as Fuse, but every method is async because the data is sharded across workers. Function-valued options do not survive the structured clone, so sortFn, getFn, and per-key getFn are unavailable; anything computed has to be a real field on the document. Call terminate() or the workers leak, and remember the documents are copied into each worker, so memory use is roughly multiplied by the shard count.
Compare one string without building an indexmatch-single-string
import Fuse from "fuse.js";
Fuse.match("javscript", "JavaScript: The Good Parts");
// { isMatch: true, score: 0.04, indices: [[0, 9]] }
Fuse.match("xyz", "JavaScript", { threshold: 0.2 });
// { isMatch: false, ... }
// filter an array yourself when you only need a boolean
const hits = names.filter((n) => Fuse.match(q, n, { threshold: 0.3 }).isMatch);Useful for a one-off comparison, a custom filter, or validating a guess, with no index build cost. It cannot do token search, because that needs corpus-level statistics, and passing useTokenSearch: true throws an explicit error rather than degrading. Calling this in a loop over a big array is slower than one Fuse instance, since you lose the precomputed field norms.
Pick the basic build when you only need fuzzysmaller-build
// full build: fuzzy + extended + logical + token search
import Fuse from "fuse.js";
// fuzzy only, roughly two kilobytes smaller
import Fuse from "fuse.js/basic";
// pre-minified entries, for direct browser loading
import Fuse from "fuse.js/min";
import Fuse from "fuse.js/min-basic";The basic build silently ignores useExtendedSearch, useTokenSearch, and logical query objects, so a query that worked in development against the full build can quietly stop filtering in production if the import changed. Prefer the plain entries and let your bundler minify; the /min entries exist for script tags and CDN use where nothing else will.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| minisearch | npm | You want a real inverted index in the browser with prefix search, stemming hooks, and better scaling past ten thousand documents |
| flexsearch | npm | Raw query speed on larger in-memory collections matters more than a friendly API or predictable defaults |
| @orama/orama | npm | You want typo tolerance plus filters, facets, and optional vector search from one library that also runs on the server or an edge runtime |
| @leeoniya/ufuzzy | npm | You are fuzzy-filtering a flat list of strings and want the smallest, fastest option with ordering you can reason about |