Skip to main content

Guide

lru-cache vs node-cache vs quick-lru 2026

Compare lru-cache, node-cache, and quick-lru for in-memory caching in Node.js. LRU eviction, TTL support, TypeScript ergonomics, memory efficiency, and when.

·PkgPulse Team·
0

TL;DR

lru-cache is the standard choice — battle-tested, TypeScript-native, excellent size/TTL controls, and used by npm and thousands of packages as a dependency. node-cache is straightforward if you need TTL-first semantics without LRU eviction complexity. quick-lru is the minimalist option — zero dependencies, tiny, perfect for embedding in libraries. For most Node.js applications, lru-cache v10+ is the default recommendation.

Key Takeaways

  • lru-cache: ~200M weekly downloads (transitive) — npm's own cache, enterprise-grade, TypeScript-native
  • node-cache: ~1.5M weekly downloads — TTL-first design, simple get/set/delete API, events
  • quick-lru: ~100M weekly downloads (transitive) — zero deps, ESM-native, great for library authors
  • In-process cache is best for: computed values, parsed configs, hot data with low cardinality
  • Use Redis instead when: data must persist restarts, multiple processes share state, cache > 1GB
  • lru-cache v10 is a complete rewrite — much better TypeScript types than v7/v8

PackageWeekly DownloadsApproachTTLEvents
lru-cache~200M (transitive)LRU + size/TTL
quick-lru~100M (transitive)LRU only
node-cache~1.5MTTL-first

Note: lru-cache and quick-lru are transitively downloaded by thousands of packages — weekly numbers reflect the full dependency tree.


When to Use In-Process Cache vs Redis

In-Process Cache (lru-cache, node-cache):
  ✅ Single process / single server
  ✅ Data fits in memory (< 100MB typical)
  ✅ Cache miss is cheap (fast computation or small DB query)
  ✅ Data doesn't need to survive restarts
  ✅ Sub-millisecond access required
  ❌ Multiple server instances (cache is not shared)

Redis:
  ✅ Multiple processes or servers
  ✅ Cache needs to survive restarts
  ✅ Large cache (> available RAM)
  ✅ Shared rate limiting, sessions
  ❌ Adds network latency (~1-5ms per operation)

lru-cache

lru-cache is the most feature-complete option — used internally by npm itself for package manifest caching.

Basic Usage

import { LRUCache } from "lru-cache"

// Size-based cache (limits total memory, not item count):
const cache = new LRUCache<string, string>({
  max: 500,                     // Max 500 items
  maxSize: 50_000_000,         // Max 50MB total
  sizeCalculation: (value) => Buffer.byteLength(value),  // Size per item
  ttl: 1000 * 60 * 5,          // 5 minute TTL
  allowStale: false,            // Return undefined if expired (default)
  updateAgeOnGet: false,        // Don't refresh TTL on read
  updateAgeOnHas: false,
})

// Store package data:
cache.set("react", JSON.stringify(reactData))

// Retrieve:
const data = cache.get("react")  // string | undefined
if (data) {
  return JSON.parse(data)
}

Typed Cache with Objects

interface PackageData {
  name: string
  weeklyDownloads: number
  version: string
  fetchedAt: number
}

const packageCache = new LRUCache<string, PackageData>({
  max: 1000,
  ttl: 1000 * 60 * 10,  // 10 minutes
  // No sizeCalculation needed for object count limit
})

// Fetch-or-cache pattern:
async function getPackage(name: string): Promise<PackageData> {
  const cached = packageCache.get(name)
  if (cached) return cached

  const data = await fetchFromNpm(name)
  packageCache.set(name, data)
  return data
}

Advanced: TTL Per Item

// Override TTL for specific items:
const cache = new LRUCache<string, PackageData>({
  max: 1000,
  ttl: 1000 * 60 * 5,  // Default: 5 minutes
})

// High-traffic packages get shorter TTL (update more frequently):
cache.set("react", reactData, { ttl: 1000 * 60 })  // 1 minute

// Rarely changing packages get longer TTL:
cache.set("is-odd", isOddData, { ttl: 1000 * 60 * 60 })  // 1 hour

Fetch Pattern (Built-in Async Loading)

lru-cache v10 has a built-in fetchMethod that handles concurrent fetches — prevents the thundering herd problem:

const cache = new LRUCache<string, PackageData>({
  max: 500,
  ttl: 1000 * 60 * 5,
  allowStale: true,  // Return stale data while revalidating

  // Runs once per key even with concurrent requests:
  fetchMethod: async (name, staleValue, { signal }) => {
    const response = await fetch(`https://registry.npmjs.org/${name}`, { signal })
    if (!response.ok) throw new Error(`npm fetch failed: ${response.status}`)
    return response.json()
  },
})

// Multiple concurrent calls with same key = single fetch:
const [a, b, c] = await Promise.all([
  cache.fetch("react"),  // → one HTTP request
  cache.fetch("react"),  // → waits for same request
  cache.fetch("react"),  // → waits for same request
])

Cache Statistics

// Inspect cache state:
console.log(cache.size)          // Current item count
console.log(cache.calculatedSize) // Total size (if sizeCalculation set)

// Iterate over entries:
for (const [key, value] of cache.entries()) {
  console.log(key, value)
}

// Get remaining TTL:
const ttl = cache.getRemainingTTL("react")  // ms remaining, 0 if expired/not set

// Peek without affecting LRU order:
const value = cache.peek("react")

// Invalidate:
cache.delete("react")
cache.clear()

node-cache

node-cache has a simpler API focused on TTL semantics — closer to a traditional key-value cache interface.

import NodeCache from "node-cache"

const cache = new NodeCache({
  stdTTL: 300,        // Default TTL: 5 minutes (in seconds, not ms)
  checkperiod: 600,   // Run cleanup every 10 minutes
  useClones: true,    // Clone objects on get/set (prevents mutation bugs)
  deleteOnExpire: true,
  maxKeys: -1,        // No limit by default
})

// Set with default TTL:
cache.set("react", reactData)

// Set with custom TTL:
cache.set("react", reactData, 3600)  // 1 hour

// Get (returns undefined if expired or missing):
const data = cache.get<PackageData>("react")

// Get multiple:
const results = cache.mget<PackageData>(["react", "vue", "angular"])
// Returns: { react: PackageData, vue: PackageData, angular: undefined }

// Check existence without getting:
const exists = cache.has("react")

// Get with TTL info:
const ttl = cache.getTtl("react")  // Unix timestamp when key expires

// Delete:
cache.del("react")
cache.del(["react", "vue"])  // Batch delete

// Flush:
cache.flushAll()

// Stats:
const stats = cache.getStats()
// { hits: 100, misses: 20, keys: 50, ksize: 5000, vsize: 100000 }

node-cache events:

// Event-driven cache management:
cache.on("set", (key, value) => {
  console.log(`Cached: ${key}`)
})

cache.on("del", (key, value) => {
  console.log(`Evicted: ${key}`)
})

cache.on("expired", (key, value) => {
  // Trigger background refresh:
  refreshInBackground(key)
})

cache.on("flush", () => {
  console.log("Cache cleared")
})

node-cache limitations:

  • No built-in LRU eviction — items expire by TTL only, not by access frequency
  • Memory can grow unbounded without maxKeys or manual eviction
  • Clone overhead can be significant for large objects (disable with useClones: false)

quick-lru

quick-lru (by Sindre Sorhus) is zero-dependency, ESM-native, and designed for embedding in libraries.

import QuickLRU from "quick-lru"

// Count-based LRU — no TTL support:
const cache = new QuickLRU<string, PackageData>({
  maxSize: 1000,     // Max items (evicts least-recently-used when full)
  maxAge: 300_000,   // Optional TTL in milliseconds (v7+)
  onEviction: (key, value) => {
    console.log(`Evicted: ${key}`)
  },
})

// Standard Map-like API:
cache.set("react", reactData)
const data = cache.get("react")  // PackageData | undefined
const exists = cache.has("react")
cache.delete("react")
cache.clear()

// Size:
console.log(cache.size)

// Iteration (LRU order):
for (const [key, value] of cache) {
  console.log(key)
}

quick-lru in a library:

// Installing lru-cache in your users' apps is 100KB
// quick-lru is 3KB — much better for library authors:

import QuickLRU from "quick-lru"

// Cache resolved package names internally:
const resolvedCache = new QuickLRU<string, string>({ maxSize: 100 })

export async function resolvePackage(name: string): Promise<string> {
  if (resolvedCache.has(name)) return resolvedCache.get(name)!

  const resolved = await computeResolution(name)
  resolvedCache.set(name, resolved)
  return resolved
}

Feature Comparison

Featurelru-cachenode-cachequick-lru
LRU eviction
TTL support✅ (v7+)
Per-item TTL
Size-based limit✅ (bytes)⚠️ (count)⚠️ (count)
Async fetchMethod
Events✅ (onEviction)
TypeScript✅ Native✅ ESM
Zero dependencies
ESM native
Bundle size~100KB~50KB~3KB

When to Use Each

Choose lru-cache if:

  • You need the most feature-complete in-memory cache
  • Thundering herd protection (built-in fetchMethod deduplication)
  • Memory-size-based limits are required (not just item count)
  • You want per-item TTL overrides

Choose node-cache if:

  • TTL semantics are your primary concern (not LRU)
  • Cache events for monitoring are important
  • The API's intuitive get/set/expire model fits your mental model
  • You don't need LRU eviction

Choose quick-lru if:

  • You're a library author and bundle size matters
  • Zero-dependency is a requirement
  • You need a simple LRU with optional TTL
  • The clean ESM-native API is preferred

The Thundering Herd Problem and lru-cache's fetchMethod

One of lru-cache v10's most valuable production features is its built-in solution to the thundering herd problem — a failure mode that is easy to overlook when building a cache, but costly in production.

The thundering herd occurs when a cached value expires (or is absent on cold start) and multiple concurrent requests all find a cache miss simultaneously. Without deduplication, all of them independently fire the expensive underlying operation — a database query, an external API call, or a heavy computation. If your cache has 1,000 concurrent users hitting a popular key at expiration time, you get 1,000 simultaneous database queries instead of one.

lru-cache's fetchMethod option addresses this by coalescing concurrent fetches for the same key. When multiple callers call cache.fetch('react') concurrently and the key is missing or expired, only one actual fetchMethod invocation runs. All other concurrent callers receive a Promise that resolves to the same result when the single fetch completes. This is a significant correctness guarantee that you would otherwise need to implement manually with a Map<string, Promise<T>> of in-flight requests.

The allowStale: true option compounds this benefit. When a cached value has expired but allowStale is true, cache.fetch() immediately returns the stale value to all callers while simultaneously running fetchMethod in the background to revalidate. Users get instant responses, and the cache refreshes without any caller waiting. This is the same pattern used by CDN stale-while-revalidate headers, applied to your in-process cache. Node-cache and quick-lru do not have equivalent mechanisms — if you need this pattern with those libraries, you have to implement the deduplication logic yourself.

Memory Management and Sizing Strategies

In-process caches compete with the rest of your Node.js process for heap memory. Poorly sized caches can trigger excessive GC pressure or, in extreme cases, OOM crashes. The three libraries take meaningfully different approaches to memory control that affect how you size them in practice.

lru-cache offers two independent limiting mechanisms: item count (max) and byte size (maxSize with sizeCalculation). Using byte-based sizing is the more robust approach for production because item-count limits assume all items are the same size, which is rarely true. A cache of npm package metadata objects where popular packages have 50KB of data and obscure packages have 1KB of data will behave very differently under a max: 1000 count limit versus a maxSize: 50_000_000 byte limit. With byte-based sizing, you can reason directly about memory usage in terms the Node.js heap inspector understands.

node-cache uses count-based limits only via maxKeys. The useClones: true default option (which deep-clones objects on every get and set to prevent accidental mutation) adds hidden memory overhead that scales with object complexity. For large cached objects, cloning can double the effective memory cost and add measurable CPU overhead per cache operation. Disabling cloning with useClones: false is appropriate when you treat cached values as immutable, which is the recommended pattern anyway.

quick-lru is the most memory-efficient of the three for its feature set. Its implementation uses two Maps (a primary and a secondary, implementing a generational LRU) rather than a doubly-linked list, which reduces per-entry overhead. The tradeoff is that the eviction boundary is approximate — items in the secondary map may be evicted before strictly-LRU order demands, but for typical caching workloads this approximation is irrelevant. The zero-dependency and minimal-implementation advantages make quick-lru the right choice when the cache is a small part of a larger library that should not balloon its own dependency tree.

Choosing Between In-Process and Distributed Caching

The decision between an in-process cache and a distributed cache like Redis or Memcached is architectural, but it's worth understanding the nuanced middle ground before defaulting to Redis for everything.

In-process caches have sub-microsecond access times since there is no serialization, network I/O, or connection pooling. For computed values that are expensive to produce — parsed configurations, pre-rendered HTML fragments, or resolved module graphs — an in-process LRU cache is the highest-performance option available, especially when the same keys are hit repeatedly within a single process.

The key limitation is process isolation: each server instance has its own independent cache, so cache hits on one server do not benefit another. For horizontally scaled applications with 10+ instances, popular keys get independently cached on each instance (which is fine — redundant caching is acceptable), but an invalidation event requires either a cache clear on all instances (usually via a coordinated restart or a pub/sub message to each instance) or accepting some window of stale data across the fleet.

A two-tier caching strategy combines both: check the in-process lru-cache first (sub-millisecond), then fall back to Redis (1-5ms), then to the origin database. This is the pattern used in high-traffic Node.js APIs where the same few hundred items are requested thousands of times per second — the in-process cache absorbs the majority of traffic while Redis provides cross-instance consistency and durability.

Methodology

Download data from npm registry (weekly average, February 2026). Feature comparison based on lru-cache v10.x, node-cache v5.x, and quick-lru v7.x.

Compare caching and performance packages on PkgPulse →

See also: html-minifier-terser vs htmlnano vs minify-html 2026 and cac vs meow vs arg 2026, acorn vs @babel/parser vs espree.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.