Engineering 35 min read

Searching Your Notes in the Browser: FlexSearch vs MiniSearch vs Fuse.js, Measured

MMNMNOTE
engineeringsearchjavascriptbrowserbenchmarklocal-firstflexsearchminisearchfusejsgithub

Reference (subject repos): nextapps-de/flexsearch — Apache-2.0 · JavaScript · lucaong/minisearch — MIT · TypeScript · krisk/Fuse — Apache-2.0 · TypeScript. All three tested at their current npm-published versions on Node 20.19.5.

Three JavaScript libraries dominate the "how do I search notes in the browser" question, and every head-to-head thread ends the same way: someone benchmarks a 5,000-word Wikipedia corpus, declares a winner, and the thread dies. That is not the question. The question is which shape you need — an inverted index or a fuzzy-string matcher — and the shape decides the winner before you ever pick a name. This post picks the shape first, then measures the three names against a 5,000-note synthetic vault so you can see where each one lives, where each one dies, and where the numbers stop being the point.

The full harness — both scripts, unmodified — is in the "How do I run the benchmark on my own corpus?" section below; every number in this piece is one node bench.js away from being reproduced or refuted on your own machine.

Browser-side search is a different problem because the disk is not reachable. When the vault is a folder you can shell into, ripgrep beats every JavaScript library — the case handled in Search Beats Folders. Inside a mobile PWA or offline site, the index has to live inside the browser, and every millisecond belongs to the main thread.

That constraint changes the trade-offs. You are trading index-build time (paid on cold start) against query latency (paid on every keystroke), and index size (paid against your origin's storage quota). MDN's current storage-quotas reference puts Chromium origins at "up to 60% of the total disk size in both persistent and best-effort modes"1 and Firefox origins at "whichever is the smaller of: 10% of the total disk size where the profile of the user is stored. Or 10 GiB, which is the group limit that Firefox applies to all origins that are part of the same site"1. That is enormous — but the index competes with the notes themselves and with whatever else the origin caches, so smaller-is-better is not a lint, it is a survival trait.

The disk companion piece argues that most people do not need an index at all. This piece is for when you do.

Which library claims what — and why the claims disagree

The three READMEs are answering different questions. FlexSearch positions itself as "Next-Generation full-text search library for Browser and Node.js"2 — fastest inverted index on the block. MiniSearch says "tiny but powerful in-memory fulltext search engine … respectful of resources"3 — scale-down, not scale-up. Fuse.js declares "lightweight fuzzy-search"4 — a Bitap matcher, not a full-text index.

FlexSearch's maintainer claim runs harder still: "FlexSearch performs queries up to 1,000,000 times faster compared to other libraries"2 — a self-benchmark on the maintainer's own harness, meant to be read as the aggressive scale-up story. MiniSearch's "respectful" line is doing the opposite work — the design brief that follows is deliberate memory frugality, aimed at mobile and embedded devices. Fuse.js's "zero dependencies" is smaller-still: a single algorithm, Bitap, applied character-by-character to each field of each doc, with no term dictionary at all.

Kirollos Risk, Fuse.js's author, states the axis directly in the docs: "Fuse.js matches based on characters — it finds strings that look like what the user typed, tolerating typos, transpositions, and partial input. Semantic search matches based on meaning — it converts text into high-dimensional vectors using a language model, then finds items whose vectors are close together in that space."5. That is the shape question — character-level fuzzy match versus a term-level inverted index versus a vector store — and it is not a benchmark decision. Every measured number below is downstream of it.

MiniSearch's author Luca Ongaro is even more explicit about who the library is for: "A prominent use-case is real time search 'as you type' in web and mobile applications, where keeping the index on the client enables fast and reactive UIs, removing the need to make requests to a search server."6. That is the design brief. If your workload does not match one of these three briefs, you are about to burn a week fighting a library.

Ecosystem receipts — versions, licences, and who ships what

Before any benchmark number lands, the ecosystem story has to be pinned to the actual state of the world. A live GitHub-API check on 2026-07-07 corrects three factual errors carried in the AI-echoed folklore — the versions and licences those answers repeat are stale. Here is what the three repos actually ship today, plus Lunr as ambient background:

LibraryVersion (npm, 2026-07-07)LicenseStarsLast push
FlexSearch0.8.212Apache-2.013,7412026-06-28
MiniSearch7.2.0MIT6,0282025-09-16
Fuse.js7.4.2Apache-2.020,3652026-07-03
Lunr2.3.9MIT9,2022024-07-31

The Apache-2.0 versus MIT split matters if you are shipping a commercial product — Apache-2.0's patent-grant clause makes it the safer default; MiniSearch's MIT is looser but carries no explicit patent grant. Lunr is on the list as ambient background: it is the library MiniSearch and FlexSearch have been benchmarking against for years, and its repo has been quiet since July 2024. If you land on a 2019 Medium comparison, this is the ranking function you are looking at.

None of these numbers are the point of the post. They are the ground floor: verified live before the bench ran, so the measurements below are talking about the libraries that actually exist in July 2026.

flowchart TD
  Q["User query<br/>('reterival', 'orbit throughput')"] --> S{"What shape<br/>do you need?"}
  S -->|"Terms + ranking"| I["Inverted index<br/>(FlexSearch, MiniSearch, Lunr)"]
  S -->|"Typos + partial"| F["Fuzzy string<br/>(Fuse.js — Bitap)"]
  S -->|"Vault outgrew RAM"| W["SQLite-WASM<br/>FTS5 + OPFS"]
  I --> P["Persistence<br/>IndexedDB / JSON blob"]
  F --> P
  W --> O["OPFS-backed<br/>DB file"]
  P --> R["Ranked hits<br/>(BM25 / TF-IDF / score)"]
  O --> R

Figure 1 — The shape question: an inverted index ranks tokens across documents, a fuzzy-string matcher scores approximate matches character-by-character, and a WASM-backed SQLite database with FTS5 tokenizers is the escape hatch when neither JS shape fits in memory. The persistence layer is orthogonal — every library ships JSON-serialisable index bytes, and it is the caller's job to put them somewhere durable.

The benchmark — what was measured, how

The point of a piece like this is that you can rerun it. The harness below is two files: generate-corpus.js deterministically builds a 5,000-note Markdown vault (10 MB, 1.58M words), and bench.js runs each library through a 30-query set — three warmups plus twenty measured iterations per shape.

The corpus is Mulberry32-seeded at 0xC0FFEE — same generator, same output — so any two runs on the same hardware are comparable to within noise. The three query shapes were chosen to stress-test each library where it lives:

Each library is given the same three fields (title, body, tags), a limit: 20 cap on returned hits, and a threshold: 0.3 on Fuse.js (typical typo tolerance from the docs). Node 20.19.5, single-threaded, no worker. results.json is written next to the harness so the numbers below are auditable.

That is enough setup. Here is what fell out.

Build cost — 50ms to 2.4s, and index size ranges 3.5x

Index construction is a cold-start tax you pay once per session, or every time the browser evicts the cache. The three libraries land in wildly different places, and the difference tracks what each one actually does at build time versus what it defers to query time. This is the first table where the shape question shows its hand:

LibraryBuild (5,000 notes)Serialised indexRSS delta
FlexSearch 0.8.2122.4s20.1 MB+75.2 MB
MiniSearch 7.2.01.3s5.6 MB+10.2 MB
Fuse.js 7.4.250 ms10.1 MB+0.25 MB

Fuse.js's 50ms build is not a magic trick — it is a Bitap matcher that does not really build an index; it prepares its lookup structures lazily and does the work at query time. FlexSearch's 2.4s build produces a heavily-tokenised forward-index optimised for query speed, and its RSS delta of ~75 MB is the cost of that trade. MiniSearch lands in the middle in every column and in the best column for the one that matters most — its serialised index is 5.6 MB, roughly the ratio Ongaro described in his 2019 note that "MiniSearch typically uses less than half of the space used by Lunr, for the same collection"7. The ratio holds against a real vault seven years later.

"This was one of the main design goals of MiniSearch, in order to support memory-constrained cases like mobile apps,"8 Ongaro wrote in the same post. On a phone with 6 GB of RAM competing against ten other tabs, a 5.6 MB index that survives a background suspend is a very different asset from a 20 MB index that has to be rebuilt from IndexedDB on wake.

Query latency — the numbers that tell the shape story

Query latency is where the shape question stops being philosophy and turns into a p95. Below is the p50/p95 for a batch of ten queries — in milliseconds — across each shape. Three numbers land two orders of magnitude apart, and the ordering is not what a first-time reader of the READMEs would guess:

Library / shapeExact (p50 / p95)Prefix (p50 / p95)Fuzzy (p50 / p95)
FlexSearch 0.8.2121.06 / 2.30 ms0.02 / 0.09 ms0.02 / 0.04 ms
MiniSearch 7.2.052.5 / 71.8 ms21.6 / 26.8 ms17.9 / 28.8 ms
Fuse.js 7.4.214,427 / 14,714 ms3,969 / 4,037 ms6,929 / 7,184 ms

Two things jump out. First, FlexSearch is one to two orders of magnitude faster than MiniSearch on every shape — the maintainer's "1,000,000 times faster" claim is a self-benchmark, but the direction of the finding replicates. FlexSearch is genuinely fast. Second, Fuse.js is off the chart in the wrong direction: 14 seconds for a batch of ten exact multi-word queries against a 5,000-note vault is not a benchmark you build UI on. That is the Bitap algorithm doing character-by-character work against every key of every doc, and it does not scale.

This does not mean Fuse.js is broken. It means the numbers on fusejs.io/performance.html — where the maintainer's own table shows "1,000: ~3ms · 10,000: ~28ms · 50,000: ~147ms · 100,000: ~299ms" for index creation without token search, and "~17ms / ~182ms / ~963ms / ~2,061ms" with it9 — are index-creation numbers, not per-query numbers, and they are measured on a corpus with three simple string keys, not multi-thousand-word Markdown bodies. Change the shape of the field to a 300-word body, and the query cost climbs steeply. This is not a hidden gotcha — the maintainer's own doc opens with "The most reliable way to answer 'is Fuse.js fast enough for my use case?' is to measure it." Do that. The answer for note-body search at 5,000 items is no, use an inverted index.

Fuzzy recall — where Fuse.js earns its slot back

Latency is only half the answer. Recall on typos — the queries a person actually types when their fingers race their brain — is the other half, and the ordering here inverts the latency table completely. Below is the percentage of ten deliberately-misspelled queries that returned at least one hit against the same corpus, with each library's default fuzzy configuration:

LibraryFuzzy recall (typo queries returning ≥ 1 hit)
FlexSearch 0.8.21210%
MiniSearch 7.2.070%
Fuse.js 7.4.290%

FlexSearch at its default configuration returned nothing for nine of ten typo queries. MiniSearch, with fuzzy: 0.2 at query time, caught seven. Fuse.js caught nine out of ten — which is exactly what the library is for. The Bitap algorithm's whole reason to exist is character-distance tolerance, and against "kompas" for "compass" and "reterival" for "retrieval" it does what nothing else does.

So the shape question was not rhetorical. Fuse.js is slow at 5,000 notes on multi-field body search and is the only one that finds the note when the user typed "quantiztaion". If your product is a command palette over a 200-item menu, that is exactly the trade you want. If your product is a note vault at 5k+ items, it is exactly the trade you cannot afford — and the honest fix is a two-stage pipeline: MiniSearch for the primary index, Fuse.js scoped to the 20 hits MiniSearch returned so typos still work on a survivor set.

That two-stage pattern shows up in production more often than any single-library answer.

Where does the index actually live — and does it survive?

An in-memory index is a lie your app tells itself. The moment the tab is suspended or storage cleared, the index is gone and you are back to the 1.3s–2.4s build. Persistence is the difference between an instant search bar and a two-second stall on every page load. Each library ships a different answer.

MiniSearch has no built-in storage; you serialise it with JSON.stringify(mini) and write the string wherever you want — IndexedDB is the standard answer. FlexSearch v0.8 shipped a Persistent Indexes system with adapters for IndexedDB, SQLite, Postgres, MongoDB, Redis, and Clickhouse — a real design decision, aimed at teams that want the index to outlive a session. Fuse.js expects you to serialise its index via Fuse.createIndex() and Fuse.parseIndex() and manage the storage yourself.

The MDN quota numbers set the floor. Chromium: up to 60% of total disk size. Firefox: the smaller of 10% of profile-disk or 10 GiB, with persistent origins going up to 50%/8 TiB1. On a 512 GB laptop, that is hundreds of gigabytes of budget — but the origin shares it with every other cache the app holds, and the browser will evict best-effort-mode data before it evicts persistent-mode data. If your index is load-bearing, request persistent storage with navigator.storage.persist() and check that the user granted it — that is the difference between an index that survives a Monday morning and one that rebuilds from the source Markdown every time.

For a vault that keeps growing, the JS-library ceiling arrives faster than you expect. The next step out is SQLite compiled to WebAssembly.

The escape hatch — SQLite-WASM + FTS5

When the vault outgrows an in-memory JavaScript index, the next step is FTS5 — described in the SQLite docs as "an SQLite virtual table module that provides full-text search functionality to database applications"10. Compiled to WebAssembly and paired with the browser's Origin Private File System, you get a real SQL database that persists as a file, not a JSON blob.

FTS5 in the browser gives you a Unicode61 tokenizer, prefix indexes, a trigram tokenizer for substring search, and the bm25() auxiliary function with tunable k1/b parameters. Queries look like SELECT * FROM notes WHERE notes MATCH 'orbit AND throughput' ORDER BY bm25(notes) — familiar and boring, which is exactly what you want at scale. The OPFS docs note directly that "The OPFS is subject to browser storage quota restrictions, just like any other origin-partitioned storage mechanism (for example IndexedDB API)"11 — so the quota story is the same as before, just with a real SQLite file underneath instead of a serialised index.

The trade is honest. SQLite-WASM's bundle adds hundreds of kilobytes to first-load cost, the WASM engine has a cold-start warmup, and OPFS is Chromium-first — Firefox and Safari have progressed but the story is not uniform. If your vault is under about 10,000 notes and your first-paint budget is tight, one of the three JS libraries still wins. If the vault is 50,000+ notes, a SQL-shaped query language matters, or you want ranking parameters you can actually tune, FTS5 is the answer and it does not become less true because it is boring.

Ranking — and why "which BM25" is not the decision

Every inverted-index library ships a ranking function, and the space of ranking functions is a distraction. The honest ceiling comes from Trotman, Puurula, and Burgess, who compared nine BM25-family variants against the INEX and TREC collections in 2014 and reported: "there is very little difference in performance between these functions"12 once each was properly tuned. That predates every library here.

What matters is not which variant a library ships with; what matters is tuning it — the k1 and b parameters that control term-frequency saturation and document-length normalisation. MiniSearch uses a BM25+ variant and exposes the parameters as bm25 search options. Fuse.js v7.4 added a Token Search layer with "BM25-style IDF weighting" on top of its Bitap core. FlexSearch uses its own contextual scoring, not classical BM25. All three are within noise of each other on relevance for the corpora most local-first apps have; the win comes from feeding the ranker a clean tokenised body, not from library-shopping.

If you are chunking the source Markdown before indexing — stripping frontmatter, removing code fences from the body, splitting long notes on H2 boundaries — that decision matters more than the ranker. That is the ground the How You Split a Note Decides What an AI Finds in It piece covers; the ranking function is downstream of it. And the whole lexical-versus-vector discussion from Everyone Says Vector Databases Are the Wrong Abstraction — the argument that at small scale lexical beats vector — is what makes any of this the right conversation at all.

The honest recommendation — pick the shape, then the name

The recommendation is not a leaderboard. It is a decision tree with five leaves — each falling out of the shape question at the top of the piece — and the measured numbers above decide which leaf you land on. Read the row matching your vault size, latency budget, and primary query shape:

There is a way to read the whole exercise that is more useful than the recommendations: none of these numbers change the fact that your notes are a folder of Markdown files. The search index is an accelerator over that folder — a JS library reindexed on load, an OPFS-backed SQLite file, or grep across the folder from a shell prompt. The primary source is the folder. Whichever library you pick, keep the notes as files you own — the index is a cache; the notes are the thing.

The search question above is one every local-first note tool that ships in a browser has to answer for itself, and the shape decides more than the name.

Frequently Asked Questions

FlexSearch vs MiniSearch vs Fuse.js — which one should I actually pick?

Pick the shape before the name. If your primary need is fast multi-word search over 5,000+ notes, use FlexSearch (2ms p95 exact search in this benchmark, 20MB index, Apache-2.0). If your primary need is as-you-type prefix search on a memory-constrained device, use MiniSearch (5.6MB index, ~20ms prefix queries, MIT). If your primary need is typo tolerance on under-1,000 items, use Fuse.js — it caught 90% of typo queries in this benchmark, and its Bitap algorithm is what it was designed for.

Is Fuse.js fast enough for my use case?

For small collections (under about 1,000 items) with short searchable strings, yes — Fuse.js's own published table puts index creation at ~3ms for 1,000 items9. For 5,000-note vaults with 300-word Markdown bodies, the exact multi-word queries in this benchmark took a p50 of 14.4 seconds. The maintainer's advice — "The most reliable way to answer 'is Fuse.js fast enough for my use case?' is to measure it" — is the right answer. Measure it on your corpus, not on the docs sample.

Can I use these libraries to search Markdown notes in the browser?

Yes. Parse the Markdown to plain text (strip fences, frontmatter, and inline formatting) and index the plain text under a body field. All three libraries treat the corpus as opaque strings — they do not know about Markdown, and they do not need to. The parsing step is independent of the library choice, and the note-splitting decision matters more for retrieval quality than the ranking function does.

Where does the search index actually live in the browser?

In memory during the session; persistence is on you. Serialise with JSON.stringify (MiniSearch, Fuse.js) or .export() (FlexSearch v0.8 Persistent Indexes) into IndexedDB. Chromium origins can use "up to 60% of the total disk size in both persistent and best-effort modes"1 and Firefox the smaller of 10% of profile-disk or 10 GiB1 — plenty of room, but call navigator.storage.persist() if the index is load-bearing, or the browser may evict it under storage pressure.

Should I skip these JS libraries and use SQLite-WASM + FTS5 instead?

Not at 5,000 notes. The three JS libraries are simpler to embed, faster to first-paint, and have no WASM cold-start. Above ~20,000 notes, or if you want ranker tuning and a SQL query language, SQLite-WASM + FTS5 in OPFS is the well-worn escape hatch — the SQLite docs describe FTS5 as "an SQLite virtual table module that provides full-text search functionality to database applications"10, and the bm25() auxiliary function exposes the k1 and b parameters directly. Pay the bundle-size and cold-start cost once, get a persistent SQL database in return.

How do I run the benchmark on my own corpus?

Save the two files below into an empty folder, run npm install flexsearch minisearch fuse.js, then node generate-corpus.js && node bench.js. To use your own notes instead of the synthetic vault, replace generate-corpus.js's synthesizer with a loader that writes any array of { id, title, body, tags } objects to corpus.jsonbench.js does not care where the array came from. results.json will contain the same fields shown in this piece — build time, index size, per-shape p50/p95 latency, and fuzzy recall — for your corpus, on your machine. That is the point of publishing the harness: the numbers above are one config; the ones you care about are the ones on your data.

generate-corpus.js — the deterministic synthetic-vault generator used for every number above:

// generate-corpus.js — deterministic synthetic Markdown-note vault
// -----------------------------------------------------------------------
// Produces `corpus.json` — an array of { id, title, body, tags } objects
// modelled on a personal-notes vault (meeting notes, book highlights,
// project logs, journal, code snippets, recipes). Deterministic — same
// output every run so any two runs of the benchmark are comparable.
//
// Zero external deps; Mulberry32 LCG-style seeded PRNG.

import fs from "node:fs";

const N = Number(process.env.N ?? 5000);
const OUT = process.env.OUT ?? "corpus.json";

// Mulberry32 PRNG — deterministic, seedable, ~2^32 period, good enough.
function rng(seed) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6D2B79F5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
const rand = rng(0xC0FFEE);
const pick = (a) => a[Math.floor(rand() * a.length)];
const pickN = (a, k) => {
  const s = new Set();
  while (s.size < k) s.add(pick(a));
  return [...s];
};

// -- vocab: recurring terms so an inverted index has real term frequency --
const PROJECTS = ["orbit", "atlas", "compass", "harbor", "keystone", "meridian", "beacon", "lantern", "delta", "prism"];
const PEOPLE = ["Ada", "Nikolai", "Mei", "Priya", "Rafael", "Yuki", "Hana", "Omar", "Lena", "Kaz"];
const TOPICS = ["retrieval", "embedding", "tokenization", "quantization", "indexing", "sharding", "caching", "throughput", "latency", "compression", "durability", "concurrency", "batching", "streaming", "consistency"];
const TOOLS = ["ripgrep", "SQLite", "Postgres", "Redis", "Kafka", "DuckDB", "Elasticsearch", "Meilisearch", "IndexedDB", "OPFS"];
const VERBS = ["measure", "profile", "reproduce", "isolate", "benchmark", "trace", "shadow", "gate", "back-fill", "roll out"];
const NOUNS = ["latency", "throughput", "footprint", "regression", "hotspot", "cold-start", "invariant", "guardrail", "boundary", "checkpoint"];
const ADJ = ["idempotent", "monotonic", "eventual", "durable", "load-bearing", "opinionated", "reproducible", "hermetic", "streaming", "amortized"];
const KINDS = ["meeting", "book-highlight", "project-log", "journal", "recipe", "snippet", "spec", "faq"];

// -- sentence templates: mix of prose the reader would actually search --
const SENT = [
  () => `${pick(PEOPLE)} landed the ${pick(TOPICS)} change on ${pick(PROJECTS)}; p95 dropped ${5 + Math.floor(rand() * 40)}%.`,
  () => `The ${pick(TOOLS)} query took ${1 + Math.floor(rand() * 300)}ms on a ${1 + Math.floor(rand() * 100)}k-row table.`,
  () => `Note to self: ${pick(VERBS)} the ${pick(NOUNS)} before the next rollout.`,
  () => `Reading Kleppmann on ${pick(TOPICS)} — the ${pick(ADJ)} case is the one people forget.`,
  () => `${pick(PEOPLE)} says the ${pick(TOPICS)} pipeline is bottlenecked on ${pick(NOUNS)}, not on ${pick(TOOLS)}.`,
  () => `Try a ${pick(ADJ)} ${pick(TOOLS)} index first; measure before you switch.`,
  () => `Recipe: reduce heat, add butter, whisk until the ${pick(NOUNS)} disappears.`,
  () => `Question for ${pick(PEOPLE)}: is ${pick(PROJECTS)}'s ${pick(TOPICS)} really ${pick(ADJ)}?`,
  () => `\`${pick(VERBS)}(${pick(NOUNS)})\` returns a ${pick(ADJ)} handle — remember to close it.`,
  () => `We spent the day on ${pick(TOPICS)}; ${pick(PEOPLE)} pushed the fix by ${1 + Math.floor(rand() * 12)}pm.`,
  () => `The ${pick(TOOLS)} adapter is ${pick(ADJ)} at scale, ${pick(ADJ)} at low load — pick your poison.`,
  () => `Rule of thumb: never ${pick(VERBS)} a ${pick(NOUNS)} you can measure.`,
];

const TITLES = [
  ({p, t}) => `Notes on ${t} for ${p}`,
  ({p, k}) => `${k} — ${p}, ${new Date(Date.now() - Math.floor(rand()*1e10)).toISOString().slice(0,10)}`,
  ({t, tool}) => `Why ${tool} feels slow at ${t}`,
  ({person, t}) => `${person} on ${t}`,
  ({t}) => `${t.replace(/^\w/, c => c.toUpperCase())}: what to measure first`,
  ({p, t}) => `${p}/${t} — open questions`,
];

function makeBody(rand) {
  const paragraphs = 3 + Math.floor(rand() * 8);   // 3–10 paragraphs
  const parts = [];
  for (let i = 0; i < paragraphs; i++) {
    const sentences = 2 + Math.floor(rand() * 5);  // 2–6 sentences
    const buf = [];
    for (let j = 0; j < sentences; j++) buf.push(SENT[Math.floor(rand() * SENT.length)]());
    parts.push(buf.join(" "));

    // sprinkle structure: ~15% of paragraphs become a list, ~10% code
    const r = rand();
    if (r < 0.15) {
      const items = 3 + Math.floor(rand() * 4);
      const lines = [];
      for (let k = 0; k < items; k++) lines.push(`- ${pick(VERBS)} the ${pick(NOUNS)} on ${pick(PROJECTS)}`);
      parts.push(lines.join("\n"));
    } else if (r < 0.25) {
      parts.push("```js\n" + `const ${pick(NOUNS)} = ${pick(VERBS).replace(/-/g,"_")}("${pick(TOPICS)}");\n` + "```");
    }
  }
  return parts.join("\n\n");
}

const notes = [];
for (let i = 0; i < N; i++) {
  const p = pick(PROJECTS);
  const t = pick(TOPICS);
  const tool = pick(TOOLS);
  const person = pick(PEOPLE);
  const k = pick(KINDS);
  const titleFn = pick(TITLES);
  const title = titleFn({ p, t, tool, person, k });
  const body = makeBody(rand);
  const tags = pickN([...TOPICS, ...PROJECTS, ...KINDS], 3 + Math.floor(rand() * 3));
  notes.push({ id: `n${i + 1}`, title, body, tags });
}

fs.writeFileSync(OUT, JSON.stringify(notes));
const bytes = fs.statSync(OUT).size;
const words = notes.reduce((s, n) => s + n.body.split(/\s+/).length, 0);
console.log(`generated ${N} notes → ${OUT}  (${(bytes / 1024 / 1024).toFixed(2)} MB, ${words.toLocaleString()} words, ${(words / N).toFixed(0)} words/note avg)`);

bench.js — the head-to-head harness (import path assumes flexsearch, minisearch, fuse.js are installed):

// bench.js — head-to-head benchmark for FlexSearch, MiniSearch, Fuse.js
// -----------------------------------------------------------------------
// Runs the three libraries against a common corpus + query set and reports
// index-build time, serialized-index size, cold + hot query latency (p50/p95),
// and precision on typo-tolerant queries. Written to be reproducible: same
// corpus (deterministic generator) + same query set + same node version =>
// stable numbers within noise.
//
// Version stamp:
//   flexsearch 0.8.212   (Apache-2.0, nextapps-de/flexsearch)
//   minisearch 7.2.0     (MIT,        lucaong/minisearch)
//   fuse.js    7.4.2     (Apache-2.0, krisk/Fuse)
// Node: v20.19.5.

import fs from "node:fs";
import { performance } from "node:perf_hooks";
import { Document as FlexDocument } from "flexsearch";
import MiniSearch from "minisearch";
import Fuse from "fuse.js";

const CORPUS_PATH = process.env.CORPUS ?? "corpus.json";
const notes = JSON.parse(fs.readFileSync(CORPUS_PATH, "utf8"));
console.log(`corpus: ${notes.length} notes  (${(fs.statSync(CORPUS_PATH).size / 1024 / 1024).toFixed(2)} MB on disk)`);

// -- query set: three shapes, chosen deliberately to exercise each library --
const QUERIES = {
  exact: [        // multi-word queries an inverted index should nail
    "orbit retrieval",
    "atlas throughput",
    "SQLite index",
    "Nikolai embedding",
    "durable checkpoint",
    "Postgres latency",
    "compass caching",
    "Priya quantization",
    "streaming boundary",
    "ripgrep tokenization",
  ],
  prefix: [       // as-you-type prefix queries (the MiniSearch story)
    "reti",       // "retrieval"
    "quan",       // "quantization"
    "thro",       // "throughput"
    "harb",       // "harbor"
    "post",       // "Postgres"
    "meil",       // "Meilisearch"
    "prism",
    "meridi",
    "duckd",      // "DuckDB"
    "elast",
  ],
  fuzzy: [        // typos an end user would make (the Fuse.js story)
    "reterival",         // retrieval
    "quantiztaion",      // quantization
    "PostgeSQL",         // Postgres
    "ripgep",            // ripgrep
    "throughtput",       // throughput
    "tokinization",      // tokenization
    "meridiean",         // meridian
    "kompas",            // compass
    "duckdb ",           // trailing whitespace / mild noise
    "elstic search",     // elasticsearch (spaced typo)
  ],
};

// -- timing helper: N warmups then M measured iterations, return p50/p95 --
function measure(fn, warmup = 3, iters = 20) {
  for (let i = 0; i < warmup; i++) fn();
  const t = [];
  for (let i = 0; i < iters; i++) {
    const s = performance.now();
    fn();
    t.push(performance.now() - s);
  }
  t.sort((a, b) => a - b);
  const p50 = t[Math.floor(t.length * 0.5)];
  const p95 = t[Math.floor(t.length * 0.95)];
  const min = t[0];
  const mean = t.reduce((a, b) => a + b, 0) / t.length;
  return { p50, p95, min, mean };
}

// -- process memory delta helper (rough — RSS is noisy) --
function rssMB() { return process.memoryUsage.rss() / 1024 / 1024; }

const RESULTS = [];

// =========================================================================
// FlexSearch: Document index, title + body + tags fields
// =========================================================================
async function benchFlex() {
  const before = rssMB();
  const tBuild0 = performance.now();
  const flex = new FlexDocument({
    document: {
      id: "id",
      index: [
        { field: "title", tokenize: "forward" },
        { field: "body",  tokenize: "forward" },
        { field: "tags",  tokenize: "forward" },
      ],
    },
  });
  for (const n of notes) flex.add(n);
  const buildMs = performance.now() - tBuild0;
  const afterBuild = rssMB();

  // serialize (FlexSearch v0.8 uses async export → cb per key)
  let bytes = 0;
  await new Promise((resolve) => {
    let pending = 0, done = false;
    flex.export((_key, data) => {
      if (data) bytes += Buffer.byteLength(typeof data === "string" ? data : JSON.stringify(data));
      pending--;
      if (done && pending <= 0) resolve();
    });
    // Handle both sync-callback and promise flavours by giving it a tick.
    setTimeout(() => { done = true; if (pending <= 0) resolve(); }, 500);
  });

  const runOne = (q) => flex.search(q, { limit: 20 });
  const perShape = {};
  for (const shape of Object.keys(QUERIES)) {
    perShape[shape] = measure(() => { for (const q of QUERIES[shape]) runOne(q); });
  }

  // recall-on-typos: how many fuzzy queries return any hit?
  let fuzzyHits = 0;
  for (const q of QUERIES.fuzzy) {
    const r = runOne(q);
    if (Array.isArray(r) && r.some((f) => f.result && f.result.length)) fuzzyHits++;
  }

  RESULTS.push({
    lib: "FlexSearch 0.8.212",
    buildMs, bytes,
    rssBuildDelta: afterBuild - before,
    perShape,
    fuzzyRecallPct: (fuzzyHits / QUERIES.fuzzy.length) * 100,
  });
}

// =========================================================================
// MiniSearch: inverted index, prefix + fuzzy at query time
// =========================================================================
function benchMini() {
  const before = rssMB();
  const tBuild0 = performance.now();
  const mini = new MiniSearch({
    fields: ["title", "body", "tags"],
    storeFields: ["id"],
    // small IDs and fields improve build time
  });
  mini.addAll(notes.map((n) => ({ ...n, tags: n.tags.join(" ") })));
  const buildMs = performance.now() - tBuild0;
  const afterBuild = rssMB();

  const bytes = Buffer.byteLength(JSON.stringify(mini));

  const runOne = (q, opts = {}) => mini.search(q, opts);
  const perShape = {
    exact:  measure(() => { for (const q of QUERIES.exact)  runOne(q); }),
    prefix: measure(() => { for (const q of QUERIES.prefix) runOne(q, { prefix: true }); }),
    fuzzy:  measure(() => { for (const q of QUERIES.fuzzy)  runOne(q, { fuzzy: 0.2 }); }),
  };

  let fuzzyHits = 0;
  for (const q of QUERIES.fuzzy) if (runOne(q, { fuzzy: 0.2 }).length) fuzzyHits++;

  RESULTS.push({
    lib: "MiniSearch 7.2.0",
    buildMs, bytes,
    rssBuildDelta: afterBuild - before,
    perShape,
    fuzzyRecallPct: (fuzzyHits / QUERIES.fuzzy.length) * 100,
  });
}

// =========================================================================
// Fuse.js: Bitap fuzzy-string, tokenized across { title, body, tags }
// =========================================================================
function benchFuse() {
  const before = rssMB();
  const tBuild0 = performance.now();
  const fuse = new Fuse(
    notes.map((n) => ({ ...n, tags: n.tags.join(" ") })),
    {
      keys: ["title", "body", "tags"],
      threshold: 0.3,           // typical typo tolerance
      ignoreLocation: true,     // scan the whole field, not near position 0
      minMatchCharLength: 2,
    }
  );
  const buildMs = performance.now() - tBuild0;
  const afterBuild = rssMB();

  // Fuse's serialized "index" (createIndex) is what you'd store to skip re-build
  const idx = Fuse.createIndex(["title", "body", "tags"], notes.map((n) => ({ ...n, tags: n.tags.join(" ") })));
  const bytes = Buffer.byteLength(JSON.stringify(idx.toJSON()));

  const runOne = (q) => fuse.search(q, { limit: 20 });
  const perShape = {};
  for (const shape of Object.keys(QUERIES)) {
    perShape[shape] = measure(() => { for (const q of QUERIES[shape]) runOne(q); }, 1, 5);
  }

  let fuzzyHits = 0;
  for (const q of QUERIES.fuzzy) if (runOne(q).length) fuzzyHits++;

  RESULTS.push({
    lib: "Fuse.js 7.4.2",
    buildMs, bytes,
    rssBuildDelta: afterBuild - before,
    perShape,
    fuzzyRecallPct: (fuzzyHits / QUERIES.fuzzy.length) * 100,
  });
}

// =========================================================================
// Main
// =========================================================================
await benchFlex();
benchMini();
benchFuse();

// -- present results --
const fmt = (n) => (n < 10 ? n.toFixed(2) : n < 100 ? n.toFixed(1) : Math.round(n).toString());
const fmtMs = (n) => `${fmt(n)}ms`;
const fmtMB = (b) => `${(b / 1024 / 1024).toFixed(2)}MB`;

console.log("\n=== Build cost ===");
console.log("lib".padEnd(22), "build".padStart(9), "index".padStart(10), "rss+".padStart(9));
for (const r of RESULTS) {
  console.log(r.lib.padEnd(22), fmtMs(r.buildMs).padStart(9), fmtMB(r.bytes).padStart(10), (fmt(r.rssBuildDelta) + "MB").padStart(9));
}

console.log("\n=== Query latency (per batch of 10 queries) ===");
console.log("lib".padEnd(22), "shape".padEnd(8), "p50".padStart(9), "p95".padStart(9), "min".padStart(9));
for (const r of RESULTS) {
  for (const shape of ["exact", "prefix", "fuzzy"]) {
    const m = r.perShape[shape];
    console.log(r.lib.padEnd(22), shape.padEnd(8), fmtMs(m.p50).padStart(9), fmtMs(m.p95).padStart(9), fmtMs(m.min).padStart(9));
  }
}

console.log("\n=== Fuzzy recall (% of typo queries with ≥1 hit) ===");
for (const r of RESULTS) {
  console.log(r.lib.padEnd(22), r.fuzzyRecallPct.toFixed(0) + "%");
}

// machine-readable output for the piece
fs.writeFileSync("results.json", JSON.stringify({
  node: process.versions.node,
  corpusSize: notes.length,
  corpusBytes: fs.statSync(CORPUS_PATH).size,
  queries: QUERIES,
  results: RESULTS,
  timestamp: new Date().toISOString(),
}, null, 2));
console.log("\nwrote results.json");

Why did FlexSearch return no results for most typo queries?

Default FlexSearch tokenisation prioritises speed over typo tolerance; the encoder does not do character-level distance matching. To catch typos you either configure a phonetic encoder, enable a suggest mode, or (the pattern most teams land on) run a two-stage search: FlexSearch for the fast primary lookup, and a scoped Fuse.js pass over the top 20 candidates for typo forgiveness. The two-stage pattern gets you FlexSearch's latency floor and Fuse.js's recall, at the cost of ~20 extra Bitap comparisons per query.

Are these libraries really "search stays local"?

Architecturally, yes — all three are pure JavaScript, make no fetch calls, and run entirely in the browser. That is a capability of the code, not a privacy guarantee for your app: if your surrounding app sends queries or matched snippets to an analytics endpoint, the search itself being in-process does not protect that. Audit the surrounding calls, not the library's promise.

References


The vault is a folder on your device; the index is a cache over the folder; and the shape of the search is the only decision that matters before you pick a name.

Explore MNMNOTE — the local-first Markdown note app that keeps every note on your device — at mnmnote.com.

Footnotes

  1. MDN Web Docs, "Storage quotas and eviction criteria". https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Browser_storage_limits_and_eviction_criteria — accessed 2026-07-07. 2 3 4 5

  2. Thomas Wilkerling (nextapps-de), FlexSearch README v0.8, master HEAD. https://github.com/nextapps-de/flexsearch#readme — accessed 2026-07-07. Wayback snapshot: https://web.archive.org/web/20260606081501/https://github.com/nextapps-de/flexsearch. 2

  3. Luca Ongaro, MiniSearch README v7.2.0. https://github.com/lucaong/minisearch#readme — accessed 2026-07-07. Wayback: https://web.archive.org/web/20260609090452/https://github.com/lucaong/minisearch.

  4. Kiro Risk, Fuse.js homepage. https://fusejs.io/ — accessed 2026-07-07. Wayback: https://web.archive.org/web/20260614204148/https://fusejs.io/.

  5. Kiro Risk, "Fuse.js vs Semantic Search", Fuse.js docs (schema datePublished: 2026-04-04). https://fusejs.io/articles/vs-semantic-search.html — accessed 2026-07-07.

  6. Luca Ongaro, MiniSearch README — Use case section. https://github.com/lucaong/minisearch#use-case — accessed 2026-07-07.

  7. Luca Ongaro, "MiniSearch, a client-side full-text search engine", lucaongaro.eu, 2019-01-30. https://lucaongaro.eu/blog/2019/01/30/minisearch-client-side-fulltext-search-engine.html — accessed 2026-07-07. Wayback: https://web.archive.org/web/20260522210715/https://lucaongaro.eu/blog/2019/01/30/minisearch-client-side-fulltext-search-engine.html.

  8. Luca Ongaro, "MiniSearch, a client-side full-text search engine" — memory-constrained-cases context. Same URL as 7.

  9. Kiro Risk, Fuse.js Performance (v7.4.2). https://fusejs.io/performance.html — accessed 2026-07-07. Wayback: https://web.archive.org/web/20260414122303/https://fusejs.io/performance.html. 2

  10. SQLite documentation, "SQLite FTS5 Extension" §1 Overview. https://www.sqlite.org/fts5.html — accessed 2026-07-07. Wayback: https://web.archive.org/web/20260625111631/https://www.sqlite.org/fts5.html. 2

  11. MDN Web Docs, "Origin private file system". https://developer.mozilla.org/en-US/docs/Web/API/File_System_API/Origin_private_file_system — accessed 2026-07-07.

  12. Andrew Trotman, Antti Puurula, Blake Burgess, "Improvements to BM25 and Language Models Examined", ADCS '14 (ACM DOI 10.1145/2682862.2682863; author-mirror PDF: https://www.cs.otago.ac.nz/homepages/andrew/papers/2014-2.pdf).