mnemonist
mnemonist is a collection of roughly fifty data structures for JavaScript that the language does not ship: heaps, tries, LRU caches, bloom filters, multimaps, multisets, ring buffers, bit sets, union-find, and a set of stranger ones like BK-trees, VP-trees, SymSpell, and suffix arrays. Each structure lives in its own file with its own type definitions, so require('mnemonist/heap') pulls in one implementation rather than the whole library. The implementations are deliberately low-level: they favour typed arrays and flat integer indices over object graphs, and the method names try to echo the built-in Map and Set where that makes sense. The library has exactly one runtime dependency, obliterator, which supplies the iterator helpers. It is not a framework and there is nothing to configure; you construct a thing and call methods on it.
The implementations here are careful, the coverage is unusually wide, and for a CommonJS Node service that needs a heap or a trie it is the shortest path to correct code. Check your module system before committing though, because ESM users cannot use the subpath imports the whole design is built around, and a single-purpose package often ends up being the better answer.
Use it if
- You need a priority queue, an LRU cache, a prefix trie, or a ring buffer in Node and you would rather not write and test one yourself at 11pm
- You want one dependency covering a dozen structures instead of a dozen single-purpose packages each with its own release cadence and its own maintainer risk
- You need one of the structures that barely exists elsewhere on npm with working typings: BK-tree and VP-tree for metric-space nearest neighbours, SymSpell and Passjoin for fuzzy string search, KD-tree for spatial lookups, generalized suffix arrays
- You care about allocation behaviour. Structures like FixedDeque, CircularBuffer, SparseSet, BitSet, and Vector are backed by typed arrays with a fixed capacity, so they do not churn the garbage collector inside a hot loop
- You are writing CommonJS. require('mnemonist/lru-cache') gives you exactly one small module with bundled .d.ts, which is the shape the library was designed for
- You are on ESM and were counting on subpath imports. import Heap from 'mnemonist/heap' fails outright on Node with ERR_PACKAGE_PATH_NOT_EXPORTED, verified against 0.40.4 on Node 22, because the exports map declares only 'require' and 'types' conditions for subpaths. import { Heap } from 'mnemonist' works but loads the whole index, which is the opposite of the modularity the README advertises
- You need exactly one structure and nothing else. A focused package (denque for a deque, heap-js for heaps, lru-cache for caching) will have better docs, more eyes on it, and no dead weight in your bundle
- You expect the method names to match the standard library. They mostly do until they suddenly do not: on MultiSet and MultiMap, .size is the total number of values while .dimension is the number of distinct keys; on BitSet, .size is the population count and not the bit length; the plain LRUCache has no delete method at all, and you have to reach for LRUCacheWithDelete or LRUMapWithDelete to get one
- You want a version number that promises something. It has been 0.x since 2016, so every minor bump is allowed to break you, and in practice a few have. Pin exactly and read the changelog before upgrading
- You want a graph. The README says outright that a Graph implementation is out of scope and points at graphology instead, so if graphs are the reason you are here, go there directly
- You want an actively growing project. It is one author, releases have thinned to roughly one a year (0.40.0 in January 2025, 0.40.4 in April 2026), and there are 64 open issues (78 counting PRs). Nothing is broken, but do not expect a new structure because you asked for one
Setup reality
npm install mnemonist and the install itself is uneventful: one runtime dependency (obliterator), no build step, no native code, TypeScript definitions bundled per structure. The friction starts at the import line. In CommonJS, require('mnemonist/heap') works exactly as documented. In ESM it does not: subpath imports throw ERR_PACKAGE_PATH_NOT_EXPORTED because the package.json exports map defines only the 'require' and 'types' conditions for './*', so your only option is a named import from the root, which loads every structure. Bundlers with tree shaking can usually recover most of that, Node cannot. After the import, three conventions surprise people. Fixed-capacity structures take the backing container class as the first constructor argument, so it is new FixedDeque(Array, 1000) or new Vector(Uint32Array, 1000), not new FixedDeque(1000). Overflow behaviour is not uniform: pushing past capacity on a FixedDeque throws an Error while a CircularBuffer silently overwrites the oldest entry, and picking the wrong one produces either a crash or quiet data loss. And .size, .dimension, .capacity, and .length mean different things on different structures, so read the page for the one you are using rather than assuming.
Patterns
Import one structure without breaking on ESMimport-a-structure
// CommonJS: subpath require works and loads one file
const Heap = require('mnemonist/heap')
const LRUCache = require('mnemonist/lru-cache')
// ESM: this THROWS ERR_PACKAGE_PATH_NOT_EXPORTED
// import Heap from 'mnemonist/heap'
// ESM: this is the only thing that resolves
import { Heap, LRUCache } from 'mnemonist'
// ESM escape hatch if you really want one file
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const Trie = require('mnemonist/trie')The exports map lists only 'require' and 'types' conditions for './*', so Node's ESM resolver finds nothing to load. Named imports from the root work but evaluate every structure in the package. Bundlers that tree shake will usually drop the unused ones; Node running the source will not.
Cache a bounded number of valueslru-cache
const LRUCache = require('mnemonist/lru-cache')
const cache = new LRUCache(1000) // fixed capacity, set once
cache.set('user:1', { id: 1 })
cache.get('user:1') // marks it as recently used
cache.peek('user:1') // reads without touching recency
cache.has('user:1')
cache.size // current entries, max 1000
// need eviction by key? use the WithDelete variant
const { LRUMapWithDelete } = require('mnemonist')
const evictable = new LRUMapWithDelete(1000)
evictable.set('a', 1)
evictable.delete('a')The plain LRUCache has no delete method, which is the single most common surprise here. Capacity is fixed at construction and cannot grow. There are no TTLs and no eviction callbacks; if you need either, the lru-cache package is the right tool. LRUMap uses a Map internally and handles large or non-string keys better than LRUCache.
Run a priority queue with a heappriority-queue
const Heap = require('mnemonist/heap')
const { MaxHeap } = require('mnemonist')
// default comparator is ascending, so peek() is the minimum
const jobs = new Heap((a, b) => a.priority - b.priority)
jobs.push({ id: 1, priority: 5 })
jobs.push({ id: 2, priority: 1 })
jobs.peek() // { id: 2, priority: 1 }
jobs.pop() // removes and returns it
jobs.size
const top = new MaxHeap()
top.push(3); top.push(9)
top.peek() // 9
Heap.nlargest(2, [5, 1, 9, 3]) // [9, 5]A bare new Heap() sorts ascending using the default comparator, so it is a min-heap and peek() gives the smallest item. Passing a comparator that returns a boolean instead of a negative, zero, or positive number produces a heap that is silently wrong rather than one that throws. Use pushpop() or replace() instead of a push followed by a pop; they do one sift instead of two.
Autocomplete with a trieprefix-search
const TrieMap = require('mnemonist/trie-map')
const index = new TrieMap()
index.set('roman', { id: 1 })
index.set('romanesque', { id: 2 })
index.set('cake', { id: 3 })
index.find('rom') // [['roman', {...}], ['romanesque', {...}]]
index.get('roman') // { id: 1 }
index.has('cake') // true
index.delete('cake')
index.size // 2
// value-free variant
const Trie = require('mnemonist/trie')
const words = Trie.from(['book', 'books'])
words.find('boo') // ['books', 'book']find() builds and returns a full array of every match, so a one or two character prefix over a large dictionary allocates a large array before you can slice it. There is no result limit argument, so cap the query length yourself or reach for the Passjoin index if you need bounded fuzzy matching instead.
Map one key to many valuesmulti-map
const MultiMap = require('mnemonist/multi-map')
const byTag = new MultiMap()
byTag.set('js', 'post-1')
byTag.set('js', 'post-2')
byTag.get('js') // ['post-1', 'post-2']
byTag.count('js') // 2
byTag.size // 2 <- total VALUES
byTag.dimension // 1 <- distinct KEYS
byTag.remove('js', 'post-1') // drop one value
byTag.delete('js') // drop the whole key
// dedupe values by using Set containers
const unique = new MultiMap(Set)
unique.set('js', 'post-1')
unique.set('js', 'post-1')
unique.get('js') // Set(1) { 'post-1' }size and dimension mean opposite things from what a Map user expects, and mixing them up gives you a count that looks plausible. get() returns undefined for an unknown key rather than an empty array, so guard before iterating. Passing Set to the constructor changes the return type of get() from Array to Set.
Group items without checking for the key firstdefault-map
const DefaultMap = require('mnemonist/default-map')
const byUser = new DefaultMap(() => [])
for (const event of events) {
byUser.get(event.userId).push(event)
}
// counters
const counts = new DefaultMap(() => 0)
counts.set('a', counts.get('a') + 1)
// stable numeric ids for arbitrary keys
const ids = new DefaultMap(DefaultMap.autoIncrement())
ids.get('alpha') // 0
ids.get('beta') // 1
ids.get('alpha') // 0The factory receives the key, so new DefaultMap(key => new Set([key])) works. Calling get() on a missing key creates and stores the default, which means a read grows the map; use peek() when you want a lookup that does not mutate. DefaultMap.autoIncrement() returns a factory function, not a map, so it goes inside the constructor.
Count occurrences and find the top itemscounter
const MultiSet = require('mnemonist/multi-set')
const hits = new MultiSet()
hits.add('/home', 3)
hits.add('/about')
hits.count('/home') // 3
hits.top(2) // [['/home', 3], ['/about', 1]]
hits.frequency('/home') // 0.75
hits.size // 4 <- total items counted
hits.dimension // 2 <- distinct items
hits.remove('/home') // decrement by one
hits.delete('/home') // remove the item entirelyremove() decrements and delete() removes the key completely, which are one letter apart and do very different things. top(n) sorts on every call, so calling it inside a loop over a large multiset is quadratic. As with MultiMap, size counts items and dimension counts distinct keys.
Keep the last N items in a fixed allocationring-buffer
const CircularBuffer = require('mnemonist/circular-buffer')
const FixedDeque = require('mnemonist/fixed-deque')
// overwrites the oldest entry when full
const recent = new CircularBuffer(Array, 2)
recent.push(1); recent.push(2); recent.push(3)
recent.toArray() // [2, 3]
// throws when full
const queue = new FixedDeque(Array, 2)
queue.push(1); queue.push(2)
// queue.push(3) -> Error: mnemonist/fixed-deque.push: deque capacity (2) exceeded!
queue.shift() // 1The first constructor argument is the backing container class, not the capacity: use Array for objects, or Float64Array / Uint32Array for numbers to stay off the heap entirely. The overflow behaviour is the real decision here. CircularBuffer drops data quietly, which is right for a log tail and wrong for a work queue; FixedDeque throws, which is the reverse.
Test set membership in a fraction of the memorybloom-filter
const BloomFilter = require('mnemonist/bloom-filter')
const seen = new BloomFilter(1_000_000) // expected item count
seen.add('user@example.com')
seen.test('user@example.com') // true
seen.test('nobody@example.com') // false, or a false positive
// rehydrate a saved filter
const restored = BloomFilter.from(['a', 'b'])A true from test() means probably present, a false means definitely absent. There is no delete and no way to enumerate the contents, by design. Sizing the filter well under the number of items you actually insert pushes the false positive rate up sharply, and nothing warns you when that happens.
Build a tiny in-memory search indexinverted-index
const InvertedIndex = require('mnemonist/inverted-index')
// [document tokenizer, query tokenizer]
const index = new InvertedIndex([
doc => doc.title.toLowerCase().split(/\s+/),
query => query.toLowerCase().split(/\s+/),
])
index.add({ title: 'Hello World' })
index.add({ title: 'Hello There' })
index.get('hello') // both documents
index.size // 2 <- documents
index.dimension // 3 <- distinct tokensPass a single tokenizer only when documents and queries have the same shape: it runs on both indexing and querying, so the document-shaped function above would crash on index.get('hello') without the two-function form. There is no ranking, no scoring, and no stemming; a multi-token query returns documents containing all tokens with no ordering. For anything a user will see, use a real search library.
Group connected items with a disjoint setunion-find
const StaticDisjointSet = require('mnemonist/static-disjoint-set')
const sets = new StaticDisjointSet(5) // items 0..4, fixed
sets.union(0, 1)
sets.union(3, 4)
sets.connected(0, 1) // true
sets.connected(0, 3) // false
sets.find(1) // representative index for the set
sets.dimension // 3 remaining sets
sets.size // 5 items
sets.mapping() // Uint8Array assigning each item a group idStatic means the item count is fixed at construction and items are integers 0 to n-1, so you need your own array or Map to translate real ids into indices and back. mapping() and compile() allocate on every call, so hoist them out of loops.
Find near matches with a BK-treefuzzy-match
const BKTree = require('mnemonist/bk-tree')
const levenshtein = require('./levenshtein') // your own metric
const tree = BKTree.from(['book', 'books', 'boo', 'cake'], levenshtein)
tree.search(1, 'book')
// [ { item: 'book', distance: 0 },
// { item: 'books', distance: 1 },
// { item: 'boo', distance: 1 } ]
tree.add('booking')The distance function has to be a true metric (symmetric, and obeying the triangle inequality) or the pruning is invalid and results silently go missing. Levenshtein qualifies; a normalized or weighted variant often does not. Results come back as objects with item and distance, unsorted beyond the traversal order.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| js-sdsl | npm | You want a broad container library too but prefer a C++ STL style API and real ESM plus CJS builds |
| heap-js | npm | A binary heap or priority queue is the only structure you need, and you want ESM, TypeScript, and focused docs |
| denque | npm | You only need a fast growable double-ended queue for a job buffer or a work list |
| lru-cache | npm | Caching is the actual problem, and you want TTLs, size-based eviction, stale-while-revalidate, and disposal hooks that mnemonist does not have |