lru-cache review
lru-cache 11.5.2 is an in-process JavaScript cache that evicts the least recently used entries when a count or calculated-size bound is reached. It supports per-entry TTLs, stale reads, disposal hooks, object keys, async loading through `fetchMethod`, request coalescing, status objects, and Node diagnostics channels. Version 11.5 adds `backgroundFetchSize`, which assigns a provisional size to an in-flight background fetch when no stale value is present. That closes a capacity-accounting gap for size-bounded caches while work is still pending.
lru-cache 11.5.2 installed in 0.5 seconds as 1 dependency-free package, used 3 MB on our box, and returned 0 audit findings. Install it for a bounded per-process cache with coalesced async loads; choose a shared store or TTL-first design when process boundaries or exact expiry drive the requirement.
We installed it
| Install | ✓ · 0.5s | 1 package on disk · 3 MB |
| Import | ✓ | ESM import works · require() works · ESM package with exports map |
| Browser | 5.8 KB | gzipped (18.3 KB minified), bundled with esbuild |
| Types | ✓ | TypeScript types bundled |
| Known vulns | 0 | 0 critical · 0 high · 0 moderate · 0 low (npm audit) |
Answers from our run
Does lru-cache install cleanly?
Yes. In a fresh container with an empty cache, npm install lru-cache finished in 0.5s, leaving 1 package and 3 MB on disk. npm audit reported no known vulnerabilities.
How much does lru-cache add to a browser bundle?
5.8 KB gzipped (18.3 KB minified) when the whole package is bundled for the browser with esbuild. Importing only part of it is usually smaller.
Does lru-cache work with both ESM and CommonJS?
Yes. Both import 'lru-cache' and require('lru-cache') worked in Node 22 in our run. The package is published as ESM with an exports map.
Does lru-cache include TypeScript types?
Yes, type declarations ship inside the package, so no @types install is needed.
lru-cache or quick-lru: which should you use?
quick-lru: Use it for a smaller count-bounded cache when async fetches, size accounting, and diagnostics are unnecessary. lru-cache 11.5.2 installed in 0.5 seconds as 1 dependency-free package, used 3 MB on our box, and returned 0 audit findings.
When should you not use lru-cache?
Cached state must be shared across processes, hosts, or deploys. lru-cache keeps values only inside the current JavaScript process.
Discussed on
- hnSparse File LRU Cache52 points
- hnRedis as an LRU cache (and the mystery of port 6379)22 points
- hnNginx + redis + lua reverse-proxy LRU cache10 points
- hnA LRU cache implementation8 points
- hnImplementing an Efficient LRU Cache in JavaScript6 points
Use it if
- One Node process needs a bounded hot-data cache with predictable least-recently-used eviction.
- Concurrent misses for the same key should share one `fetchMethod` promise and optionally serve a stale value during refresh.
- Entries vary in cost, so `maxSize`, `sizeCalculation`, and `maxEntrySize` need to enforce a budget other than item count.
- Cache hits, misses, evictions, TTL state, and fetch timing must feed status objects or `node:diagnostics_channel`.
- Cached state must be shared across processes, hosts, or deploys. lru-cache keeps values only inside the current JavaScript process.
- Expiration at an exact wall-clock deadline is the main requirement. Stale entries are normally removed on access, and the README recommends a TTL-focused cache for that job.
- Your supported Node line includes 18 or 21. Version 11 declares `20 || >=22`, so Node 21 falls outside the published engine range too.
- You need to cache `undefined` as a value. Calling `set(key, undefined)` deletes the key because `undefined` is the package's miss sentinel.
- A plain `Map` with a small known key set already meets the need. TTL tracking, size accounting, disposal, fetches, and observability each add work that the README says affects performance.
Setup reality
We installed lru-cache 11.5.2 in a fresh Node 22 Bookworm sandbox in 0.5 seconds. It left 1 package and 3 MB on disk, with 0 direct dependencies and 0 peer dependencies. npm audit found 0 known vulnerabilities. Both require() and ESM import worked, and TypeScript declarations are bundled. Our browser build measured 18.3 KB minified and 5.8 KB gzipped. The package license is BlueOak-1.0.0 and its engine range is Node 20 or 22 and newer.
Construction must include max, maxSize, or ttl; otherwise storage has no useful boundary. A positive max preallocates item slots and is the straightforward fast path. maxSize requires every stored item to provide a positive integer size, either through sizeCalculation or the individual set. A TTL-only cache does not proactively delete stale items unless ttlAutopurge is enabled, so untouched entries can retain memory after expiry.
fetchMethod turns cache.fetch(key) into a coalesced async loader. Calls for the same missing key share the in-flight request. Options decide whether stale data is returned during refresh, after rejection, or after abort. Version 11.5's backgroundFetchSize gives a pending fetch a default effective size of 1 when it does not shadow a stale value. Set it deliberately when maxSize units represent bytes or another cost scale.
Keys use identity: two separately created {id: 1} objects are different keys. undefined cannot be stored. Fake-timer tests also need care because the module captures performance or Date at import time; dynamically import after enabling timers or pass a perf object with now(). Browser and edge builds can miss the first tick of diagnostics setup because the fallback loads node:diagnostics_channel dynamically.
Patterns
Bound entries by count create-count-cache
import { LRUCache } from 'lru-cache';
const cache = new LRUCache({ max: 500 });
cache.set('user:42', profile);
const hit = cache.get('user:42');A positive `max` preallocates room for 500 entries and avoids the unbounded-storage warning.
Account for different value sizes bound-by-size
import { LRUCache } from 'lru-cache';
const cache = new LRUCache({
maxSize: 10 * 1024 * 1024,
maxEntrySize: 512 * 1024,
sizeCalculation: (value) => Buffer.byteLength(value),
});Every item needs a positive integer size when `maxSize` is active; this example rejects entries above 512 KB.
Add lazy TTL expiry expire-items
import { LRUCache } from 'lru-cache';
const cache = new LRUCache({
max: 1_000,
ttl: 60_000,
});
cache.set('session:7', session, { ttl: 5_000 });A 5-second entry is treated as missing after expiry, but it is normally removed only when accessed or evicted.
Delete expired entries on timers autopurge-expired
import { LRUCache } from 'lru-cache';
const cache = new LRUCache({
ttl: 30_000,
ttlAutopurge: true,
});`ttlAutopurge` adds timer work; use it only when stale, untouched entries must leave memory near the 30-second deadline.
Coalesce concurrent cache misses fetch-on-miss
import { LRUCache } from 'lru-cache';
const cache = new LRUCache({
max: 500,
fetchMethod: async (key, staleValue, { signal }) => {
const response = await fetch(`/api/items/${key}`, { signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
},
});
const item = await cache.fetch('42');Concurrent `fetch('42')` calls share 1 in-flight loader unless options force a refresh.
Return stale data during refresh serve-stale-refresh
const value = await cache.fetch('catalog', {
allowStale: true,
forceRefresh: true,
noDeleteOnFetchRejection: true,
});This can return the stale value immediately while 1 background request refreshes it; failed refreshes keep the prior entry.
Charge pending loads against capacity size-background-fetch
import { LRUCache } from 'lru-cache';
const cache = new LRUCache({
maxSize: 1_000,
backgroundFetchSize: 25,
sizeCalculation: (value) => value.cost,
fetchMethod: loadValue,
});Version 11.5 counts an unshadowed in-flight fetch as 25 size units here instead of the default 1.
Clean up after eviction dispose-evicted-values
const cache = new LRUCache({
max: 100,
disposeAfter(value, key, reason) {
value.close?.();
console.log({ key, reason });
},
});`disposeAfter` runs after the cache mutation, which is safer than changing the same cache inside `dispose`.
Reuse the same object key use-object-keys
const requestKey = { tenant: 'acme', id: 42 };
cache.set(requestKey, result);
cache.get(requestKey); // hit
cache.get({ tenant: 'acme', id: 42 }); // missObject keys use reference identity, so 2 objects with equal fields do not address the same entry.
Record why a lookup missed inspect-operation-status
const status = {};
const value = cache.get('user:42', { status });
console.log({ value, status });The status object is mutated with hit, miss, stale, TTL, size, and cache details; collecting it adds allocation cost.
Subscribe to synchronous metrics observe-node-cache
import { subscribe } from 'node:diagnostics_channel';
subscribe('lru-cache:metrics', (status) => {
metrics.count(`cache.${status.get ?? status.set ?? 'operation'}`);
});The `lru-cache:metrics` channel covers synchronous operations; async `fetch()` uses the separate tracing channel lifecycle.
Inject a deterministic clock test-ttl-clock
let now = 0;
const cache = new LRUCache({
max: 10,
ttl: 1_000,
perf: { now: () => now },
});
cache.set('key', 'value');
now = 1_001;
expect(cache.get('key')).toBeUndefined();The `perf` option avoids import-time capture of the real clock and makes the 1,000 ms expiry deterministic in tests.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| quick-lru | npm | Use it for a smaller count-bounded cache when async fetches, size accounting, and diagnostics are unnecessary. |
| mnemonist | npm | Use its LRU structures when keys are short strings or integers and a narrower API is enough. |
| flat-cache | npm | Use it when cache entries must persist to disk between process runs. |
More utils guides
type-fest · ajv · p-limit · find-up · js-yaml · zod · the whole shelf →
How this guide is made: grounded in the library's documentation, release notes, changelog, and issue history, on a fixed rubric — not a hands-on install of every release. The 50 most-downloaded entries are additionally install-verified in clean containers. Corrections: contact the desk.

