Memoize: Getting Lazy and Eager at the Same Time

A hand-rolled cache-once trick technically stays pure, but reads as suspicious. Wrapping the same function with memoize gets the same speed without the doubt.

September 12, 20263 min read21 / 27

Lazy and eager were framed as a trade-off. Most trade-offs have a middle ground worth checking for, and this one does.

The Tempting Hybrid

What if the divider only computed its string the first time it's actually called, then reused that result forever after?

JavaScript
function makeDivider(width) { let line; return function divider() { if (line === undefined) { line = '-'.repeat(width); } return line; }; }

Deferred until the first real call, computed exactly once, cached for every call after that. Genuinely the best of both posts ago's trade-off.

Why This Should Make You Suspicious

line starts as undefined and gets reassigned to a string. The rule from several posts back was direct: never close over something that gets reassigned.

Here's the twist: call divider() any number of times, and you always get the same string back. By the strict definition, this is a pure function call. But nothing about reading the code makes that obvious. A reader scanning this would reasonably assume it's impure, purely because it reassigns something it closes over, and then have to work it out for themselves.

Pure, but not obviously pure, costs you almost the entire benefit of writing pure code in the first place. The whole point was never needing to double-check.

The Fix: Move the Suspicious Part Somewhere Trusted

The answer isn't to abandon the caching. It's to stop writing the suspicious part yourself:

JavaScript
function memoize(fn) { const cache = new Map(); return function memoized(...args) { const key = JSON.stringify(args); if (!cache.has(key)) { cache.set(key, fn(...args)); } return cache.get(key); }; }

Now go back to the original, simple, obviously-pure makeDivider from the last post, unchanged:

JavaScript
function makeDivider(width) { return function divider() { return '-'.repeat(width); }; } const divider40 = memoize(makeDivider(40)); divider40(); // computed once divider40(); // returned from cache

The original function never needed to change. memoize does the exact same reassignment trick internally, the cache genuinely does get mutated. It's just contained inside one small, well-known utility instead of scattered through your own function bodies.

Hand-rolled cache

Reassigns a closed-over variable inside your own function body.

memoize(fn)

Same reassignment trick, but contained in one trusted, well-known place.

Why Hiding It There Actually Helps

A reader who sees memoize(makeDivider(40)) doesn't need to verify anything. They either already know what memoize does, or they check it once and trust it everywhere after that. Compare that to the hand-rolled version, where every single occurrence has to be re-read and re-verified on its own.

This is the same idea from the containment posts: the impurity-shaped code didn't disappear, it just moved somewhere it only has to be trusted once.

When Memoizing Actually Helps

Caching isn't free. Every memoized function holds onto a growing cache in memory, and every call pays a small lookup cost checking it.

That cost is worth it only when the same inputs are genuinely likely to repeat. A function called constantly with a small set of repeating inputs benefits enormously. A function called constantly with a different input every time gains nothing and just leaks memory into a cache that never gets reused. Ask which one you're dealing with before reaching for memoize, not after.