lru-cache vs quick-lru vs cacheable-request vs cache-manager vs memory-cache
Caching Strategies and Libraries for Node.js Applications
lru-cachequick-lrucacheable-requestcache-managermemory-cacheSimilar Packages:

Caching Strategies and Libraries for Node.js Applications

These libraries provide solutions for storing and retrieving data efficiently to improve application performance. lru-cache and quick-lru offer in-memory storage with Least Recently Used eviction policies, suitable for simple key-value needs. cache-manager provides a wrapper that supports multiple storage backends (memory, Redis, etc.) and stacking. cacheable-request focuses specifically on caching HTTP requests based on standard HTTP headers. memory-cache is a simpler in-memory option but has fallen out of favor compared to more actively maintained alternatives.

Npm Package Weekly Downloads Trend

3 Years

Github Stars Ranking

Stat Detail

Package
Downloads
Stars
Size
Issues
Publish
License
lru-cache349,236,4775,9183.33 MB14a month agoBlueOak-1.0.0
quick-lru26,402,36277020.4 kB1a year agoMIT
cacheable-request21,203,6772,01179.3 kB15 months agoMIT
cache-manager02,01148.7 kB14 months agoMIT
memory-cache01,592-329 years agoBSD-2-Clause

Caching Strategies and Libraries for Node.js Applications

Caching is essential for building fast applications. It reduces database load, speeds up API responses, and improves user experience. The JavaScript ecosystem offers several tools for this, but they solve different problems. Some focus on HTTP requests, others on in-memory data structures, and some on abstracting storage backends. Let's compare cache-manager, cacheable-request, lru-cache, memory-cache, and quick-lru to help you pick the right tool.

๐Ÿ—๏ธ Architecture: Wrapper vs. Direct Implementation

The first decision is whether you need a direct data structure or an abstraction layer.

lru-cache provides a direct implementation of a Least Recently Used map. You interact with the data structure itself. It is fast and has no dependencies.

// lru-cache: Direct usage
import { LRUCache } from 'lru-cache';

const cache = new LRUCache({ max: 500 });
cache.set('key', 'value');
const value = cache.get('key');

quick-lru is similar but focuses on minimalism. It also gives you a direct Map-like interface with LRU eviction.

// quick-lru: Direct usage
import QuickLRU from 'quick-lru';

const lru = new QuickLRU({ maxSize: 500 });
lru.set('key', 'value');
const value = lru.get('key');

cache-manager wraps storage engines. You don't talk to the memory directly; you talk to the manager, which talks to the store (memory, Redis, etc.).

// cache-manager: Abstraction layer
import cacheManager from 'cache-manager';

const cache = await cacheManager.caching({ store: 'memory', max: 500 });
await cache.set('key', 'value');
const value = await cache.get('key');

memory-cache is a direct in-memory store but uses a simpler, older API style compared to modern ES6 classes.

// memory-cache: Direct usage
import Cache from 'memory-cache';

const cache = new Cache();
cache.put('key', 'value');
const value = cache.get('key');

cacheable-request is different. It wraps HTTP requests, not just data. It caches the response of a network call.

// cacheable-request: HTTP wrapper
import CacheableRequest from 'cacheable-request';

const cacheableRequest = new CacheableRequest(request);
const emit = cacheableRequest.get('https://api.example.com/data');
emit.on('response', response => { /* handle response */ });

โฑ๏ธ Time-To-Live (TTL) Management

Most caches need to expire data. The way they handle this varies significantly.

lru-cache supports TTL per item or globally. It automatically removes stale items on access or via a background process in newer versions.

// lru-cache: TTL support
const cache = new LRUCache({ 
  max: 500, 
  ttl: 1000 * 60 * 5 // 5 minutes 
});
cache.set('key', 'value');

cache-manager handles TTL through its store configuration. When setting a value, you pass the time in seconds.

// cache-manager: TTL support
await cache.set('key', 'value', { ttl: 300 }); // 5 minutes

memory-cache allows you to pass a timeout in milliseconds when putting a value.

// memory-cache: TTL support
cache.put('key', 'value', 300 * 1000); // 5 minutes in ms

quick-lru does not support TTL natively. It only evicts based on size (maxSize). You would need to build time-based expiration yourself.

// quick-lru: No native TTL
// You must store timestamps manually
const lru = new QuickLRU({ maxSize: 500 });
lru.set('key', { value: 'data', timestamp: Date.now() });
// Custom logic needed to check timestamp on get()

cacheable-request relies on HTTP headers. It respects Cache-Control, Expires, and ETag from the server response.

// cacheable-request: HTTP Header TTL
// Automatically parses 'Cache-Control: max-age=300' from server
const emit = cacheableRequest.get('https://api.example.com/data');

๐Ÿงน Eviction Policies: LRU vs. Simple Expiration

When the cache is full, what gets removed?

lru-cache strictly follows Least Recently Used. The item you haven't touched in the longest time goes first.

// lru-cache: Strict LRU
const cache = new LRUCache({ max: 2 });
cache.set('a', 1);
cache.set('b', 2);
cache.set('c', 3); // 'a' is evicted
console.log(cache.has('a')); // false

quick-lru also uses LRU eviction but is optimized for speed and simplicity.

// quick-lru: Strict LRU
const lru = new QuickLRU({ maxSize: 2 });
lru.set('a', 1);
lru.set('b', 2);
lru.set('c', 3); // 'a' is evicted
console.log(lru.has('a')); // false

memory-cache uses expiration primarily. While it has a max size, its main focus is time-based expiration rather than strict access-order eviction.

// memory-cache: Expiration focus
cache.put('a', 1, 1000);
cache.put('b', 2, 1000);
// Eviction happens based on checkInterval and timeouts

cache-manager depends on the underlying store. The memory store uses LRU, but a Redis store might use different strategies configured on the Redis side.

// cache-manager: Store dependent
// Memory store uses LRU, Redis uses Redis policies
const cache = await cacheManager.caching({ store: 'memory', max: 2 });

cacheable-request does not evict based on LRU in the traditional sense. It stores responses on disk or memory based on HTTP validity.

// cacheable-request: HTTP validity
// Stores responses until HTTP headers say they are stale

๐Ÿ› ๏ธ Maintenance and Status

Choosing a library also means choosing a maintainer. Some of these packages are older and less active.

lru-cache is actively maintained and widely used in the Node.js core ecosystem. It receives regular updates for performance and security.

cache-manager is actively maintained. It is the go-to for multi-store caching in Node.js servers.

cacheable-request is maintained and commonly used with the got HTTP client. It is stable for HTTP caching needs.

quick-lru is stable and maintained by a well-known open-source developer. It is lightweight and reliable.

memory-cache shows signs of age. It has not seen significant updates in a long time. It is often flagged as a legacy option.

โš ๏ธ Warning: memory-cache is not recommended for new projects. It lacks the modern features and active security patches of lru-cache.

๐ŸŒ Real-World Scenarios

Scenario 1: Caching API Responses in a Backend

You are building a Node.js API. You want to cache database query results in memory to reduce load.

  • โœ… Best choice: lru-cache
  • Why? You need strict memory limits and TTL to prevent memory leaks in a long-running process.
// lru-cache: Backend data caching
const queryCache = new LRUCache({ max: 1000, ttl: 60000 });
async function getUser(id) {
  if (queryCache.has(id)) return queryCache.get(id);
  const user = await db.query(id);
  queryCache.set(id, user);
  return user;
}

Scenario 2: Caching HTTP Requests in a Scraper

You are scraping websites. You want to avoid hitting the same URL twice within an hour.

  • โœ… Best choice: cacheable-request
  • Why? It handles HTTP semantics (ETags, 304 Not Modified) automatically.
// cacheable-request: HTTP scraping
const cacheableRequest = new CacheableRequest(request);
const emit = cacheableRequest.get('https://target.com/page');

Scenario 3: Multi-Layer Caching (Memory + Redis)

You have a high-traffic app. You want fast local memory cache backed by a shared Redis cache.

  • โœ… Best choice: cache-manager
  • Why? It supports stacking stores out of the box.
// cache-manager: Stacking stores
const multiCache = await cacheManager.multiCaching([memoryCache, redisCache]);
await multiCache.set('key', 'value');

Scenario 4: Lightweight Frontend Utility

You need a small cache in a browser extension or a small utility script without heavy dependencies.

  • โœ… Best choice: quick-lru
  • Why? It has zero dependencies and a tiny footprint.
// quick-lru: Frontend utility
const lru = new QuickLRU({ maxSize: 100 });
lru.set('config', settings);

๐Ÿ“Š Summary Table

Featurelru-cachequick-lrucache-managermemory-cachecacheable-request
Primary UseIn-memory dataIn-memory dataMulti-store wrapperIn-memory dataHTTP requests
EvictionLRULRUStore-dependentExpiration/SizeHTTP Headers
TTL Supportโœ… NativeโŒ Manualโœ… Nativeโœ… Nativeโœ… HTTP Headers
DependenciesNoneNoneFewNoneFew
StatusActiveActiveActiveLegacyActive

๐Ÿ’ก Final Recommendation

For most Node.js applications needing in-memory caching, lru-cache is the standard choice. It balances features, performance, and maintenance.

If you need to cache HTTP calls specifically, cacheable-request saves you from reinventing the wheel for header parsing.

If you need to abstract over Redis and Memory together, cache-manager is the only one on this list that handles that architecture.

Avoid memory-cache in new work. Use quick-lru when you need something smaller and simpler than lru-cache without TTL needs.

How to Choose: lru-cache vs quick-lru vs cacheable-request vs cache-manager vs memory-cache

  • lru-cache:

    Choose lru-cache if you need a robust, feature-rich in-memory cache with strict LRU eviction, TTL support, and size limits. It is the industry standard for pure JavaScript in-memory caching in Node.js environments.

  • quick-lru:

    Choose quick-lru if you need a minimal, dependency-free LRU cache with a simple API and no external dependencies. It is perfect for lightweight frontend or backend tasks where you just need basic max-size eviction without extra features.

  • cacheable-request:

    Choose cacheable-request if your primary goal is to cache HTTP GET requests automatically based on standard HTTP headers. It integrates well with the got HTTP client and is best for reducing network overhead in data-fetching layers.

  • cache-manager:

    Choose cache-manager if you need a unified interface for multiple caching backends like Redis or memory, or if you require stacking caches (e.g., memory + Redis). It is ideal for server-side applications where flexibility and abstraction over the storage layer are critical.

  • memory-cache:

    Avoid memory-cache for new projects as it is less actively maintained and lacks features found in modern alternatives. Only consider it for legacy systems where it is already deeply integrated and changing it poses too much risk.

README for lru-cache

lru-cache

A cache object that deletes the least-recently-used items.

Specify a max number of the most recently used items that you want to keep, and this cache will keep that many of the most recently accessed items.

This is not primarily a TTL cache, and does not make strong TTL guarantees. There is no preemptive pruning of expired items by default, but you may set a TTL on the cache or on a single set. If you do so, it will treat expired items as missing, and delete them when fetched. If you are more interested in TTL caching than LRU caching, check out @isaacs/ttlcache.

As of version 7, this is one of the most performant LRU implementations available in JavaScript, and supports a wide diversity of use cases. However, note that using some of the features will necessarily impact performance, by causing the cache to have to do more work. See the "Performance" section below.

Installation

npm install lru-cache --save

Usage

// hybrid module, either works
import { LRUCache } from 'lru-cache'
// or:
const { LRUCache } = require('lru-cache')
// or in minified form for web browsers:
import { LRUCache } from 'http://unpkg.com/lru-cache@9/dist/mjs/index.min.mjs'

// At least one of 'max', 'ttl', or 'maxSize' is required, to prevent
// unsafe unbounded storage.
//
// In most cases, it's best to specify a max for performance, so all
// the required memory allocation is done up-front.
//
// All the other options are optional, see the sections below for
// documentation on what each one does.  Most of them can be
// overridden for specific items in get()/set()
const options = {
  max: 500,

  // for use with tracking overall storage size
  maxSize: 5000,
  sizeCalculation: (value, key) => {
    return 1
  },

  // for use when you need to clean up something when objects
  // are evicted from the cache
  dispose: (value, key, reason) => {
    freeFromMemoryOrWhatever(value)
  },

  // for use when you need to know that an item is being inserted
  // note that this does NOT allow you to prevent the insertion,
  // it just allows you to know about it.
  onInsert: (value, key, reason) => {
    logInsertionOrWhatever(key, value)
  },

  // how long to live in ms
  ttl: 1000 * 60 * 5,

  // return stale items before removing from cache?
  allowStale: false,

  updateAgeOnGet: false,
  updateAgeOnHas: false,

  // async method to use for cache.fetch(), for
  // stale-while-revalidate type of behavior
  fetchMethod: async (key, staleValue, { options, signal, context }) => {},
}

const cache = new LRUCache(options)

cache.set('key', 'value')
cache.get('key') // "value"

// non-string keys ARE fully supported
// but note that it must be THE SAME object, not
// just a JSON-equivalent object.
var someObject = { a: 1 }
cache.set(someObject, 'a value')
// Object keys are not toString()-ed
cache.set('[object Object]', 'a different value')
assert.equal(cache.get(someObject), 'a value')
// A similar object with same keys/values won't work,
// because it's a different object identity
assert.equal(cache.get({ a: 1 }), undefined)

cache.clear() // empty the cache

If you put more stuff in the cache, then less recently used items will fall out. That's what an LRU cache is.

For full description of the API and all options, please see the LRUCache typedocs

Storage Bounds Safety

This implementation aims to be as flexible as possible, within the limits of safe memory consumption and optimal performance.

At initial object creation, storage is allocated for max items. If max is set to zero, then some performance is lost, and item count is unbounded. Either maxSize or ttl must be set if max is not specified.

If maxSize is set, then this creates a safe limit on the maximum storage consumed, but without the performance benefits of pre-allocation. When maxSize is set, every item must provide a size, either via the sizeCalculation method provided to the constructor, or via a size or sizeCalculation option provided to cache.set(). The size of every item must be a positive integer.

If neither max nor maxSize are set, then ttl tracking must be enabled. Note that, even when tracking item ttl, items are not preemptively deleted when they become stale, unless ttlAutopurge is enabled. Instead, they are only purged the next time the key is requested. Thus, if ttlAutopurge, max, and maxSize are all not set, then the cache will potentially grow unbounded.

In this case, a warning is printed to standard error. Future versions may require the use of ttlAutopurge if max and maxSize are not specified.

If you truly wish to use a cache that is bound only by TTL expiration, consider using a Map object, and calling setTimeout to delete entries when they expire. It will perform much better than an LRU cache.

Here is an implementation you may use, under the same license as this package:

// a storage-unbounded ttl cache that is not an lru-cache
const cache = {
  data: new Map(),
  timers: new Map(),
  set: (k, v, ttl) => {
    if (cache.timers.has(k)) {
      clearTimeout(cache.timers.get(k))
    }
    cache.timers.set(
      k,
      setTimeout(() => cache.delete(k), ttl),
    )
    cache.data.set(k, v)
  },
  get: k => cache.data.get(k),
  has: k => cache.data.has(k),
  delete: k => {
    if (cache.timers.has(k)) {
      clearTimeout(cache.timers.get(k))
    }
    cache.timers.delete(k)
    return cache.data.delete(k)
  },
  clear: () => {
    cache.data.clear()
    for (const v of cache.timers.values()) {
      clearTimeout(v)
    }
    cache.timers.clear()
  },
}

If that isn't to your liking, check out @isaacs/ttlcache.

Storing Undefined Values

This cache never stores undefined values, as undefined is used internally in a few places to indicate that a key is not in the cache.

You may call cache.set(key, undefined), but this is just an alias for cache.delete(key). Note that this has the effect that cache.has(key) will return false after setting it to undefined.

cache.set(myKey, undefined)
cache.has(myKey) // false!

If you need to track undefined values, and still note that the key is in the cache, an easy workaround is to use a sigil object of your own.

import { LRUCache } from 'lru-cache'
const undefinedValue = Symbol('undefined')
const cache = new LRUCache(...)
const mySet = (key, value) =>
  cache.set(key, value === undefined ? undefinedValue : value)
const myGet = (key, value) => {
  const v = cache.get(key)
  return v === undefinedValue ? undefined : v
}

Tracing and Observability

Most methods can accept a status option, which is an LRUCache.Status object that will be decorated along the operation with indications about what was done and why.

Additionally, this library is instrumented using the node:diagnostics_channel module on Node and other platforms that support it. In order to get diagnostics metrics, listen on the channel('lru-cache:metrics'). To get Tracing Channel traces, subscribe to the tracingChannel('lru-cache'). The LRUCache.Status objects will be provided as the message context to those channel listeners.

For example, you could do the following to get comprehensive information about every LRUCache instance in your application:

import { tracingChannel, subscribe } from 'node:diagnostics_channel'

subscribe('lru-cache:metrics', (message, name) => {
  // name will always be 'lru-cache:metrics'
  // message will be the LRUCache.Status object for whatever
  // synchronous operation was performed.
  console.error('LRUCache Metrics', message)
})

tracingChannel('lru-cache').subscribe({
  start: status => {
    // a traced operation is starting
  },
  asyncStart: status => {
    // an async traced operation is starting
  },
  asyncEnd: status => {
    // an async traced operation is ending
  }
  error: status => {
    // a traced operation failed
  },
  end: status => {
    // a traced operation is complete
  },
})

The async cache.fetch() and cache.forceFetch methods are covered by tracingChannels. All the other operations are covered by the lru-cache:metrics channel, because they are strictly synchronous, and thus don't have an asynchronous lifecycle to track.

Note that using status objects or using node:diagnostics_channel listeners will impose a modest performance penalty. Creating data objects is not ever free; do not believe anyone who tells you otherwise. But it is as small as possible.

Platform Compatibility Caveat

Not all platforms support the node:diagnostics_channel module. Currently, this is only available in Node, Bun, and Deno, and some edge computing platforms that provide a Node compatibility layer.

To work around this, if you are loading in a non-Node environment, the package.json exports will direct your module loader to pull in a version that starts out with a dummy implementation, then does a conditional dynamic import of the node:diagnostics_channel module, and then swaps out those dummy objects with the real thing if it succeeds. This means that cache metrics and tracing channels started in the first load-time tick of your application will not be covered, except in environments that load using the require import condition, or both the node and esm import conditions together.

Top-level await could be used to remove this caveat, but that feature is dead on arrival, unfortunately. See #397 and #398 for more details.

Performance

As of April 2026, version 11 of this library is one of the most performant LRU cache implementations in JavaScript.

Benchmarks can be extremely difficult to get right. In particular, the performance of set/get/delete operations on objects will vary wildly depending on the type of key used. V8 is highly optimized for objects with keys that are short strings, especially integer numeric strings. Thus any benchmark which tests solely using numbers as keys will tend to find that an object-based approach performs the best.

Note that coercing anything to strings to use as object keys is unsafe, unless you can be 100% certain that no other type of value will be used. For example:

const myCache = {}
const set = (k, v) => (myCache[k] = v)
const get = k => myCache[k]

set({}, 'please hang onto this for me')
set('[object Object]', 'oopsie')

Also beware of "Just So" stories regarding performance. Garbage collection of large (especially: deep) object graphs can be incredibly costly, with several "tipping points" where it increases exponentially. As a result, putting that off until later can make it much worse, and less predictable. If a library performs well, but only in a scenario where the object graph is kept shallow, then that won't help you if you are using large objects as keys.

In general, when attempting to use a library to improve performance (such as a cache like this one), it's best to choose an option that will perform well in the sorts of scenarios where you'll actually use it.

This library is optimized for repeated gets and minimizing eviction time, since that is the expected need of a LRU. Set operations are somewhat slower on average than a few other options, in part because of that optimization. It is assumed that you'll be caching some costly operation, ideally as rarely as possible, so optimizing set over get would be unwise.

If performance matters to you:

  1. If it's at all possible to use small integer values as keys, and you can guarantee that no other types of values will be used as keys, then do that, and use a cache such as lru-fast, or mnemonist's LRUCache which uses an Object as its data store.

  2. Failing that, if you can use short non-numeric strings (ie, less than 256 characters) as your keys, and you do not need any of the other features of this library, use mnemonist's LRUCache.

  3. If the types of your keys will be anything else, especially long strings, strings that look like floats, objects, or some mix of types, or if you aren't sure, then this library will work well for you.

    If you do not need the features that this library provides (like asynchronous fetching, a variety of TTL staleness options, and so on), then mnemonist's LRUMap is also a very good option, and just slightly faster than this module (since it does considerably less).

  4. Do not use a dispose function, size tracking, or especially ttl behavior or observability features, unless absolutely needed. These features are convenient, and necessary in some use cases, and every attempt has been made to make the performance impact minimal, but it isn't nothing.

Testing

When writing tests that involve TTL-related functionality, note that this module creates an internal reference to the global performance or Date objects at import time. If you import it statically at the top level, those references cannot be mocked or overridden in your test environment.

To avoid this, dynamically import the package within your tests so that the references are captured after your mocks are applied. For example:

// โŒ Not recommended
import { LRUCache } from 'lru-cache'
// mocking timers, e.g. jest.useFakeTimers()

// โœ… Recommended for TTL tests
// mocking timers, e.g. jest.useFakeTimers()
const { LRUCache } = await import('lru-cache')

This ensures that your mocked timers or time sources are respected when testing TTL behavior.

Additionally, you can pass in a perf option when creating your LRUCache instance. This option accepts any object with a now method that returns a number.

For example, this would be a very bare-bones time-mocking system you could use in your tests, without any particular test framework:

import { LRUCache } from 'lru-cache'

let myClockTime = 0

const cache = new LRUCache<string>({
  max: 10,
  ttl: 1000,
  perf: {
    now: () => myClockTime,
  },
})

// run tests, updating myClockTime as needed

Breaking Changes in Version 7

This library changed to a different algorithm and internal data structure in version 7, yielding significantly better performance, albeit with some subtle changes as a result.

If you were relying on the internals of LRUCache in version 6 or before, it probably will not work in version 7 and above.

Breaking Changes in Version 8

  • The fetchContext option was renamed to context, and may no longer be set on the cache instance itself.
  • Rewritten in TypeScript, so pretty much all the types moved around a lot.
  • The AbortController/AbortSignal polyfill was removed. For this reason, Node version 16.14.0 or higher is now required.
  • Internal properties were moved to actual private class properties.
  • Keys and values must not be null or undefined.
  • Minified export available at 'lru-cache/min', for both CJS and MJS builds.

Breaking Changes in Version 9

  • Named export only, no default export.
  • AbortController polyfill returned, albeit with a warning when used.

Breaking Changes in Version 10

  • cache.fetch() return type is now Promise<V | undefined> instead of Promise<V | void>. This is an irrelevant change practically speaking, but can require changes for TypeScript users.

For more info, see the change log.