lru-cache vs node-cache vs memory-cache vs apicache
In-Memory Caching Strategies for Node.js Applications
lru-cachenode-cachememory-cacheapicacheSimilar Packages:

In-Memory Caching Strategies for Node.js Applications

apicache, lru-cache, memory-cache, and node-cache are solutions for storing data in memory to speed up Node.js applications. lru-cache provides a low-level Least Recently Used algorithm for strict memory management. node-cache offers a simple interface with time-to-live (TTL) support for general purpose caching. apicache is middleware designed specifically for caching HTTP responses in Express apps. memory-cache is a basic key-value store that is no longer actively maintained.

Npm Package Weekly Downloads Trend

3 Years

Github Stars Ranking

Stat Detail

Package
Downloads
Stars
Size
Issues
Publish
License
lru-cache592,435,9875,9153.33 MB322 days agoBlueOak-1.0.0
node-cache4,863,2032,372-766 years agoMIT
memory-cache630,6931,592-329 years agoBSD-2-Clause
apicache82,4311,247-635 years agoMIT

In-Memory Caching Strategies for Node.js Applications

When building server-side JavaScript applications, caching is essential for reducing database load and improving response times. The packages apicache, lru-cache, memory-cache, and node-cache all store data in memory, but they solve different problems with varying levels of control and maintenance support. Let's break down how they work and where each one fits.

🏗️ Core Design & Purpose

lru-cache is a low-level utility focused on the Least Recently Used (LRU) algorithm.

  • It evicts old items when the cache reaches a size limit.
  • Best for preventing memory leaks in long-running processes.

node-cache is a higher-level wrapper around in-memory storage.

  • It focuses on Time-To-Live (TTL) expiration.
  • Best for simple key-value pairs that should expire automatically.

apicache is Express middleware, not a general cache library.

  • It caches entire HTTP responses based on URL and headers.
  • Best for quickly caching API endpoints without writing logic.

memory-cache is a basic key-value store.

  • It allows setting values with optional expiration.
  • Best avoided now due to lack of updates.

💻 Setup & Basic Usage

Every package has a different initialization style. Here is how you create a cache instance and store data.

lru-cache requires defining a maximum size.

import { LRUCache } from 'lru-cache';

const cache = new LRUCache({
  max: 500,
  ttl: 1000 * 60 * 5 // 5 minutes
});

cache.set('foo', 'bar');
const value = cache.get('foo');

node-cache uses a simple constructor with optional TTL.

const NodeCache = require("node-cache");
const myCache = new NodeCache({ stdTTL: 100 });

myCache.set("foo", "bar");
const value = myCache.get("foo");

apicache attaches directly to an Express app.

const express = require('express');
const apicache = require('apicache');
const app = express();

app.use(apicache.middleware('60 seconds'));

app.get('/api/data', (req, res) => {
  res.json({ data: 'cached response' });
});

memory-cache uses a global singleton pattern by default.

const cache = require('memory-cache');

cache.put('foo', 'bar', 1000); // 1 second
const value = cache.get('foo');

⏳ TTL & Expiration Handling

Handling when data expires is critical for stale data prevention.

lru-cache supports TTL per item or globally.

  • Items are removed lazily when accessed after expiration.
  • You can check validity before getting.
// lru-cache
const options = { ttl: 5000 };
cache.set('key', 'value', options);
// Returns undefined if expired
const val = cache.get('key'); 

node-cache handles expiration actively.

  • It runs a check period to delete expired keys.
  • You can listen for delete events.
// node-cache
myCache.set("key", "val", 10); // 10 seconds
myCache.on("del", (key) => console.log(`${key} deleted`));

apicache handles expiration via HTTP headers.

  • You define duration in the middleware string.
  • Less control over individual items.
// apicache
// Caches for 2 minutes
app.use(apicache.middleware('2 minutes'));

memory-cache supports expiration but lacks events.

  • You pass milliseconds during put.
  • No callback when items expire.
// memory-cache
cache.put('key', 'val', 5000); // 5 seconds
// No event emitter for expiration

⚠️ Maintenance & Risk Status

Choosing a library means trusting its maintainer. Some of these packages are no longer safe for production.

lru-cache is actively maintained.

  • Regular updates and security patches.
  • Safe for critical infrastructure.

node-cache is maintained but slower to update.

  • Stable API with few breaking changes.
  • Safe for most use cases.

apicache is largely unmaintained.

  • Many open issues and pull requests ignored.
  • Risk of security vulnerabilities in middleware.

memory-cache is deprecated in practice.

  • No significant updates in years.
  • Should not be used in new projects.

📊 Feature Comparison

Featurelru-cachenode-cacheapicachememory-cache
Primary UseMemory controlSimple TTLHTTP MiddlewareBasic Key-Value
EvictionLRU AlgorithmTTL ExpirationTTL ExpirationTTL Expiration
Events❌✅ (del, exp)❌❌
Maintenance✅ Active✅ Stable❌ Unmaintained❌ Unmaintained
Express Specific❌❌✅❌

💡 Final Recommendation

lru-cache is the engine choice 🏎️ — use it when you need to build your own caching layer with strict memory limits. It powers many other libraries internally.

node-cache is the utility choice 🛠️ — use it for straightforward caching needs where TTL is more important than memory limits. It saves you from writing boilerplate.

apicache is the legacy choice 🕰️ — only use it if you are maintaining an old Express app that already depends on it. For new APIs, write custom middleware or use node-cache.

memory-cache is the avoid choice 🚫 — do not use it. It offers no advantage over node-cache and carries risk due to lack of maintenance.

Final Thought: For most modern Node.js apps, combine lru-cache for performance-critical data and node-cache for general temporary storage. Avoid apicache and memory-cache to keep your stack secure and maintainable.

How to Choose: lru-cache vs node-cache vs memory-cache vs apicache

  • lru-cache:

    Choose when you need strict memory limits and high performance. Ideal for caching function results or objects where memory growth must be controlled precisely.

  • node-cache:

    Pick for simple key-value caching with TTL needs. Great for configuration data or temporary storage without complex memory constraints.

  • memory-cache:

    Do not use in new projects. It is unmaintained and lacks features found in modern alternatives like node-cache or lru-cache.

  • apicache:

    Use only for legacy Express apps needing quick HTTP response caching without custom logic. Avoid for new projects due to lack of maintenance and limited flexibility compared to modern middleware.

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.