mrkeyoor.com_
Thu 06 Aug 10:56 UTC
npmCLI & Toolingupdated 06 Aug 2026

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.

Verdict

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.

API stability4/5Constructors and methods for the long-standing structures have not moved in years and old code keeps working. The counterweight is that the project has never left 0.x since 2016, so a minor bump is formally allowed to break anything, and the naming conventions across structures are inconsistent enough that porting between them is not mechanical.
Docs3/5The documentation site has a page per structure with constructor signatures, method lists, and complexity notes, which is more than most collection libraries offer. What is missing is any guidance on which structure to pick, no benchmark numbers despite performance being the stated goal, and no mention anywhere of the ESM subpath problem you will hit in the first five minutes.
Maintenance3/5One author, repo last pushed 30 April 2026 alongside the 0.40.4 release, with 0.40.0 in January 2025 before that. Bugs do get fixed and the code is stable, but 64 open issues (78 counting PRs) are outstanding and the cadence is now roughly annual.
Ecosystem4/5Around 14.0M weekly downloads against only 2432 stars, which is the signature of a package almost everyone depends on transitively and almost nobody talks about. There is no plugin surface and no community tooling around it; it is a leaf dependency that just sits there and works.

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
Skip it if

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')   // 0

The 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 entirely

remove() 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()       // 1

The 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 tokens

Pass 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 id

Static 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

PackageRegistryPick it when
js-sdslnpmYou want a broad container library too but prefer a C++ STL style API and real ESM plus CJS builds
heap-jsnpmA binary heap or priority queue is the only structure you need, and you want ESM, TypeScript, and focused docs
denquenpmYou only need a fast growable double-ended queue for a job buffer or a work list
lru-cachenpmCaching is the actual problem, and you want TTLs, size-based eviction, stale-while-revalidate, and disposal hooks that mnemonist does not have