Learn when Clojure memoization speeds up repeated work, when the built-in cache is the wrong tool, and how to avoid common traps with recursive and side-effecting functions.
Memoization is one of the simplest ways to trade memory for speed, but it only helps when the function and workload actually fit that trade. Applied well, it can eliminate repeated expensive computation. Applied casually, it can create stale results, unbounded memory growth, or misleading benchmarks.
Memoization: Caching the result of a function call so repeated calls with the same inputs can reuse prior work instead of recomputing it.
In Clojure, the main tool is memoize. It is intentionally small and opinionated: thread-safe, easy to use, and unbounded. That makes it powerful for the right cases and dangerous for the wrong ones.
memoize HelpsMemoization is a strong fit when all of these are mostly true:
Typical examples include:
If one of those assumptions breaks, plain memoization becomes much less attractive.
memoize wraps a function and stores results keyed by its arguments:
1(def expensive->report
2 (memoize
3 (fn [account-id]
4 (Thread/sleep 500)
5 {:account-id account-id
6 :score (* 10 account-id)})))
Design-wise, that means:
That is perfect for some workloads and a bad fit for many service-layer caching problems.
One common teaching example uses Fibonacci. But many introductions accidentally demonstrate the wrong memoization pattern.
This version looks reasonable but does not fix the recursive explosion:
1(defn slow-fib [n]
2 (if (< n 2)
3 n
4 (+ (slow-fib (- n 1))
5 (slow-fib (- n 2)))))
6
7(def fast-fib (memoize slow-fib))
The recursive calls inside slow-fib still call slow-fib, not fast-fib, so they bypass the cache.
The correct approach routes recursion back through the memoized var:
1(declare fib)
2
3(def fib
4 (memoize
5 (fn [n]
6 (if (< n 2)
7 n
8 (+ (fib (- n 1))
9 (fib (- n 2)))))))
That works because overlapping subproblems now reuse cached results instead of recomputing them.
Plain memoization is the wrong tool for:
For example, this is a bad fit:
1(def latest-profile
2 (memoize
3 (fn [user-id]
4 (http/get (str "https://api.example.com/users/" user-id)))))
Now cache policy, staleness, and network behavior are all hidden behind one function wrapper with no expiration story.
Often the better question is not “Should I memoize this function?” but “At what scope should this value be cached?”
Possible scopes include:
Plain memoize is usually strongest at the first two. Once you need TTLs, eviction, shared consistency, metrics, or explicit invalidation, you are designing a caching policy, not just wrapping a function.
Before memoizing a function, ask:
If the answer to several of those is “no”, plain memoization is probably not the right design.
graph TD;
A["Function call"] --> B{"Pure and stable for same args?"}
B -->|No| C["Do not memoize directly"]
B -->|Yes| D{"Repeated calls with same args?"}
D -->|No| E["Cache adds little value"]
D -->|Yes| F{"Input space bounded enough?"}
F -->|No| G["Need bounded or expiring cache strategy"]
F -->|Yes| H["`memoize` is a good candidate"]
The diagram below is the right decision model: memoization is not a generic performance flag. It is a very specific bet on repeated pure work and acceptable cache lifetime.