Lodash _.memoize: Fix the Cache Key Before You Chase the Speedup
Lodash's _.memoize keys its cache on the first argument by default. Here's how that breaks multi-argument functions, how a resolver fixes it, and how to check the cache.
19 Sept 2026, 23:10 UTC

The symptom: the same expensive call, over and over
You have a function that is pure, deterministic, and slow enough to notice — formatting a currency amount with Intl.NumberFormat, or normalizing a config string into an object. It gets called from a render path with a small set of recurring arguments, and it recomputes every time.
_.memoize from Lodash 4.x (the 4.17 line is the widely deployed version) is the one-line version of "remember the answer." The catch is that its default cache key is the first argument only, and that single design detail is where most memoization bugs originate.
What _.memoize actually keys on
By default, the value of the first argument becomes the cache key. A resolver function can override that, and the memoized function exposes its live cache as memoized.cache. Everything else follows from those three facts.
const _ = require('lodash');
function formatPrice(amount, currency) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
}).format(amount);
}
const memoFormat = _.memoize(formatPrice);
memoFormat(10, 'USD'); // "$10.00"
memoFormat(10, 'EUR'); // "$10.00" <-- cache hit on key 10
Both calls share the key 10. The second call never reaches formatPrice; it returns the stored USD string. The function is not broken — the key is.
Fixing it with a resolver
const memoFormat = _.memoize(
formatPrice,
(amount, currency) => `${currency}:${amount}`
);
memoFormat(10, 'USD'); // "$10.00"
memoFormat(10, 'EUR'); // "€10.00"
The resolver receives the same arguments as the memoized function and must return a value that uniquely identifies the input combination. Strings and numbers work directly as cache keys. Objects do not: two structurally identical objects are different keys, so either serialize them deliberately or use a WeakMap-based cache and pass the object itself.
The trade-off is memory, and it is unbounded by default
Lodash's memoize does not evict. Every distinct key stays for the lifetime of the memoized function. With a resolver that produces a bounded key space — currency codes, enum values, a handful of IDs — that is fine. With a resolver that includes a timestamp, a user ID, or free-form text, the cache becomes a slow memory leak.
- Cheap functions: if the computation is a couple of property reads, the cache lookup plus retained memory can cost more than the work saved.
- Side effects: memoization skips the function body on a hit. Logging, counters, network calls, and mutations inside the function will silently not happen.
- Mutable inputs: if you memoize on an object reference and then mutate the object, the cached result is stale and nothing warns you.
Two ways to bound it: replace the cache constructor via _.memoize.Cache with a Map so you can call memoized.cache.clear() at a known boundary, or swap in an LRU implementation that provides has, get, and set. Assigning _.memoize.Cache is global — it affects every memoized function created afterwards — so set it once at startup or restore it immediately after creating the memoized function.
const _ = require('lodash');
const OriginalCache = _.memoize.Cache;
_.memoize.Cache = Map;
const memoFormat = _.memoize(formatPrice, (a, c) => `${c}:${a}`);
_.memoize.Cache = OriginalCache;
// later, at a safe boundary:
memoFormat.cache.clear();
With the default MapCache, memoFormat.cache is still the live cache object and exposes set, get, has, and delete. A size property exists on a plain Map, but it is not part of Lodash's documented public surface, so prefer counting entries yourself or using a Map if you need to monitor growth.
Verifying that the cache is doing what you think
Run these in Node or the browser console against the module you actually ship. The goal is to confirm hits and keying, not to benchmark.
- Confirm hits happen. Wrap the underlying function with a counter, memoize the wrapper, call twice with identical arguments, and assert the counter is 1.
- Confirm distinct inputs stay distinct. Call with two argument sets that differ only in the second argument and assert the results differ.
- Confirm the key space is bounded. After a realistic run, inspect
memoized.cacheand count how many entries accumulated. If it grows with traffic, the resolver is the problem. - Confirm timing actually improves. Time a loop of repeated calls before and after memoization with the same inputs. If the difference is inside noise, drop the memoization.
let calls = 0;
const slow = (x) => { calls++; return x * 2; };
const memoSlow = _.memoize(slow);
memoSlow(21); memoSlow(21);
console.log(calls); // expected 1
memoSlow(21); memoSlow(22);
console.log(calls); // expected 2
Those expected values follow from the documented behavior of Lodash 4.x. Run the snippet against your installed version rather than assuming them.
When to reach for it
Memoize when all three conditions hold: the function is pure, the input key space is small and stable, and the computation is expensive relative to a cache lookup. Otherwise use a plain Map you control, cache at a higher level such as a request-scoped object or a build step, or leave the function alone.
If you only need to memoize on object identity, a WeakMap avoids the retention problem entirely because entries are garbage-collected with their keys — but it accepts object keys only, so it is not a drop-in replacement for primitive arguments.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.