Memory Leaks in Lodash Memoize with Async Code

Caching asynchronous operations with Lodash’s _.memoize is a common performance optimization in JavaScript applications, but it introduces distinct memory leak vectors if not configured properly. Because _.memoize is designed for synchronous execution, applying it to asynchronous functions caches the underlying Promise object rather than the resolved data. This article covers the specific memory leak mechanisms that arise from this behavior—including unbounded cache growth, permanently retained rejected states, and closure retention chains—along with targeted patterns to prevent heap exhaustion.

Caching Promises Instead of Resolved Data

When an asynchronous function (or one returning a Promise) is wrapped with _.memoize, the function immediately returns a Promise instance. Lodash caches this Promise synchronously.

const memoizedFetch = _.memoize(async (id) => {
  const response = await fetch(`/api/item/${id}`);
  return response.json();
});

Because the cache stores the Promise reference, any memory held by the internal machinery of that Promise remains pinned in the heap for the entire lifecycle of the cache.

1. Unbounded Cache Growth (No Eviction Policy)

By default, _.memoize instantiates a standard JavaScript Map or an internal hash structure via _.memoize.Cache = Map. It possesses:

In long-running Node.js processes or single-page applications (SPAs), caching async queries across dynamic arguments (such as unique user IDs, timestamps, or search queries) causes the cache to grow infinitely, steadily consuming heap space until an out-of-memory (OOM) crash occurs.

2. Permanent Retention of Rejected Promises

When an asynchronous call fails, the returned Promise rejects. Unlike synchronous errors—which throw immediately and bypass the cache entry in _.memoize—an asynchronous rejection happens after the Promise has already been cached.

This introduces two distinct leak scenarios:

To prevent this, failed promises must manually delete themselves from the memoization cache:

const memoizedRequest = _.memoize((url) => {
  return fetch(url).catch((err) => {
    memoizedRequest.cache.delete(url);
    throw err;
  });
});

3. Closure Retention in Promise Chains

Promises maintain internal references to their chained callbacks (.then(), .catch(), and .finally()). When a promise is permanently stored in memoize.Cache:

4. Strong Object References as Cache Keys

By default, _.memoize determines its cache key using only the first argument passed to the function (arguments[0]). If an object or complex reference is passed as the first parameter:

Remediation Strategies

To eliminate memory leaks when using _.memoize with asynchronous functions:

  1. Delete Rejected Promises: Always attach a rejection handler directly to the cached promise to remove the key via memoizedFn.cache.delete(key) upon failure.
  2. Swap the Cache Implementation: Replace _.memoize.Cache with an LRU cache or a TTL-based cache (such as lru-cache or quick-lru) to enforce maximum memory limits.
  3. Use WeakMap for Object Keys: When caching data associated with specific object instances, configure _.memoize.Cache = WeakMap so keys can be garbage-collected when no longer needed elsewhere in the application.