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:
- No Maximum Size: It does not limit the number of entries.
- No Time-to-Live (TTL): Entries never expire automatically.
- No Least-Recently-Used (LRU) Eviction: Old keys are never purged to free space.
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:
- Error and Stack Trace Retention: The rejected
Promiseretains its rejection reason, which is frequently anErrorinstance. In V8,Errorinstances retain entire call-stack frames and surrounding closure scopes, keeping large swaths of memory reachable by the garbage collector. - Dead State Poisoning: Subsequent calls with identical arguments will continuously return the rejected promise rather than retrying the operation, while permanently hoarding memory for an unusable result.
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:
- Every reference captured inside the asynchronous function's scope remains pinned in memory.
- If the cached promise is consumed by other modules that chain
.then()handlers onto it, those handlers and their own enclosing lexical scopes can remain attached to the promise's internal reaction records, preventing garbage collection of unrelated components, DOM elements, or heavy payloads.
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:
- The default
Mapcache retains a strong reference to that object key. - The object cannot be garbage collected even if all other application references to it are severed.
- If a custom resolver converts objects to strings (such as
JSON.stringify), the resulting large strings become permanent residents in memory, compounding memory bloat over time.
Remediation Strategies
To eliminate memory leaks when using _.memoize with
asynchronous functions:
- Delete Rejected Promises: Always attach a rejection
handler directly to the cached promise to remove the key via
memoizedFn.cache.delete(key)upon failure. - Swap the Cache Implementation: Replace
_.memoize.Cachewith an LRU cache or a TTL-based cache (such aslru-cacheorquick-lru) to enforce maximum memory limits. - Use WeakMap for Object Keys: When caching data
associated with specific object instances, configure
_.memoize.Cache = WeakMapso keys can be garbage-collected when no longer needed elsewhere in the application.