memoize()
intermediateCache function results by argument key — and discuss cache-key strategy and memory trade-offs.
Implement lodash's _.once in JavaScript — the smallest real closure question, with the throwing-first-call policy, GC detail, and once-vs-memoize distinction.
Implement
once(fn): the returned function runsfnon the first call and returns that same result — cached forever — on every call after, without runningfnagain.
The smallest interview question that's still a real one. It's closures distilled to two variables, and because the code is trivial, everything rides on the edges: what happens when the first call throws, what happens to fn afterward, and how once differs from memoize. Small question, sharp follow-ups.
function once(fn) {
let called = false;
let result;
return function (...args) {
if (!called) {
called = true; // BEFORE the call — see the throw discussion
result = fn.apply(this, args); // forward this and args
fn = null; // release fn for garbage collection
}
return result;
};
}let count = 0;
const init = once(() => {
count += 1;
return `initialized #${count}`;
});
init(); // "initialized #1" — runs
init(); // "initialized #1" — cached; count is still 1
init(); // "initialized #1" — forevercalled and result live in the closure: invisible to callers, impossible to reset from outside, garbage-collected only with the function itself. If the interviewer asks "where is the state?", the answer is the question.
1. The throwing first call — a genuine policy fork. If fn throws, has it "been called"?
called = true before invoking (above, lodash's behavior): the attempt counts; a throw means every later call returns undefined (the assignment never happened). Guarantee: at most once, ever.called = true after: a throw leaves called false → the next call retries. Guarantee: at most one success.Neither is wrong; not knowing which you built is. For side-effect guards (the main use), at-most-once is safer — a half-completed initializer retrying can double-fire the side effects that did succeed. Name the fork, pick, justify — that's the whole senior move on this question.
2. fn = null after the call. The closure keeps fn alive forever otherwise. Usually irrelevant; meaningful when fn closes over something heavy (a big config blob, a DOM subtree). Lodash does exactly this. One line, disproportionate signal — it shows you think about what closures retain.
3. this and arguments forward on the first call only. Later calls ignore their arguments entirely and return the cached result — init("ignored") doesn't warn. That's the contract; contrast with memoize below, where arguments are the identity.
const getClient = once(() => createClient(config)).addEventListener(type, handler, { once: true }) bakes it into the DOM, and knowing that option exists (auto-removal included) is the better answer for listener use cases than wrapping by hand.once(fn) | memoize(fn) | |
|---|---|---|
| Caches | one result, period | one result per argument key |
| Later args | ignored | are the cache key |
| State | boolean + value | a Map that can grow |
| Job | side-effect guard | computation cache |
One-liner: once is memoize with a constant cache key — and if you can say that, you can implement either from the other, which is occasionally the actual follow-up task.
undefined result — fn legitimately returning undefined is indistinguishable from "never ran" if you guard on result !== undefined instead of a boolean flag — the flag isn't optional bookkeeping, it's correctness.once returns the same in-flight promise (the promise is the result, cached synchronously) — accidental but correct request-dedupe, same mechanism as memoizing API calls. If the promise rejects, at-most-once caches the rejection forever — tie back to the throw policy.this on the first call — method usage (obj.init = once(function () { ... })) needs the function/apply wiring; an arrow wrapper breaks it (why).if (!result)) — breaks for falsy/undefined returns.this).reset(), that's an explicit API addition (once.reset = () => { called = false; }) with thread-of-control caveats, not a freebie.Map-based memoize when the question is a boolean and a value.once such that a throw allows retry." — move called = true after the call; state the changed guarantee (at most one success).nTimes(fn, n)." — generalize the boolean to a counter; once is nTimes(fn, 1) — checks the abstraction transfers.once that retries failed promises?" — cache the promise, but on rejection clear the slot (the same eviction move as API memoization).{ once: true } in addEventListener differ?" — the platform also removes the listener, releasing it for GC — strictly more than the wrapper does; knowing the difference is the point.wasCalled() — API-design in miniature.Cache function results by argument key — and discuss cache-key strategy and memory trade-offs.
Pre-fill leading arguments of a function — partial application, and how it differs from currying.
A single-slot memoizer that remembers only the previous call — the pattern behind React's useMemo.
Compose functions left-to-right into a pipeline — the one-liner with a lot of interview depth.