lru-cache
lru-cache is an in-memory cache that evicts the least-recently-used entry when it fills up. You give it a bound (max entry count, max total size, or a TTL) and it keeps the hottest items, with Map-like get/set/has/delete plus extras: per-entry TTLs, size-aware limits, dispose callbacks on eviction, and an async fetch() method that deduplicates concurrent loads and can serve stale values while revalidating. It is written in TypeScript, has zero dependencies, and at half a billion weekly downloads sits under a large chunk of the npm ecosystem, including npm itself.
The default in-process cache for Node and deservedly so: fast, bounded, feature-complete, and maintained for over a decade. Just pick your bound deliberately and resist turning on features you do not need, since size tracking, TTLs, and disposers all cost performance.
Use it if
- You memoize expensive work (DB lookups, API calls, parsed files) in a long-running Node process and need a hard bound so the cache cannot eat unbounded memory
- You want stale-while-revalidate behavior in-process: fetchMethod plus allowStale gives you request coalescing and background refresh without Redis
- Your keys are objects, long strings, or mixed types: it uses a real Map underneath, so there is no string coercion foot-gun like plain-object caches have
- You need cleanup on eviction, for example closing handles or freeing buffers, via the dispose callback
- You need a shared cache across processes or servers: this is per-process memory, so N workers hold N copies that drift; that job belongs to Redis or memcached
- TTL expiry is your primary behavior: the README itself says this is not primarily a TTL cache; expired items are only purged on access unless you pay for ttlAutopurge, and it points TTL-first users to @isaacs/ttlcache
- You only need a tiny bounded memo and none of the features: quick-lru is a fraction of the code, and mnemonist's LRUCache benchmarks faster when keys are short strings or integers
- You are on an old runtime: v11 requires Node 20 or 22+, so pinning v10 or older means running on unsupported majors of this package
Setup reality
npm install lru-cache and import { LRUCache } from 'lru-cache'; it is a named export only, and works from both ESM and CJS. The constructor is where the friction is: you must pass at least one of max, maxSize, or ttl or the cache is unbounded (it warns on stderr), maxSize additionally requires a sizeCalculation function or per-set sizes, and there are around thirty options whose interactions (allowStale, noDeleteOnStaleGet, updateAgeOnGet) reward reading the typedocs carefully. Two behavioral surprises: set(key, undefined) is an alias for delete(key), and TTL tests need dynamic import or the perf option because the clock reference is captured at import time.
Patterns
Create a bounded cachebasic-cache
import { LRUCache } from 'lru-cache'
const cache = new LRUCache({ max: 500 })
cache.set('key', 'value')
cache.get('key') // 'value'
cache.has('key') // true
cache.delete('key')At least one of max, ttl, or maxSize is required. Prefer max when you can: storage is pre-allocated up front, which is the fast path.
Add time-based expiryttl-cache
const cache = new LRUCache({
max: 1000,
ttl: 1000 * 60 * 5, // 5 minutes
})
cache.set('session', data)
cache.set('short', data, { ttl: 1000 }) // per-entry override
cache.get('session') // undefined once expiredExpired items are not removed proactively, only treated as missing on access; set ttlAutopurge: true if memory must actually shrink on expiry, at a real performance cost.
Bound by total size instead of countsize-bounded-cache
const cache = new LRUCache({
maxSize: 50 * 1024 * 1024, // ~50MB of cached bodies
sizeCalculation: (value) => value.byteLength || 1,
})
cache.set(url, buffer)With maxSize every entry must report a positive integer size, via this callback or a size option on set(); there is no pre-allocation, so it is slower than a plain max cache.
Deduplicate async loads with fetch()memoize-async-fetch
const cache = new LRUCache({
max: 500,
ttl: 60_000,
fetchMethod: async (key, staleValue, { signal }) => {
const res = await fetch(`https://api.example.com/${key}`, { signal })
return res.json()
},
})
const user = await cache.fetch('user:42')Concurrent fetch() calls for the same key share one in-flight promise, which kills thundering-herd loads. The signal aborts if the entry is evicted mid-flight.
Serve stale data while refreshingstale-while-revalidate
const cache = new LRUCache({
max: 500,
ttl: 60_000,
allowStale: true,
fetchMethod: loadFromUpstream,
})
// returns the expired value immediately and refreshes
// in the background for the next caller
const data = await cache.fetch('config')allowStale applies to get() as well as fetch(); pair it with noDeleteOnStaleGet if you want stale entries kept around instead of removed on read.
Run cleanup when entries are evictedcleanup-on-evict
const cache = new LRUCache({
max: 100,
dispose: (value, key, reason) => {
// reason: 'evict' | 'set' | 'delete' | 'expire' | 'fetch'
value.close()
},
})dispose fires on eviction, overwrite, and delete; check reason if overwrites should not trigger teardown. Do not mutate the cache inside dispose.
Use objects as cache keys safelyobject-keys
const cache = new LRUCache({ max: 100 })
const req = { path: '/a', user: 1 }
cache.set(req, result)
cache.get(req) // result
cache.get({ path: '/a', user: 1 }) // undefined!Keys use Map identity semantics: the same object works, a structurally equal one does not. Serialize to a string first if you need value equality.
Type keys and values with genericstypescript-generics
import { LRUCache } from 'lru-cache'
interface User { id: number; name: string }
const users = new LRUCache<string, User>({ max: 1000 })
users.set('u:1', { id: 1, name: 'Ada' })
const u = users.get('u:1') // User | undefinedKeys and values must not be null or undefined by design; model missing as absent, not as a stored null, or wrap values in your own sigil object.
Iterate entries or snapshot the cacheiterate-and-persist
for (const [key, value] of cache.entries()) {
// most-recently-used first
}
const snapshot = cache.dump() // serializable array
const restored = new LRUCache({ max: 500 })
restored.load(snapshot)entries() iterates newest to oldest, rentries() the reverse. dump()/load() preserve TTL bookkeeping, handy for warm restarts.
Make TTLs testable with mocked timetest-with-fake-timers
// the module captures Date/performance at import time,
// so import it after installing fake timers:
const { LRUCache } = await import('lru-cache')
// or inject a clock explicitly:
let now = 0
const cache = new LRUCache({
max: 10,
ttl: 1000,
perf: { now: () => now },
})
now += 1500 // entry is now expiredA static top-level import freezes the real clock reference before jest.useFakeTimers() runs; the perf option is the cleanest escape hatch.
Read a value without changing recencypeek-without-touching
cache.peek('key') // no recency bump
cache.get('key', { updateAgeOnGet: true }) // also resets TTL age
cache.getRemainingTTL('key') // ms until expiryget() promotes the entry to most-recently-used; peek() does not, which matters when instrumentation or debugging reads should not distort eviction order.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| quick-lru | npm | You want a minimal modern LRU with size limits and little else, at a fraction of the footprint. |
| mnemonist | npm | You need raw speed with short-string or integer keys and can live without TTLs and async fetch. |
| @isaacs/ttlcache | npm | Expiry time, not recency, is your real eviction policy; same author, TTL-first design. |