npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@endosome/minisearch

v0.13.0

Published

WebAssembly full-text search with a MiniSearch-compatible JavaScript fallback for callbacks and value semantics.

Readme

@endosome/minisearch

A Rust + WebAssembly full-text search engine with a compatibility facade for MiniSearch 7.2.0. Indexing, searching, updates and vacuuming run in Wasm, with results that are MiniSearch's down to the last bit of the score. Document ids and stored fields stay in JavaScript, kept the way MiniSearch keeps them, and every callback (tokenize, processTerm, boostDocument, filter, …) is called as MiniSearch calls it. A bundled, pinned MiniSearch implementation takes over for the few inputs the engine cannot represent. Check index.executionMode to see which engine owns the index.

Version 0.13.0 makes search() about 4× faster than MiniSearch (0.12.0: 2.8×), keeps indexing linear at scale, and adds searchTopK, searchScores and countMatches. It is published as @endosome/minisearch (before: minisearch-wasm). See Changes in 0.13.0 and CHANGELOG.md. See COMPATIBILITY.md for the API, import formats, engine selection and persistence limitations.

This is an independent WebAssembly reimplementation that is API-compatible with MiniSearch. It is not affiliated with or endorsed by the original MiniSearch project.

The original JavaScript conformance tests are copied under reference-tests/, and Rust integration tests in tests/ translate them feature by feature; MiniSearch's own test suite also runs against the public facade.

Migrating from MiniSearch

Install this package under MiniSearch's name, and your code stays exactly as it is. The documented MiniSearch 7.2.0 API, callbacks and TypeScript types included, keeps working:

npm install minisearch@npm:@endosome/minisearch
import MiniSearch from "minisearch"; // unchanged; now the Wasm engine

That is npm's package alias: your package.json lists "minisearch": "npm:@endosome/minisearch@^0.13.0", and every import ... from "minisearch" (or require("minisearch"), or "minisearch/SearchableMap") resolves here. To keep both libraries side by side instead, install @endosome/minisearch and import it by that name.

In a browser or module Worker, also call await MiniSearch.init() once at startup. Without it every index runs on the bundled JavaScript engine, which is fully compatible but no faster. Node loads the Wasm module on import.

An index stays in Wasm through ordinary use: documents with any kind of ids and values, every mutation, searches on a clean or a dirty index, vacuuming, getStoredFields (the live object, as upstream), loadJSON, and every callback: tokenize and processTerm (the facade calls them and the engine indexes their terms), boostDocument (the engine calls it back while it scores), filter, prefix, fuzzy, boostTerm, extractField, stringifyField and logger. It moves to the JavaScript engine, once and permanently, only for what the engine cannot represent:

  • a query-tree node with a boostDocument of its own;
  • field text or terms that are not strings, or contain lone surrogates;
  • options with no native form: fields that is not an array or repeats a name, Infinity in search options.

index.executionMode reports "wasm" or "javascript". Up to 0.11.0 the list is longer: there tokenize, processTerm, boostDocument, object ids, Dates and an edited getStoredFields object also transfer.

What you can add once you have switched. None of these exist in MiniSearch; each is optional, and code that does not use them behaves as before:

| Need | Use | Gain on the benchmark corpus | | --- | --- | --- | | Only the first page of results | searchTopK(query, { limit: 20, ...options }) | 0.40× the time of search(...).slice(0, 20) | | Only ids and scores | searchScores(query, options) | 0.45× | | Only the number of results | countMatches(query, options) | 0.34× | | Results without match | search(query, { includeMatch: false }) | about 0.5× of search() | | Ids, scores and terms in compact form | searchJoined, searchJoinedOpts, searchRaw | 5.7× and 10.6× MiniSearch | | Faster save and load | toBytes() / MiniSearch.loadBytes(bytes) | 76× and 12× MiniSearch's JSON | | Callback-free search options | prefix: [false, true], fuzzy: [0.2, false], boostTerm: [2, 1] (one entry per query term), filter: { category: "books" } | kept in Wasm without calling back | | A job-board tokenizer | tokenizer: "jobboard" | native, no callback |

The first three return exactly what the search() expression would; see Top results, scores and counts and the API section for the rest.

Measured speedups

Speedup over MiniSearch 7.2.0 (its time divided by this package's; higher is faster), from npm run bench:public: 20,000 synthetic documents, 39 prefix + fuzzy AND queries, 9 paired rounds, medians, Node 24.22 on Linux (Intel Xeon E5-2640). Every result row was checked equal to MiniSearch's: all 934,016 scores identical. Compact APIs are timed with their results decoded; searchJoined and searchRaw have no MiniSearch counterpart and are compared with search(). The last column is 0.12.0 on the same machine; final report.

| Operation | MiniSearch 7.2.0 | This version | 0.12.0 | | --- | --- | --- | --- | | search(), full result objects | 1.0× (1,307 ms) | 4.1× | 2.8× | | search() with a filter callback | 1.0× (1,331 ms) | 3.1× | 2.4× | | search() with boostDocument | 1.0× (1,406 ms) | 2.8× | 2.3× | | search() with a processTerm callback | 1.0× (1,279 ms) | 4.3× | 2.9× | | search() on a dirty index (25% discarded) | 1.0× (957 ms) | 3.7× | 3.0× | | searchJoined, decoded, vs search() | 1.0× (1,338 ms) | 5.7× | 5.8× | | searchRaw, decoded, vs search() | 1.0× (1,350 ms) | 10.6× | 10.7× | | autoSuggest | 1.0× (905 ms) | 7.3× | 6.8× | | addAll | 1.0× (1,206 ms) | 2.5× | 2.5× | | addAll with a processTerm callback | 1.0× (1,220 ms) | 1.3× | 1.3× | | loadJSON | 1.0× (388 ms) | 2.2× | 1.6× | | Load, loadBytes vs loadJSON | 1.0× (384 ms) | 12× | 11× | | Save, toBytes vs JSON.stringify | 1.0× (610 ms) | 76× | 61× | | Vacuum after discarding 25% | 1.0× (113 ms) | 18× | 18× | | Snapshot size, binary vs JSON | 1.0× (8,296 KB) | 7.5× smaller | 7.5× smaller |

Indexing is linear in the corpus size: 1M documents take 38 s, where 0.12.0 took 200–357 s.

So swapping the import alone makes search() about 4× and autoSuggest about 7× faster, custom tokenizers and document boosts included. The larger gains need code changes: searchJoined and searchRaw return compact result shapes, searchTopK, searchScores and countMatches return less than search() (see the API section), and loadBytes replaces loadJSON. loadJSONAsync waits for a timer where MiniSearch's does; on Windows, where such a wait takes about 15 ms, both take about 8 seconds for this index.

The facade costs size: the package is 1.5 MB unpacked, 483 KB as a tarball (0.12.0: 469 KB), because it ships the Wasm engine, the pinned JavaScript engine and ESM, CommonJS and browser-global builds. These numbers are workload- and machine-dependent; measure your own corpus before relying on a ratio. Earlier releases' measurements are in differential/results/.

Design: keep the boundary thin

A Wasm search engine's performance depends on its JS↔Wasm boundary. In Wasm mode:

  1. Do all the work in Wasm. Tokenization, the radix tree, BM25, prefix and fuzzy traversal, scoring, and serialization all run in Rust.
  2. Cross the boundary as little as possible. A query enters as a string plus one orMode flag — no per-call options object to deserialize. The result set leaves as just three values: scores (a Float64Array, one bulk copy), ids (a JSON array string), and terms (a newline-joined string). No per-hit JS object is created on the Rust side.

That is what searchJoined does — and searchRaw goes further still (everything numeric: typed arrays plus one small interned term table). These are the recommended paths for embedding apps. search() is also provided for MiniSearch-shaped results (it returns the full nested per-hit { id, score, terms, queryTerms, match, … } objects); rebuilding those nested objects costs more than the compact formats. These performance properties apply to Wasm mode; a compatibility transfer pays a one-time index conversion cost and subsequent operations run in JavaScript. The two indexes are not kept in sync, and an index does not switch back automatically.

API

import init, { MiniSearchWasm } from "@endosome/minisearch";
await init();

// Build (or load a prebuilt index — far cheaper, see below).
const mini = new MiniSearchWasm({
  idField: "id",
  fields: ["title", "description", "company", "location", "org"],
  tokenizer: "jobboard",                 // or "default"
  searchOptions: { boost: { title: 4 }, prefix: true, fuzzy: 0.2, combineWith: "AND" },
});
mini.addAllJSON(rawJobsJsonText);        // inspect values, then bulk-index in Wasm when eligible

// Fast path — everything in Wasm, minimal boundary crossing:
const r = mini.searchJoined("software engineer", /* orMode */ false);
// r = { count, ids: '["id0","id1"]', scores: Float64Array, terms: "a b\nc" }
const ids   = JSON.parse(r.ids);
const terms = r.count ? r.terms.split("\n") : [];
for (let i = 0; i < r.count; i++) {
  use(ids[i], r.scores[i], terms[i] ? terms[i].split(" ") : []);
}

// …with per-call option overrides (partial, like search()):
const exact = mini.searchJoinedOpts("java", { prefix: false, fuzzy: false });

// Fastest path — everything numeric. Fetch the id table once, then per query
// only typed arrays + one small interned term table cross the boundary:
let idTable = JSON.parse(mini.docIdTable()); // slot n = internal doc id n
let idTableVersion = mini.idTableVersion;
const raw = mini.searchRaw("software engineer"); // optional options 2nd arg
if (raw.idTableVersion !== idTableVersion) {
  idTable = JSON.parse(mini.docIdTable());
  idTableVersion = mini.idTableVersion;
}
// { count, idTableVersion, docIds: Uint32Array, scores: Float64Array, termTable: "a\nb\n…",
//   termOffsets: Uint32Array, termIds: Uint32Array }
const termTable = raw.termTable ? raw.termTable.split("\n") : [];
for (let i = 0; i < raw.count; i++) {
  const terms = [];
  for (let k = raw.termOffsets[i]; k < raw.termOffsets[i + 1]; k++)
    terms.push(termTable[raw.termIds[k]]);
  use(idTable[raw.docIds[i]], raw.scores[i], terms);
}

// Compatibility path — MiniSearch-shaped result objects (slower, see above):
const full = mini.search("software engineer", { combineWith: "AND" });

// Opt-in shortcuts, each defined by search() with the same arguments: the
// same rows, scores and order, only less of them. They build and transfer
// only what they return (on the bench corpus 0.35-0.45x the time of the
// search() expression), and fall back to search() itself where a callback or
// a stored `id`/`score` field shapes the rows.
const top10 = mini.searchTopK("software engineer", { limit: 10 }); // search(...).slice(0, 10)
const pairs = mini.searchScores("software engineer");  // [{ id, score }, …] in search() order
const total = mini.countMatches("software engineer");  // search(...).length

// Query-expression trees and wildcard, like JS MiniSearch: subqueries combine
// with AND / OR / AND_NOT and nest arbitrarily; a node's other keys override
// the search options for its subtree. `MiniSearchWasm.wildcard` matches every
// document (e.g. "everything except…" via AND_NOT).
const advanced = mini.search({
  combineWith: "AND_NOT",
  queries: [
    { combineWith: "OR", queries: ["designer", { prefix: true, queries: ["develop"] }] },
    "senior",
  ],
});
const everything = mini.search(MiniSearchWasm.wildcard);

// Auto-suggest (search-as-you-type), MiniSearch-compatible: AND + prefix on
// the last term by default; defaults configurable via the constructor's
// `autoSuggestOptions`, overridable per call.
const suggestions = mini.autoSuggest("softw eng", { fuzzy: 0.2 });
// => [{ suggestion: "software engineer", terms: ["software", "engineer"], score: … }, …]

// Non-blocking batch indexing, yielding between chunks:
await mini.addAllAsync(documents, { chunkSize: 100 });

// Discard leaves stale postings. Auto-vacuum is enabled by default and can be
// tuned or disabled in the constructor; manual vacuuming is also asynchronous.
mini.discard(oldDocumentId);
await mini.vacuum({ batchSize: 1000, batchWait: 10 });
console.log(mini.dirtCount, mini.dirtFactor, mini.isVacuuming);

// The rest of the MiniSearch document API:
mini.replace(updatedDocument);           // discard + add, by id field
mini.has(id);                            // true for a live document
mini.getStoredFields(id);                // stored fields object, or undefined
MiniSearchWasm.getDefault("idField");    // "id"; callback defaults come back as functions

// MiniSearch's callback search options have declarative forms here: one entry
// per query term for prefix / fuzzy / boostTerm, and a stored-field filter.
// Functions are accepted too; the facade evaluates them, and the index stays in Wasm.
mini.search("softw eng", {
  prefix: [false, true], fuzzy: [0.2, false], boostTerm: [2, 1],
  filter: { company: "ACME" },
});

// Fast path: one string + one Float64Array across the boundary; a suggestion
// row IS its space-joined terms.
const s = mini.autoSuggestJoined("softw eng");
const phrases = s.count ? s.suggestions.split("\n") : [];
for (let i = 0; i < s.count; i++) use(phrases[i], s.scores[i], phrases[i].split(" "));

// Persistence: the engine's binary snapshot plus the ids and stored fields as
// JSON in Wasm mode; a tagged JSON envelope in JavaScript mode. Supply
// callback options again when loading (tokenize/processTerm are required).
const bytes = mini.toBytes();            // Uint8Array
const loaded = MiniSearchWasm.loadBytes(bytes);

// toJSON / loadJSON use MiniSearch's own JSON format, so JSON.stringify(mini)
// loads in the JavaScript library and vice versa (same constructor options).
const forJs  = MiniSearch.loadJSON(JSON.stringify(mini), options);
const fromJs = MiniSearchWasm.loadJSON(JSON.stringify(jsMiniSearch), options);
const later  = await MiniSearchWasm.loadJSONAsync(JSON.stringify(jsMiniSearch), options);

// The engine's own, smaller JSON snapshot (not readable by the JS library):
const native = mini.toNativeJSONString();
const reloaded = MiniSearchWasm.loadNativeJSON(native);

Top results, scores and counts

When you show only the first page of results, need only ids and scores, or only a count, three methods return exactly that part of search()'s result, without building the rest. Each takes the same query and options as search() (query trees and MiniSearch.wildcard included), and returns what the search() expression in its comment would, with the same ids, scores, order and ties:

// The first `limit` results, as full result objects:
// search(query, options).slice(0, limit)
const firstPage = mini.searchTopK("software engineer", { limit: 20, fuzzy: 0.2 });

// Every result as { id, score }, in search() order:
// search(query, options).map(({ id, score }) => ({ id, score }))
const ranked = mini.searchScores("software engineer");

// How many results there are:
// search(query, options).length
const total = mini.countMatches("software engineer", { combineWith: "OR" });
  • limit is required and must be a non-negative integer, or searchTopK throws a TypeError. It is not passed on as a search option.
  • On the benchmark corpus they take 0.34–0.45× the time of the search() expression. Scoring still visits every matching document; what they save is building and transferring results nobody reads.
  • They fall back to running search() itself, and are then no faster, where search()'s rows depend on JavaScript: a filter or boostDocument callback, a stored field named score or id (which replaces the result's own value), a stored object returned by getStoredFields or toJSON that was given such a property, an index that moved to the JavaScript engine, or a subclass that overrides search. Stored fields never change a count, so countMatches falls back only for the callbacks, the JavaScript engine and a subclass. The results are the same either way.
  • On an index with discarded documents, each call cleans up what it reaches, as one search() call would.

Changes in 0.13.0

  • Faster search(). Full result objects: 4.1× MiniSearch, against 2.8× for 0.12.0 on the same machine. With a filter callback 3.1× (was 2.4×), with boostDocument 2.8× (2.3×), on an index with discarded documents 3.7× (3.0×). Each call runs the engine once, tracks only the result data it returns, and builds rows into preallocated arrays.
  • Linear indexing at scale. 1M documents index in 38 s (0.12.0: 200–357 s); the first searches on an index with discarded documents are about 8× faster.
  • Less memory, faster maintenance. 14% less live memory for a 300k-document index; compaction 0.6× and vacuum 0.8× of 0.12.0's time; termCount is O(1).
  • New: searchTopK, searchScores and countMatches (see Top results, scores and counts).
  • Fixed toward MiniSearch: a stored-field getter is read once per result, and autoSuggest follows a stored score field.
  • Renamed package: install @endosome/minisearch. Snapshots and the snapshot format names are unchanged, so indexes saved by 0.12.0 load as they are.

Changes in 0.12.0

CHANGELOG.md has the full list.

  • Ids and stored fields live in JavaScript, as in MiniSearch: ids of any type, stored fields as live objects. Object ids, Dates and other values stay in Wasm; getStoredFields returns the live object.
  • Callbacks stay in Wasm. tokenize and processTerm run on the JavaScript side, called exactly as MiniSearch calls them, and the engine indexes and searches their terms. The engine calls boostDocument back while it scores, in MiniSearch's order.
  • Faster drop-in paths. search() about 2.4×, addAll about 2× and loadJSON about 2.5× faster than 0.11.0; see the table above.
  • Smaller snapshots. Binary version 5 is about 40% smaller; snapshots of 0.9.0 to 0.11.0 still load.
  • Closer to MiniSearch. JSON.stringify(index) is MiniSearch's, byte for byte, in ordinary use; removeAll() keeps the dirt count; a field repeated in the search option fields is scored once.

Changes in 0.11.0

  • Indexes stay in Wasm. Discards and replaces, queries on a dirty index (the engine reproduces MiniSearch's lazy cleanup, first query included), vacuuming, getStoredFields, loadJSON/loadJSONAsync, and the callbacks filter, prefix, fuzzy, boostTerm, extractField, stringifyField and logger no longer move an index to the JavaScript engine.
  • Scores are MiniSearch's, bit for bit. The inverse document frequency uses the logarithm algorithm V8 uses; about 4% of scores on small indexes used to differ in the last bit.
  • Snapshots. Everything the engine can save loads again (negative field averages, postings left by a changed document); what no reader accepts is refused when saving, with a way out. A rejected snapshot can no longer grow Wasm memory past the 256 MiB decode budget. loadBytes takes an ArrayBuffer too.
  • Robustness. No query aborts the module (an absurd fuzzy distance used to), and string parameters of the core are type-checked instead of trapping.
  • Removal is linear. Removing the oldest 50,000 of 100,000 documents took 48.7 s and takes 0.6 s.
  • Fixes: vacuum empties terms in MiniSearch's order (the radix key order could differ afterwards), compact results score a repeated query term correctly, the declarative filter compares numbers by value, a zero boost means none, a mismatched remove no longer blocks compact().
  • Callback and async compatibility: filters run before sorting; compact searches evaluate callbacks once; native JSON loading yields while rebuilding maps and postings; short async indexing batches are deferred.
  • Package: ships this README and LICENSE.txt; named types for TypeScript CommonJS consumers; declarations without the DOM library; MiniSearch is bundled, not a dependency; the Node entry survives bundlers; the global script is minified and defines only MiniSearch.

Upgrading to 0.10.0

The default export is now the MiniSearch-compatible constructor. Existing import init, { MiniSearchWasm } initialization remains supported. Callbacks, JavaScript values, dirty queries and maintenance use the bundled JavaScript engine; a transfer is permanent for that instance and can block on large indexes. Use executionMode to observe it. Native version-4 snapshots from 0.9.0 remain readable; JavaScript-mode snapshots use a distinct compatibility envelope. See COMPATIBILITY.md for all migration details and limitations.

Upgrading from versions before 0.9.0

Version 0.9.0 fixed field-length accounting, binary snapshot traversal order, snapshot validation, and compact ID encoding, and aligns the JSON API with MiniSearch. Three migrations are required:

  • toJSON() / loadJSON() now use MiniSearch's format. toJSON() and toJSONString() produce what the JavaScript library's toJSON() produces (so JSON.stringify(index) is loadable on either side), and loadJSON(json, options) takes MiniSearch's signature. The engine's own JSON snapshot moved to toNativeJSON(), toNativeJSONString() and loadNativeJSON(json).

  • Rebuild persisted indexes from the original documents. Binary snapshots now use version 4; JSON snapshots require snapshot_version: 4. Older snapshots are rejected because they did not preserve field presence, exact long-field lengths, or all binary tree ordering information. Do not change a snapshot's version number by hand.

  • Decode compact IDs with JSON.parse. Both searchJoined(...).ids (including searchJoinedOpts) and docIdTable() are JSON array strings. Replace their .split("\n") calls with JSON.parse(...). The terms and termTable formats are unchanged. Empty ID results are "[]".

In the published 0.9.0 native API, external IDs are non-null JSON values and array/object IDs use JSON-value equality. The 0.10.0 facade instead preserves JavaScript identity for object IDs and stored values by selecting JavaScript mode. Numeric and string IDs stay distinct. Compact result formats still encode IDs as JSON; use full search() for object identities or values that cannot round-trip through JSON. Deleted ID-table slots are null.

idTableVersion is an instance-local decimal string. Add, remove, discard and removeAll() change it; each searchRaw() result carries the generation used by that query. Cache an ID table with its generation, refresh it after mutations or compaction, and fetch a new table for every newly loaded engine. Resolve raw results before subsequently mutating their engine.

Native version-4 snapshots validate document/field mappings, dimensions, finite statistics, postings, and radix-tree structure. Dirty indexes may retain stale postings. Loads reject trailing binary data, integer overflow, invalid UTF-8 and excessive nesting. Snapshot limits are 256 MiB of input, 1,024 fields, 2,000,000 internal ID slots, 16,000,000 field slots, radix depth 128, and stored JSON value depth 64. The binary reader also applies a 256 MiB allocation budget; this is a defensive accounting limit, not a measurement of total process memory. JSON parsing retains serde_json's default recursion limit. Snapshots exceeding these limits require splitting or rebuilding the index.

Result ordering and field handling now follow JS MiniSearch more closely: equal-score results come out in MiniSearch's Map order instead of internal document-id order; a hit's terms and match keys follow JS Object.keys order (array-index-like terms such as 2024 first); numeric field values and ids are stringified like JS (10.0 indexes as 10, and 1 and 1.0 are the same id); and prefix/fuzzy term lengths and fuzzy distances count UTF-16 code units like String.length. Indexes with such content rank identically but may order ties and matched terms differently than 0.8.0 did.

The default tokenizer now includes boundary empty tokens in field-length statistics, skips null/missing fields, and retains exact lengths above 65,535. Its separator table is generated from the JavaScript engine's Unicode data (Node 24, Unicode 17.0) instead of a Rust crate's Unicode 15 tables. The built-in jobboard tokenizer keeps its existing tokenization behavior.

Errors are Error objects with MiniSearch's messages (e.message, e.stack and instanceof Error work). The published 0.9.0 native API rejected most callbacks and copied stored object values. The 0.10.0 facade accepts callbacks and preserves live stored-field references, including Dates and objects, through its JavaScript engine.

Verify the changes with npm run build and npm test.

Compatibility and extensions

The public facade supports MiniSearch 7.2.0's documented methods, callbacks, query trees, wildcard, default constructor, generic TypeScript types and SearchableMap subpath. COMPATIBILITY.md lists the execution modes and remaining limitations.

  • JavaScript semantics: ids and stored fields are kept in JavaScript, as MiniSearch keeps them; tokenize, processTerm and boostDocument are called as MiniSearch calls them. Only the inputs listed under Migrating from MiniSearch select the pinned JavaScript engine; a transfer preserves radix traversal order. Dirty searches and vacuum stay native and perform the same lazy cleanup as MiniSearch, query by query.
  • Asynchronous work: addAllAsync uses MiniSearch's chunk scheduler and converts documents within each deferred chunk. Native loadJSONAsync yields between batches of document-map and posting reconstruction; JavaScript mode uses MiniSearch's yielding loader. JSON parsing, native table allocation, per-list sorting and final validation still run synchronously.
  • Persistence: toJSON/loadJSON use MiniSearch's format. Native snapshots (binary version 5, with the ids and stored fields as JSON) retain the fast native loading path; version 4 still loads. JavaScript indexes use a tagged compatibility envelope instead; callbacks must be supplied again and JSON cannot preserve object identity or prototypes.
  • Maintenance: active and queued vacuum promises follow MiniSearch's completion boundaries. Once vacuum finishes cleanly and most internal ids are free, the facade compacts them. compact() also reclaims clean native ID tables and scratch buffers; it changes idTableVersion. Wasm linear memory need not shrink.
  • Extensions: compact searchJoined/searchRaw results, addAllJSON, native snapshots, the jobboard tokenizer, includeMatch: false, declarative per-term arrays and equality filters remain available. Compact results require JSON-safe IDs and delimiter-safe terms; use full results otherwise.

Benchmark results

Measurements of the 0.12.0 package against MiniSearch and 0.11.0 are in Migrating from MiniSearch above. The paired runs (npm run bench:public) time the compact APIs with their results decoded (JSON.parse of the ids, splitting the terms); the historical figures below time the calls alone, which is why searchJoined reads about 8× there and about 5× in the paired runs.

The paired public-package report for 0.10.0 also measures the first query batch and that version's one-time transfer to JavaScript: full search() 1.42x faster, decoded searchJoined 4.48x, decoded searchRaw 13.18x, and 1.28-1.59 seconds for the first callback or dirty query on 20,000 documents, after which 0.10.0 runs at MiniSearch's speed. Version 0.11.0 no longer transfers in those cases (see above).

Historical native measurements: the numbers below predate the compatibility facade. They do not measure its transfer cost, bundled JavaScript size, JSON eligibility scan or JavaScript execution mode. Re-benchmark the public package for your workload before applying these ratios.

The figures below were measured on 0.8.0 with the external jobboard benchmark. A 0.9.0 check with the checked-in differential/bench_wasm.mjs (20,000 synthetic documents, 38 queries, medians of 9 rounds) against the 0.8.0 build on the same machine showed searchJoined and autoSuggest 10-20% faster (run-to-run variance is about 5%), toBytes about 30% faster, loadBytes within 5% (the snapshot is now validated while it is decoded) and a 3% smaller binary snapshot. The download row was measured on the version 3 snapshot format and has not been re-measured for version 4. The compat row is from 0.9.0: search() now hands ids, scores and interned term tables to one JavaScript function that builds the result objects natively, instead of one boundary call per property per hit (it measured 0.58× before that change).

Measured by keyword-search/scripts/bench-minisearch-vs-wasm.mjs on the real jobboard corpus (~22k documents, 30 representative queries), comparing against JS MiniSearch. Search ratios are reported by median (robust to system noise); expect some run-to-run variance.

| Category | Rust vs JS MiniSearch | |---|---| | Search — app workload ({id, score, terms} via searchJoined) | ~11× faster (median; mean ~7×) | | Search — same workload via searchRaw | ~23× faster (median; mean ~16×) | | Index download (prebuilt, brotli) | ~0.73× — smaller on the wire than JS | | Load prebuilt index — loadBytes vs loadJSON | ~13× faster | | Serialize index — toBytes vs JSON.stringify | ~12× faster | | Build index — addAllJSON vs addAll | ~2.9× faster | | Full compat search() vs JS search() | ~1.7× faster (~2.1× with includeMatch: false; 0.9.0, 20k synthetic documents, differential/bench_wasm.mjs) |

Search-as-you-type re-issues the same committed terms every keystroke; the engine memoizes prefix/fuzzy tree expansions (bit-identical replay, invalidated on any mutation), which is where most of the search speedup comes from on repeated queries. Cold first-run queries are ~2-3× faster than JS.

The synthetic maintenance benchmark (npm run bench:maintenance, 5,000 documents, 500-document async chunks) measured addAllAsync at 2.89× the synchronous Wasm indexing time, reflecting its deliberate event-loop yields. Vacuuming 1,250 discarded documents took 122 ms and reduced the binary snapshot by 20%. These timings are workload- and machine-dependent; the benchmark always verifies post-maintenance search parity and dirt cleanup.

The compact binary snapshot is delta+varint encoded, so it is both smaller than the JSON index and low-entropy enough to compress well. Since version 4 it stores the radix tree node by node, so loading is a single validated pass with no tree rebuild (about 13× faster than JS loadJSON in the table above).

The native differential suites compare scores within floating-point tolerance and preserve result/term ordering. Their dirty-index checks use the original's post-cleanup state. differential/public_api.mjs separately compares the public facade's first dirty query without warming either side, as well as callback and identity behavior. Native test results alone do not establish full API compatibility.

Install

npm install @endosome/minisearch
# or, to replace MiniSearch without touching imports of "minisearch":
npm install minisearch@npm:@endosome/minisearch

Node ESM and CommonJS initialize the packaged Wasm module synchronously:

import MiniSearch from "@endosome/minisearch";
const index = new MiniSearch({ fields: ["text"] });
// CommonJS: const MiniSearch = require("@endosome/minisearch");

Browser ESM and module Workers can call await init() before constructing an index to select Wasm. Without initialization the constructor works immediately in JavaScript mode; existing indexes keep their mode when initialization ends.

import MiniSearch, { init } from "@endosome/minisearch";
await init();
const index = new MiniSearch({ fields: ["text"] });

The legacy import init, { MiniSearchWasm } / await init() syntax still works. Plain browser ESM bundles its compatibility dependency and needs no import map. The browser-global bundle exposes MiniSearch and MiniSearch.init(). SearchableMap is exported at @endosome/minisearch/SearchableMap for both ESM and CommonJS. See COMPATIBILITY.md for direct asset imports and CSP.

Bundlers

Browser bundles resolve to the fetch-based entry (also for require), which loads minisearch_wasm_bg.wasm from a URL next to the module. A bundler that does not copy that file has to be given it; pass the URL it produces to init:

import MiniSearch, { init } from "@endosome/minisearch";
import wasmUrl from "@endosome/minisearch/minisearch_wasm_bg.wasm?url"; // Vite; webpack: new URL("…", import.meta.url)
await init(wasmUrl);

With esbuild use --loader:.wasm=file. A Node bundle that leaves the .wasm file behind still imports: the entry warns once and runs on the JavaScript engine until initSync({ module: bytes }) is called.

The package ships typed declarations for its whole surface (MiniSearchWasmOptions, SearchOptions, Query, SearchResult, Suggestion, JoinedResults, RawResults, MiniSearchJSON, …) with optional arguments marked optional; types/fixtures.ts compiles the README examples under strict with TypeScript 7 as part of the checks below.

Build

npm install        # esbuild, TypeScript, Vitest and the pinned MiniSearch
npm run build      # wasm-pack -> target/wasm-glue, finalize-pkg -> pkg/, and the engine test build -> target/pkg-core
npm test           # everything below

rust-toolchain.toml pins Rust (with the wasm32-unknown-unknown target); wasm-pack 0.15.0 is needed on the PATH (cargo install wasm-pack --version 0.15.0, or a release binary). npm test runs:

npm run test:native        # cargo fmt --check, clippy -D warnings, cargo test (+ release-mode snapshot tests)
npm run test:differential  # native engine vs MiniSearch on a generated corpus (765,678 rows)
npm run test:wasm          # engine suites against target/pkg-core, facade suites against pkg/
npm run test:types         # strict ES2022 TypeScript fixtures
npm run test:package       # installs the tarball: ESM/CJS/types/global script/bundlers, README, determinism
npm run test:upstream      # MiniSearch's own test suite against the facade
npm run check:separators   # tokenizer table still matches this Node's Unicode data
npm run check:size         # published files against scripts/size-budget.json

npm run build:pkg reassembles pkg/ from the last Wasm build after a change to js/, the README or the scripts. After a Node upgrade that brings a new Unicode version, regenerate the tokenizer's separator table with node scripts/gen-separators.mjs (it writes src/separators.rs). CI (.github/workflows/ci.yml) builds and runs npm test on every push and pull request.

Publishing

npm run publish:pkg   # build, npm test, then `npm publish ./pkg`

You must npm login first. The publishable artifact is the generated pkg/ directory, assembled from scratch by scripts/finalize-pkg.mjs on every build (manifest, entries, declarations, this README and the licenses). Tag the release (vX.Y.Z) when publishing: the documents link to files by tag.

See PORTING.md for the porting history and conformance status, CHANGELOG.md for what changed, and TODO.md / IMPROVEMENTS-3.md for the open review items.