What Is a Thunk? The Function Wrapper That Delays Evaluation

- A thunk is a zero-argument function that wraps a deferred computation — calling it "forces" the result
- Invented in 1961 by Peter Ingerman for ALGOL 60 call-by-name argument evaluation; the name is literally the past tense of "think"
- Un-memoized thunks defer work but repeat it on every force; memoized thunks compute once and cache the result forever
- Haskell uses lazy memoized thunks for every binding by default; Python generators share the core idea without the caching
- In interviews, thunks appear as memoization wrappers, top-down DP caches, generator iterators, and lazy loading patterns in system design
You have a value. You don't need it yet. But the code around you is about to compute it anyway, like a contractor who starts drilling the moment you open the front door.
That's the problem a thunk solves. A thunk is a zero-argument function that wraps a computation to defer it until later. No new syntax, no special runtime support. Just a function you don't call right away. The word surfaces in Redux docs, DP explainers, Haskell tutorials, and compiler theory, and each time it means the same thing: someone decided to compute this later, and they left you a wrapper.
The Name Is Funnier Than the Idea
Peter Zilahy Ingerman coined "thunk" in 1961 while working on ALGOL 60 compilers. ALGOL used call-by-name semantics: when you passed an argument to a procedure, the argument wasn't evaluated up front. Instead, the compiler generated a small helper subroutine that computed the argument's value on demand.
Ingerman called these helpers "thunks." The reasoning: the analysis had already been done at two in the morning. Thunk is the past tense of "think." A thunk is something that has already been thought about.
Sixty-five years later, the same word describes the same concept across JavaScript, Haskell, Python, and Rust. The name stuck because the idea is genuinely useful, and also because engineers named things differently when the sun wasn't up.
What a Thunk Is: A Zero-Argument Function
Start with eager evaluation:
result = expensive_query(user_id) # runs immediately, result or not
Wrap it in a function:
get_result = lambda: expensive_query(user_id) # nothing runs yet result = get_result() # runs here
get_result is a thunk. The computation lives inside a closure. Calling the thunk "forces" the computation. Until then, it costs nothing.
Same idea in TypeScript:
const getResult = () => expensiveQuery(userId); const result = getResult();
That's the complete concept. Everything else is this pattern applied somewhere specific: lazy evaluation, memoization, Redux middleware. The hard part isn't the idea. The hard part is recognizing it when it shows up in a codebase wearing different clothes.
Lazy Evaluation: Never Compute What You Don't Need
Some languages treat every expression as a thunk by default. Haskell is the canonical example. Nothing is evaluated until a value is actually needed. Haskell programmers will tell you this is enlightened. Haskell codebases will show you the six-page stack overflow where the thunks quietly accumulated.
-- Infinite list. Haskell never blows up because each element -- is a thunk that's forced only when accessed. naturals = [1..] take 5 naturals -- forces exactly 5 thunks: [1, 2, 3, 4, 5]
Lazy evaluation lets you model infinite sequences without running out of memory. Each element is a thunk. You pay only for the elements you access.
Python's generators work on the same principle. yield suspends execution and returns a generator object. The next value isn't computed until next() is called:
def naturals(): n = 1 while True: yield n n += 1 gen = naturals() next(gen) # 1, computed now next(gen) # 2, computed now # the "infinite" sequence never exists in memory all at once
The difference from Haskell: Python generators aren't memoized. Force a generator element a second time and you'd have to replay the sequence from the start. Haskell's lazy thunks cache their result after the first evaluation, which brings us to memoization.
Lazy evaluation trades predictability for efficiency. The upside: you never pay for a value you don't use. The downside: thunks can accumulate faster than they're forced, building up in memory as unevaluated closures until something finally needs them. This is the source of Haskell's notorious space leaks. The pattern is powerful, but it's not free. See Eager vs. Lazy Evaluation for how these two strategies compare in practice.
Memoization: Force Once, Cache Forever
A memoized thunk computes its value on the first call, stores it, and returns the cached value on every subsequent call. This is the version Haskell uses internally for every binding. It's also the version every DP solution uses, whether or not the word "thunk" ever appears in the code.
class Thunk: def __init__(self, fn): self._fn = fn self._computed = False self._value = None def force(self): if not self._computed: self._value = self._fn() self._computed = True return self._value result = Thunk(lambda: sum(range(10_000_000))) result.force() # ~100ms, caches result result.force() # ~0ms result.force() # ~0ms
This maps directly onto memoized dynamic programming. When you write a top-down DP solution with a cache, each recursive call that checks the cache before computing is forcing a memoized thunk. The call graph visits each subproblem once, computes it, and stores the result. Every subsequent visit is O(1). This is part of why top-down DP is often easier to write correctly under pressure: you only reason about one subproblem at a time.
The subproblem cache in DP is just a dictionary of memoized thunks indexed by state. What Is Memoization and DP vs Memoized Recursion cover the same mechanics from the algorithm side.
Redux: The Middleware That Calls Your Thunk
Here's a confession: the majority of JavaScript developers who use redux-thunk every day have never thought about what "thunk" actually means. They type const fetchUser = (id) => async (dispatch) => { ... } and it works and they ship. Fair enough.
Redux expects action creators to return plain objects. But async operations like fetching from an API don't fit in a plain object. redux-thunk middleware solves this: if you dispatch a function instead of an object, the middleware calls that function with dispatch and getState as arguments. That function is the thunk.
// Without thunk middleware: must be a plain object dispatch({ type: 'SET_USER', user: hardcodedUser }); // With redux-thunk: action creator returns a function (the thunk) const fetchUser = (userId: number) => async (dispatch: Dispatch) => { dispatch({ type: 'FETCH_USER_START' }); const user = await api.getUser(userId); dispatch({ type: 'FETCH_USER_SUCCESS', user }); }; // The middleware sees a function and calls it store.dispatch(fetchUser(42));
The middleware's core logic is embarrassingly simple. The whole thing:
const thunkMiddleware = (store) => (next) => (action) => { if (typeof action === 'function') { return action(store.dispatch, store.getState); } return next(action); };
If the action is a function, call it. If it's an object, pass it along. That's the entire implementation. A 60-year-old idea from ALGOL 60, powering async state management in 2026. Redux Toolkit's createAsyncThunk wraps this with less boilerplate, but the underlying mechanics are the same.
The Complexity Numbers Are Simple
Thunks have predictable cost characteristics, and this is where interviews will go if you bring up deferred computation.
A thunk itself is a closure: a function pointer plus captured variable references. Allocation is O(1). Storage is O(k) where k is the number of captured variables.
What varies is when you pay for the underlying computation:
| Strategy | Time per force | Total time (k forces) | Space |
|---|---|---|---|
| Eager evaluation | O(f) at creation | O(f) total | O(1) wrapper |
| Un-memoized thunk | O(f) per force | O(k·f) total | O(1) wrapper |
| Memoized thunk | O(f) first, O(1) after | O(f) total | O(f) cached value |
Un-memoized thunks defer cost. Memoized thunks eliminate repeated cost at the price of space.
For DP, this distinction matters. A memoized recursive solution only pays for the subproblems it actually visits. A bottom-up DP table pays for the full state space. If the recursion skips large regions of the state space, top-down memoization wins on both time and space. If every state is visited anyway, bottom-up is typically faster in practice due to cache locality.
Interviews Never Say "Thunk." They Ask for Thunks Anyway.
The word won't appear on a LeetCode problem. But the pattern shows up constantly. Knowing the vocabulary makes the explanation cleaner, which matters more than you'd think.
Closures that defer work. Any time an interviewer asks you to build a factory function or a memoized wrapper, you're writing a thunk. "Write a function that memoizes the result of an expensive computation" is asking for a memoized thunk. Now you can say that.
Generators and lazy sequences. "Generate Fibonacci numbers without precomputing them" or "build an infinite iterator" is asking for lazy thunks. The value exists in concept, not memory, until accessed.
Top-down DP. When you add a cache to a recursive function, every cache-miss entry is a thunk being forced for the first time. Every cache-hit is a previously forced thunk returning its stored value. You're operating a dictionary of memoized thunks, and saying so in an interview is the kind of abstraction that scores points in the "communication" dimension.
System design follow-ups. Interviewers often ask how to avoid recomputing something in a distributed cache, a news feed, or a recommendation system. The answer almost always involves deferred computation: compute the expensive result once, store it, serve from the store on subsequent requests. The vocabulary changes (cache-aside, lazy hydration, write-through) but the structure is identical. A thunk by any other name.
Practicing these patterns under pressure is different from reading about them. SpaceComplexity puts you in a real-time mock interview where follow-up questions force you to explain the "why" behind decisions like when to memoize and when not to. That's the muscle the actual interview tests.
Key Takeaways
- A thunk is a zero-argument function wrapping a deferred computation. Force it to run it.
- Invented by Peter Ingerman in 1961 for ALGOL 60, named because the analysis was already done (at 2am, apparently).
- Un-memoized: defers work, repeats it on each force. Memoized: computes once, caches the result.
- Haskell treats every expression as a memoized thunk. Python generators share the core idea without the caching.
- In interviews, thunks appear as memoization wrappers, generator iterators, top-down DP caches, and lazy loading in system design.