mrkeyoor.com_
Wed 05 Aug 19:54 UTC
npmUtilsupdated 05 Aug 2026

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.

Verdict

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.

API stability4/5The v7 rewrite was a hard break, but since then majors have been small (named-export-only in v9, a fetch() return type tweak in v10, Node version bumps in v11); the core API is settled.
Docs4/5A long practical README plus generated typedocs covering every option; the honesty about performance trade-offs is rare, though the sheer option count makes the docs dense reading.
Maintenance5/5Pushed July 2026 with exactly 1 open issue against half a billion weekly downloads; isaacs has maintained it since the early npm days.
Ecosystem5/5One of the most-depended-on packages on npm; it is already in nearly every dependency tree, and diagnostics_channel instrumentation plugs into standard Node observability tooling.

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

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 expired

Expired 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 | undefined

Keys 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 expired

A 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 expiry

get() promotes the entry to most-recently-used; peek() does not, which matters when instrumentation or debugging reads should not distort eviction order.

Alternatives

PackageRegistryPick it when
quick-lrunpmYou want a minimal modern LRU with size limits and little else, at a fraction of the footprint.
mnemonistnpmYou need raw speed with short-string or integer keys and can live without TTLs and async fetch.
@isaacs/ttlcachenpmExpiry time, not recency, is your real eviction policy; same author, TTL-first design.